From 04db22636ffe50f67dc6255506dd791d212ce953 Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Wed, 7 Oct 2026 15:03:32 -0700 Subject: [PATCH 01/44] Clarify global flag help descriptions and categories --- cmd/openai/flag_descriptions_test.go | 166 ++++++++++++++++++++++ cmd/openai/main_help_test.go | 2 +- pkg/custom/help.go | 83 ++++++++++- pkg/custom/help_test.go | 76 ++++++++++ scripts/demos/record-flag-descriptions.sh | 100 +++++++++++++ 5 files changed, 420 insertions(+), 7 deletions(-) create mode 100644 cmd/openai/flag_descriptions_test.go create mode 100755 scripts/demos/record-flag-descriptions.sh diff --git a/cmd/openai/flag_descriptions_test.go b/cmd/openai/flag_descriptions_test.go new file mode 100644 index 00000000..09d836f0 --- /dev/null +++ b/cmd/openai/flag_descriptions_test.go @@ -0,0 +1,166 @@ +package main + +import ( + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMainGlobalFlagErrorTransformExample(t *testing.T) { + help := runMainDispatch(t, "bash", "openai", "help", "--all") + require.Zero(t, help.code, help.stderr) + _, description, found := strings.Cut(help.stdout, "\n --transform-error ") + require.True(t, found) + description, _, _ = strings.Cut(description, "\n --") + _, example, found := strings.Cut(description, "such as '") + require.True(t, found, "missing error extraction example") + path, _, found := strings.Cut(example, "'") + require.True(t, found) + result := runMainAPIErrorResponse(t, http.StatusBadRequest, "application/json", + `{"error":{"message":"synthetic example message","type":"invalid_request_error"}}`, + "--transform-error", path) + require.NotZero(t, result.code) + require.Empty(t, result.stdout) + require.Equal(t, "\"synthetic example message\"\n", result.stderr) +} + +func TestMainGlobalFlagDescriptions(t *testing.T) { + groups := []struct { + title string + flags []string + }{ + {"Authentication", []string{"--api-key", "--admin-api-key", "--webhook-secret", "--mtls-client-cert-file", "--mtls-client-key-file"}}, + {"Output", []string{"--format", "--format-error", "--transform", "--transform-error", "--raw-output"}}, + {"Request options", []string{"--organization", "--project", "--base-url", "--header"}}, + {"Troubleshooting", []string{"--debug"}}, + } + for _, path := range [][]string{nil, {"models", "list"}, {"images"}, {"images", "generate"}} { + t.Run(strings.Join(append([]string{"root"}, path...), "/"), func(t *testing.T) { + args := append([]string{"openai", "help", "--all"}, path...) + got := runMainDispatchWithEnv(t, "bash", nil, args...) + require.Zero(t, got.code, got.stderr) + require.Empty(t, got.stderr) + remaining := got.stdout + for i, group := range groups { + _, after, found := strings.Cut(remaining, "\n "+group.title+"\n") + require.True(t, found, "missing or reordered group %s", group.title) + nextTitle := "Other options" + if i+1 < len(groups) { + nextTitle = groups[i+1].title + } + section, _, _ := strings.Cut(after, "\n "+nextTitle+"\n") + for _, flag := range group.flags { + // These root-local request flags become inherited in the separate globals task. + if len(path) > 0 && (flag == "--api-key" || flag == "--admin-api-key" || flag == "--webhook-secret" || flag == "--organization" || flag == "--project") { + continue + } + require.Contains(t, section, "\n "+flag, "%s must appear in %s", flag, group.title) + } + remaining = after + } + text := strings.Join(strings.Fields(got.stdout), " ") + for _, description := range []string{ + "Format names are case-insensitive.", + "readable text, including pipes", + "raw: unformatted JSON.", + "raw returns one API page envelope.", + "Does not change error output.", + "GJSON path syntax.", + "the path applies to each item, except in the interactive explore viewer.", + "With --format raw, the path applies to the page.", + "If the path does not match, keep the original result.", + "errors are displayed on stderr.", + "Inherits json, jsonl, raw, or yaml from --format unless explicitly set.", + "API and local errors use 'message'; streamed errors can use 'error.message'.", + "Repeat for multiple headers; the last value for each header name wins.", + "OPENAI_CUSTOM_HEADERS accepts one 'Name: Value' header per line.", + "Flags override matching environment headers.", + "https://api.openai.com/v1", + } { + require.Contains(t, text, description) + } + for _, name := range []string{"OPENAI_BASE_URL", "OPENAI_CUSTOM_HEADERS", "OPENAI_MTLS_CLIENT_CERT_FILE", "OPENAI_MTLS_CLIENT_KEY_FILE"} { + require.Contains(t, text, name) + } + if len(path) == 0 { + for _, description := range []string{ + "API key used to authenticate API requests.", + "Admin API key used to authenticate organization administration requests.", + "Organization ID to send in the OpenAI-Organization request header.", + "Project ID to send in the OpenAI-Project request header.", + "The CLI has no webhook verification command.", + "OPENAI_API_KEY", "OPENAI_ADMIN_KEY", "OPENAI_WEBHOOK_SECRET", "OPENAI_ORG_ID", "OPENAI_PROJECT_ID", + } { + require.Contains(t, text, description) + } + } + }) + } +} + +func TestMainGlobalFlagHelpHidesConfiguredValues(t *testing.T) { + env := []string{ + "OPENAI_API_KEY=synthetic-env-api-secret", "OPENAI_ADMIN_KEY=synthetic-env-admin-secret", + "OPENAI_WEBHOOK_SECRET=synthetic-env-webhook-secret", "OPENAI_ORG_ID=synthetic-env-org-secret", + "OPENAI_PROJECT_ID=synthetic-env-project-secret", + "OPENAI_BASE_URL=https://synthetic-env-user:synthetic-env-password@example.invalid/v1?key=synthetic-env-url-secret", + "OPENAI_CUSTOM_HEADERS=X-First: synthetic-env-header-secret\nX-Second: synthetic-env-header-second-secret", + "OPENAI_MTLS_CLIENT_CERT_FILE=/synthetic-env-cert-secret.pem", + "OPENAI_MTLS_CLIENT_KEY_FILE=/synthetic-env-key-secret.pem", + } + flags := []string{ + "--api-key", "synthetic-flag-api-secret", "--admin-api-key", "synthetic-flag-admin-secret", + "--webhook-secret", "synthetic-flag-webhook-secret", "--organization", "synthetic-flag-org-secret", + "--project", "synthetic-flag-project-secret", + "--base-url", "https://synthetic-flag-user:synthetic-flag-password@example.invalid/v1?key=synthetic-flag-url-secret", + "--header", "X-First: synthetic-flag-header-secret", "-H", "X-Second: synthetic-flag-header-second-secret", + "--mtls-client-cert-file", "/synthetic-flag-cert-secret.pem", "--mtls-client-key-file", "/synthetic-flag-key-secret.pem", + } + for _, explicit := range []bool{false, true} { + for _, route := range [][]string{ + {"--help"}, {"images", "generate", "--help"}, {"models", "list", "--help"}, + {"help", "--all"}, {"help", "--all", "images", "generate"}, {"help", "--all", "models", "list"}, + } { + name := "environment/" + if explicit { + name = "flags/" + } + t.Run(name+strings.Join(route, "/"), func(t *testing.T) { + args := []string{"openai"} + if explicit { + args = append(args, flags...) + } + got := runMainDispatchWithEnv(t, "bash", env, append(args, route...)...) + require.Zero(t, got.code, got.stderr) + require.Empty(t, got.stderr) + require.NotEmpty(t, got.stdout) + for _, secret := range []string{"synthetic-env-", "synthetic-flag-", "example.invalid"} { + require.NotContains(t, got.stdout+got.stderr, secret) + } + if route[0] == "help" { + require.Contains(t, got.stdout, "https://api.openai.com/v1") + require.Contains(t, got.stdout, "OPENAI_BASE_URL") + } + }) + } + } +} + +func TestMainGlobalFlagDescriptionsPreserveRequestProject(t *testing.T) { + for _, path := range [][]string{{"admin", "organization", "invites", "create"}, {"admin:organization:invites", "create"}} { + t.Run(strings.Join(path, "/"), func(t *testing.T) { + args := append([]string{"openai", "help", "--all"}, path...) + got := runMainDispatchWithEnv(t, "bash", nil, args...) + require.Zero(t, got.code, got.stderr) + require.Empty(t, got.stderr) + _, after, found := strings.Cut(got.stdout, "\n --project ") + require.True(t, found, "invite help must retain its request project flag") + local, _, _ := strings.Cut(after, "\n --") + text := strings.Join(strings.Fields(local), " ") + require.Contains(t, text, "An array of projects to which membership is granted") + require.NotContains(t, text, "OpenAI-Project request header") + }) + } +} diff --git a/cmd/openai/main_help_test.go b/cmd/openai/main_help_test.go index c5e0bb4a..32b350a3 100644 --- a/cmd/openai/main_help_test.go +++ b/cmd/openai/main_help_test.go @@ -200,7 +200,7 @@ func TestMainHelpKeepsAPIProjectInCommandOptions(t *testing.T) { if !ok || !strings.Contains(local, "--project any") || !strings.Contains(local, "membership is granted") { t.Fatalf("API project input is missing: %s", got.stdout) } - for _, heading := range []string{"Request configuration", "Response output"} { + for _, heading := range []string{"Authentication", "Output", "Request options", "Troubleshooting"} { if strings.Contains(local, heading) || !strings.Contains(global, heading) { t.Errorf("%q should group only global flags: %s", heading, got.stdout) } diff --git a/pkg/custom/help.go b/pkg/custom/help.go index 297290b6..0447741e 100644 --- a/pkg/custom/help.go +++ b/pkg/custom/help.go @@ -11,18 +11,87 @@ import ( // ConfigureHelp decorates help without changing API commands or their defaults. func ConfigureHelp(root *cli.Command, args []string) ([]string, bool, error) { orderImageCommands(root) + configureGlobalFlagDescriptions(root) configureHelpGroups(root) configureImageHelpInvocation(root, clihelp.Invocation(root.Name, args)) - // Full help documents credential flags, but must never echo their values. + return clihelp.Configure(root, args) +} + +// Apply copy to root-owned flags only. A request field with the same name keeps +// its generated description, sources, defaults, and parser behavior. +func configureGlobalFlagDescriptions(root *cli.Command) { for _, flag := range root.Flags { - if flag, ok := flag.(*requestflag.Flag[string]); ok { + // Root flags can retain their request flag behind a persistent wrapper. + if wrapped, ok := flag.(interface { + RequestFlag() *requestflag.Flag[string] + }); ok { + flag = wrapped.RequestFlag() + } + switch flag := flag.(type) { + case *requestflag.Flag[string]: + switch flag.Name { + case "api-key": + flag.Usage = "API key used to authenticate API requests." + case "admin-api-key": + flag.Usage = "Admin API key used to authenticate organization administration requests." + case "organization": + flag.Usage = "Organization ID to send in the OpenAI-Organization request header." + case "project": + flag.Usage = "Project ID to send in the OpenAI-Project request header." + case "webhook-secret": + flag.Usage = "Webhook signing secret passed to the SDK. The CLI has no webhook verification command." + default: + continue + } + // Environment names remain visible, but configured values never appear. + flag.HideDefault = true + case *cli.StringFlag: switch flag.Name { - case "api-key", "admin-api-key", "webhook-secret": + case "base-url": + flag.Usage = "Send API requests to this endpoint. Env: OPENAI_BASE_URL." + flag.DefaultText = "https://api.openai.com/v1" + case "format": + flag.Usage = "Choose how results are displayed. Format names are case-insensitive.\n" + + "auto: readable text, including pipes; --transform or --raw-output selects JSON handling.\n" + + "text: readable summary; json: full JSON; jsonl: one JSON value per line; yaml: YAML.\n" + + "explore: interactive JSON viewer (JSON when piped); pretty: styled JSON; raw: unformatted JSON.\n" + + "For paginated lists, raw returns one API page envelope. It differs from --raw-output." + case "format-error": + flag.Usage = "Choose how errors are displayed on stderr. Uses the same formats as --format.\n" + + "Defaults to readable text. Inherits json, jsonl, raw, or yaml from --format unless explicitly set.\n" + + "Use json for full API error details. With auto, --transform-error selects JSON handling." + case "transform": + flag.Usage = "Show part of a JSON result, such as 'id', using GJSON path syntax.\n" + + "For paginated lists, the path applies to each item, except in the interactive explore viewer.\n" + + "With --format raw, the path applies to the page.\n" + + "If the path does not match, keep the original result." + case "transform-error": + flag.Usage = "Show part of a JSON error, such as 'message', using GJSON path syntax.\n" + + "API and local errors use 'message'; streamed errors can use 'error.message'.\n" + + "If the path does not match, keep the original error." + case mtlsClientCertFileFlag: + flag.Usage = "PEM client certificate file for mutual TLS (mTLS). Use with --mtls-client-key-file.\n" + + "Requires an explicit HTTPS endpoint through --base-url or OPENAI_BASE_URL.\n" + + "For certificate chains, put the client certificate first, then intermediates." + flag.HideDefault = true + case mtlsClientKeyFileFlag: + flag.Usage = "PEM private key file matching the mTLS client certificate. Use with --mtls-client-cert-file." flag.HideDefault = true } + case *cli.BoolFlag: + switch flag.Name { + case "raw-output": + flag.Usage = "Print string results without JSON quotes. Useful with --transform. Does not change error output." + case "debug": + flag.Usage = "Write HTTP request and response diagnostics to stderr for troubleshooting. May include sensitive data." + } + case *requestHeaderFlag: + flag.Usage = "Add an HTTP request header as 'Name: Value'.\n" + + "Repeat for multiple headers; the last value for each header name wins.\n" + + "Example: -H 'X-Request-Tag: cli-test'.\n" + + "Env: OPENAI_CUSTOM_HEADERS accepts one 'Name: Value' header per line. Flags override matching environment headers." } } - return clihelp.Configure(root, args) } func configureImageHelpInvocation(root *cli.Command, invocation string) { @@ -45,8 +114,10 @@ func configureImageHelpInvocation(root *cli.Command, invocation string) { func configureHelpGroups(root *cli.Command) { groups := []clihelp.FlagGroup{ - {Title: "Response output", Names: []string{"format", "format-error", "transform", "transform-error", "raw-output"}, Owner: root}, - {Title: "Request configuration", Names: []string{"api-key", "admin-api-key", "webhook-secret", "organization", "project", "base-url", "header", "mtls-client-cert-file", "mtls-client-key-file", "debug"}, Owner: root}, + {Title: "Authentication", Names: []string{"api-key", "admin-api-key", "webhook-secret", "mtls-client-cert-file", "mtls-client-key-file"}, Owner: root}, + {Title: "Output", Names: []string{"format", "format-error", "transform", "transform-error", "raw-output"}, Owner: root}, + {Title: "Request options", Names: []string{"organization", "project", "base-url", "header"}, Owner: root}, + {Title: "Troubleshooting", Names: []string{"debug"}, Owner: root}, } var visit func(*cli.Command, bool) visit = func(command *cli.Command, image bool) { diff --git a/pkg/custom/help_test.go b/pkg/custom/help_test.go index 0cbc39af..e4b89bf8 100644 --- a/pkg/custom/help_test.go +++ b/pkg/custom/help_test.go @@ -1,13 +1,89 @@ package custom import ( + "context" "strings" "testing" "github.com/openai/openai-cli/internal/clihelp" + "github.com/openai/openai-cli/internal/requestflag" "github.com/urfave/cli/v3" ) +// Mirror the root flag wrapper's accessor without coupling copy to its type. +type helpRequestFlag struct{ *requestflag.Flag[string] } + +func (f *helpRequestFlag) RequestFlag() *requestflag.Flag[string] { return f.Flag } +func (*helpRequestFlag) IsLocal() bool { return false } + +func TestConfigureHelpHidesConfiguredGlobalValues(t *testing.T) { + for _, name := range []string{"api-key", "admin-api-key", "webhook-secret", "organization", "project"} { + for _, order := range []string{"plain", "wrapped-first", "wrapped-after"} { + t.Run(name+"/"+order, func(t *testing.T) { + const secret = "fake-help-value-do-not-display" + flag := &requestflag.Flag[string]{ + Name: name, Default: secret, Sources: cli.EnvVars("OPENAI_TEST_HELP_VALUE"), + } + root := &cli.Command{Name: "openai", Flags: []cli.Flag{flag}} + if order == "wrapped-first" { + root.Flags[0] = &helpRequestFlag{flag} + } + configureGlobalFlagDescriptions(root) + if order == "wrapped-after" { + root.Flags[0] = &helpRequestFlag{flag} + } + root.Action = func(context.Context, *cli.Command) error { return nil } + if err := root.Run(context.Background(), []string{"openai", "--" + name, secret}); err != nil { + t.Fatal(err) + } + configureGlobalFlagDescriptions(root) + help := root.Flags[0].String() + if strings.Contains(help, secret) || !strings.Contains(help, "OPENAI_TEST_HELP_VALUE") { + t.Fatalf("help must show the environment name without its value: %q", help) + } + if flag.Usage == "" || flag.Default != secret || flag.Get() != secret || !flag.IsSet() { + t.Fatal("help metadata changed flag values or omitted its description") + } + }) + } + } +} + +func TestConfigureHelpPreservesRootAndRequestFlagOwnership(t *testing.T) { + local := &requestflag.Flag[any]{Name: "project", Usage: "API project membership", BodyPath: "projects"} + rootProject := &requestflag.Flag[string]{Name: "project"} + unrelated := &requestflag.Flag[string]{Name: "unrelated", Usage: "Keep this description", Default: "keep-default"} + child := &cli.Command{Name: "create", Flags: []cli.Flag{local}} + root := &cli.Command{Name: "openai", Flags: []cli.Flag{rootProject, unrelated}, Commands: []*cli.Command{child}} + configureGlobalFlagDescriptions(root) + configureHelpGroups(root) + if local.Usage != "API project membership" || local.HideDefault || local.BodyPath != "projects" { + t.Fatal("help metadata changed the request field") + } + if unrelated.Usage != "Keep this description" || unrelated.HideDefault || unrelated.Default != "keep-default" { + t.Fatal("help metadata changed an unrelated root flag") + } + groups := child.Metadata["help-flag-groups"].([]clihelp.FlagGroup) + for i, name := range []string{"Authentication", "Output", "Request options", "Troubleshooting"} { + if groups[i].Title != name || groups[i].Owner != root { + t.Fatalf("incorrect root-owned group: %+v", groups[i]) + } + } +} + +func TestConfigureHelpBaseURLUsesLiteralDefault(t *testing.T) { + const configured = "https://fake-user:fake-password@example.invalid/v1?token=fake-query-token" + flag := &cli.StringFlag{Name: "base-url", Value: configured, DefaultText: configured} + root := &cli.Command{Name: "openai", Flags: []cli.Flag{flag}} + configureGlobalFlagDescriptions(root) + if got := flag.String(); strings.Contains(got, "fake-") || !strings.Contains(got, "https://api.openai.com/v1") { + t.Fatalf("base URL help exposed configuration or lost the default: %q", got) + } + if flag.Value != configured || !strings.Contains(flag.Usage, "OPENAI_BASE_URL") { + t.Fatal("help changed the endpoint or lost the environment name") + } +} + func TestConfigureHelpRefreshesImageExampleInvocation(t *testing.T) { images := &cli.Command{Name: "images", Commands: []*cli.Command{ {Name: "generate"}, {Name: "edit"}, {Name: "create-variation"}, diff --git a/scripts/demos/record-flag-descriptions.sh b/scripts/demos/record-flag-descriptions.sh new file mode 100755 index 00000000..cc067c41 --- /dev/null +++ b/scripts/demos/record-flag-descriptions.sh @@ -0,0 +1,100 @@ +#!/bin/bash +# Record complete help at 90 and 56 columns. Keep all media outside Git. +# Build the existing fixture before running: +# go build -o /tmp/image-model-demo-api ./scripts/demos/image-models +# DEMO_API_BINARY=/tmp/image-model-demo-api scripts/demos/record-flag-descriptions.sh \ +# BEFORE_BINARY AFTER_BINARY BASE_SHA CANDIDATE_SHA OUTPUT_DIR +set -euo pipefail + +if [ "$#" -ne 5 ]; then + echo 'usage: record-flag-descriptions.sh BEFORE_BINARY AFTER_BINARY BASE_SHA CANDIDATE_SHA OUTPUT_DIR' >&2 + exit 2 +fi +demo_full_rows="${DEMO_FULL_ROWS:-280}" +[[ "$demo_full_rows" =~ ^[1-9][0-9]*$ ]] || { echo 'DEMO_FULL_ROWS must be positive.' >&2; exit 2; } +demo_source="$(cd "$(dirname "$0")" && pwd)" +demo_root="$(cd "$demo_source/../.." && pwd)" +source "$demo_source/capture_and_render.sh" +demo_prepare_capture "$demo_root" "$1" "$2" "$3" "$4" "$5" \ + "${DEMO_API_BINARY:-$demo_root/dist/demos/bin/image-model-demo-api}" +demo_output_root="$demo_output" +demo_render_options=(--font-family Menlo --font-size 18 --line-height 1.2 \ + --theme dracula --fps-cap 20 --last-frame-duration 3) +demo_start_api "$demo_runtime/requests.txt" +{ + echo 'feature: global flag descriptions and categories' + echo "base commit: $demo_before_sha" + echo "candidate commit: $demo_after_sha" + echo 'data: no API key; loopback fixture records unexpected requests' + echo 'capture: real binaries in isolated bash PTYs; no personal environment or shell hooks' + echo 'scope: terminal replay; no native graphical terminal claim' + demo_capture_metadata + shasum -a 256 "$demo_source/record-flag-descriptions.sh" "$demo_source/image-models/main.go" +} > "$demo_output_root/metadata.txt" + +for demo_topic in root-full images-full root-short images-short; do + case "$demo_topic" in + root-full) demo_args=(help --all); demo_rows="$demo_full_rows";; + images-full) demo_args=(help --all images generate); demo_rows="$demo_full_rows";; + root-short) demo_args=(--help); demo_rows=48;; + images-short) demo_args=(images generate --help); demo_rows=48;; + esac + { + cat <<'SCENE' +#!/bin/bash +set -uo pipefail +test -t 0 && test -t 1 && test -t 2 || exit 99 +unset OPENAI_API_KEY +printf '\033[2J\033[H%s\n\n' "$DEMO_SCENE_LABEL" +sleep 0.3 +SCENE + # Preserve exact arguments without eval or personal shell expansion. + printf 'demo_args=(' + printf ' %q' "${demo_args[@]}" + printf ' )\n' + cat <<'SCENE' +printf '$ openai' +printf ' %s' "${demo_args[@]}" +printf '\n' +sleep 0.3 +if openai "${demo_args[@]}"; then demo_status=0; else demo_status=$?; fi +printf '\n$ ' +sleep 3 +exit "$demo_status" +SCENE + } > "$demo_runtime/scene.sh" + + for demo_columns in 90 56; do + demo_window_size="${demo_columns}x${demo_rows}" + demo_output="$demo_output_root/$demo_topic-$demo_columns" + mkdir "$demo_output" + { + printf 'command: openai'; printf ' %s' "${demo_args[@]}"; printf '\n' + echo "render: asciinema + agg, Menlo 18px, Dracula, $demo_window_size, line height 1.2" + } > "$demo_output/metadata.txt" + cp "$demo_runtime/scene.sh" "$demo_output/scene.sh" + demo_capture_scene before 0 "$demo_runtime/before" "$demo_api_url/help-only/v1" \ + "Before: $demo_topic ($demo_columns columns)" "COLUMNS=$demo_columns" "LINES=$demo_rows" + demo_capture_scene after 0 "$demo_runtime/after" "$demo_api_url/help-only/v1" \ + "After: $demo_topic ($demo_columns columns)" "COLUMNS=$demo_columns" "LINES=$demo_rows" + for demo_scene in before after; do + /usr/bin/grep -Fq 'openai' "$demo_output/$demo_scene.txt" + if /usr/bin/grep -Eq 'synthetic-demo-key|/help-only/v1' "$demo_output/$demo_scene.txt"; then + echo 'Help disclosed a configured fixture value.' >&2 + exit 1 + fi + done + if [[ "$demo_topic" = *-short ]]; then + sed 's/^Before: /Comparison: /' "$demo_output/before.txt" > "$demo_runtime/before-short.txt" + sed 's/^After: /Comparison: /' "$demo_output/after.txt" > "$demo_runtime/after-short.txt" + cmp "$demo_runtime/before-short.txt" "$demo_runtime/after-short.txt" + echo 'Short help: unchanged' >> "$demo_output/metadata.txt" + fi + demo_assemble_capture 300 before after + done +done +demo_stop_api +cp "$demo_runtime/requests.txt" "$demo_output_root/requests.txt" +test ! -s "$demo_output_root/requests.txt" +echo 'API requests: 0 (required)' >> "$demo_output_root/metadata.txt" +printf 'Recorded flag descriptions in %s\n' "$demo_output_root" From 70a6e1ca853a25e99eca79f848ae5cd5e21b15f6 Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Wed, 7 Oct 2026 15:37:23 -0700 Subject: [PATCH 02/44] Prepare clearer format help and recovery demos --- cmd/openai/flag_descriptions_test.go | 5 ++-- pkg/custom/help.go | 15 +++++++---- scripts/demos/record-flag-descriptions.sh | 31 +++++++++++++---------- 3 files changed, 31 insertions(+), 20 deletions(-) diff --git a/cmd/openai/flag_descriptions_test.go b/cmd/openai/flag_descriptions_test.go index 09d836f0..2a1b7376 100644 --- a/cmd/openai/flag_descriptions_test.go +++ b/cmd/openai/flag_descriptions_test.go @@ -86,8 +86,9 @@ func TestMainGlobalFlagDescriptions(t *testing.T) { } if len(path) == 0 { for _, description := range []string{ - "API key used to authenticate API requests.", - "Admin API key used to authenticate organization administration requests.", + "Authenticate API requests. Set OPENAI_API_KEY to keep the key out of shell history.", + "Authenticate organization administration requests.", + "a project API key cannot replace an admin key.", "Organization ID to send in the OpenAI-Organization request header.", "Project ID to send in the OpenAI-Project request header.", "The CLI has no webhook verification command.", diff --git a/pkg/custom/help.go b/pkg/custom/help.go index 0447741e..7ffd78e2 100644 --- a/pkg/custom/help.go +++ b/pkg/custom/help.go @@ -31,9 +31,9 @@ func configureGlobalFlagDescriptions(root *cli.Command) { case *requestflag.Flag[string]: switch flag.Name { case "api-key": - flag.Usage = "API key used to authenticate API requests." + flag.Usage = "Authenticate API requests. Set OPENAI_API_KEY to keep the key out of shell history." case "admin-api-key": - flag.Usage = "Admin API key used to authenticate organization administration requests." + flag.Usage = "Authenticate organization administration requests. Set OPENAI_ADMIN_KEY; a project API key cannot replace an admin key." case "organization": flag.Usage = "Organization ID to send in the OpenAI-Organization request header." case "project": @@ -52,9 +52,14 @@ func configureGlobalFlagDescriptions(root *cli.Command) { flag.DefaultText = "https://api.openai.com/v1" case "format": flag.Usage = "Choose how results are displayed. Format names are case-insensitive.\n" + - "auto: readable text, including pipes; --transform or --raw-output selects JSON handling.\n" + - "text: readable summary; json: full JSON; jsonl: one JSON value per line; yaml: YAML.\n" + - "explore: interactive JSON viewer (JSON when piped); pretty: styled JSON; raw: unformatted JSON.\n" + + "- auto: readable text, including pipes; --transform or --raw-output selects JSON handling.\n" + + "- text: readable summary.\n" + + "- json: full JSON.\n" + + "- jsonl: one JSON value per line.\n" + + "- yaml: YAML.\n" + + "- explore: interactive JSON viewer (JSON when piped).\n" + + "- pretty: styled JSON.\n" + + "- raw: unformatted JSON.\n\n" + "For paginated lists, raw returns one API page envelope. It differs from --raw-output." case "format-error": flag.Usage = "Choose how errors are displayed on stderr. Uses the same formats as --format.\n" + diff --git a/scripts/demos/record-flag-descriptions.sh b/scripts/demos/record-flag-descriptions.sh index cc067c41..b930cb48 100755 --- a/scripts/demos/record-flag-descriptions.sh +++ b/scripts/demos/record-flag-descriptions.sh @@ -1,5 +1,6 @@ #!/bin/bash -# Record complete help at 90 and 56 columns. Keep all media outside Git. +# Record help and error recovery at 90 and 56 columns. Keep all media outside Git. +# Set DEMO_INCLUDE_FULL=0 to omit the longer reference pages. # Build the existing fixture before running: # go build -o /tmp/image-model-demo-api ./scripts/demos/image-models # DEMO_API_BINARY=/tmp/image-model-demo-api scripts/demos/record-flag-descriptions.sh \ @@ -12,6 +13,8 @@ if [ "$#" -ne 5 ]; then fi demo_full_rows="${DEMO_FULL_ROWS:-280}" [[ "$demo_full_rows" =~ ^[1-9][0-9]*$ ]] || { echo 'DEMO_FULL_ROWS must be positive.' >&2; exit 2; } +demo_include_full="${DEMO_INCLUDE_FULL:-1}" +[[ "$demo_include_full" =~ ^[01]$ ]] || { echo 'DEMO_INCLUDE_FULL must be 0 or 1.' >&2; exit 2; } demo_source="$(cd "$(dirname "$0")" && pwd)" demo_root="$(cd "$demo_source/../.." && pwd)" source "$demo_source/capture_and_render.sh" @@ -22,7 +25,7 @@ demo_render_options=(--font-family Menlo --font-size 18 --line-height 1.2 \ --theme dracula --fps-cap 20 --last-frame-duration 3) demo_start_api "$demo_runtime/requests.txt" { - echo 'feature: global flag descriptions and categories' + echo 'feature: useful help, typed flags, and specific error recovery' echo "base commit: $demo_before_sha" echo "candidate commit: $demo_after_sha" echo 'data: no API key; loopback fixture records unexpected requests' @@ -32,12 +35,19 @@ demo_start_api "$demo_runtime/requests.txt" shasum -a 256 "$demo_source/record-flag-descriptions.sh" "$demo_source/image-models/main.go" } > "$demo_output_root/metadata.txt" -for demo_topic in root-full images-full root-short images-short; do +demo_topics=(root-short models-short responses-short images-short unknown-option invalid-integer) +if [ "$demo_include_full" -eq 1 ]; then demo_topics+=(root-full images-full); fi +for demo_topic in "${demo_topics[@]}"; do + demo_expected_status=0 case "$demo_topic" in root-full) demo_args=(help --all); demo_rows="$demo_full_rows";; images-full) demo_args=(help --all images generate); demo_rows="$demo_full_rows";; root-short) demo_args=(--help); demo_rows=48;; + models-short) demo_args=(models list --help); demo_rows=48;; + responses-short) demo_args=(responses create --help); demo_rows=48;; images-short) demo_args=(images generate --help); demo_rows=48;; + unknown-option) demo_args=(models list --max-itmes=1); demo_rows=20; demo_expected_status=1;; + invalid-integer) demo_args=(models list --max-items=not-an-integer); demo_rows=20; demo_expected_status=1;; esac { cat <<'SCENE' @@ -73,23 +83,18 @@ SCENE echo "render: asciinema + agg, Menlo 18px, Dracula, $demo_window_size, line height 1.2" } > "$demo_output/metadata.txt" cp "$demo_runtime/scene.sh" "$demo_output/scene.sh" - demo_capture_scene before 0 "$demo_runtime/before" "$demo_api_url/help-only/v1" \ + demo_capture_scene before "$demo_expected_status" "$demo_runtime/before" "$demo_api_url/help-only/v1" \ "Before: $demo_topic ($demo_columns columns)" "COLUMNS=$demo_columns" "LINES=$demo_rows" - demo_capture_scene after 0 "$demo_runtime/after" "$demo_api_url/help-only/v1" \ + demo_capture_scene after "$demo_expected_status" "$demo_runtime/after" "$demo_api_url/help-only/v1" \ "After: $demo_topic ($demo_columns columns)" "COLUMNS=$demo_columns" "LINES=$demo_rows" for demo_scene in before after; do /usr/bin/grep -Fq 'openai' "$demo_output/$demo_scene.txt" if /usr/bin/grep -Eq 'synthetic-demo-key|/help-only/v1' "$demo_output/$demo_scene.txt"; then - echo 'Help disclosed a configured fixture value.' >&2 + echo 'The command disclosed a configured fixture value.' >&2 exit 1 fi done - if [[ "$demo_topic" = *-short ]]; then - sed 's/^Before: /Comparison: /' "$demo_output/before.txt" > "$demo_runtime/before-short.txt" - sed 's/^After: /Comparison: /' "$demo_output/after.txt" > "$demo_runtime/after-short.txt" - cmp "$demo_runtime/before-short.txt" "$demo_runtime/after-short.txt" - echo 'Short help: unchanged' >> "$demo_output/metadata.txt" - fi + test ! -s "$demo_runtime/requests.txt" demo_assemble_capture 300 before after done done @@ -97,4 +102,4 @@ demo_stop_api cp "$demo_runtime/requests.txt" "$demo_output_root/requests.txt" test ! -s "$demo_output_root/requests.txt" echo 'API requests: 0 (required)' >> "$demo_output_root/metadata.txt" -printf 'Recorded flag descriptions in %s\n' "$demo_output_root" +printf 'Recorded help and error recovery in %s\n' "$demo_output_root" From ad0dd6d25626c1e7684873a023ac06d9e8b4d525 Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Wed, 7 Oct 2026 15:43:14 -0700 Subject: [PATCH 03/44] Show practical help examples and specific argument recovery --- cmd/openai/flag_descriptions_test.go | 3 + cmd/openai/image_settings_help_test.go | 10 +- cmd/openai/main_help_examples_test.go | 4 +- cmd/openai/main_help_test.go | 10 +- cmd/openai/normal_help_examples_test.go | 195 ++++++++++++++++++++++ cmd/openai/parser_error_guidance_test.go | 121 ++++++++++++++ internal/clihelp/brief.go | 99 +++++++++-- internal/clihelp/brief_test.go | 80 ++++++++- internal/clihelp/flag_labels.go | 61 +++++++ internal/clihelp/flag_labels_test.go | 53 ++++++ internal/clihelp/full.go | 23 ++- internal/clihelp/full_test.go | 37 ++-- internal/clihelp/help.go | 5 + pkg/custom/help.go | 40 +++++ pkg/custom/help_test.go | 42 +++++ pkg/custom/image_upload_help.go | 7 + pkg/custom/local_errors.go | 93 ++++++++++- pkg/custom/parser_error_guidance_test.go | 109 ++++++++++++ pkg/custom/save_generated_images.go | 9 +- scripts/demos/record-flag-descriptions.sh | 2 +- 20 files changed, 944 insertions(+), 59 deletions(-) create mode 100644 cmd/openai/normal_help_examples_test.go create mode 100644 cmd/openai/parser_error_guidance_test.go create mode 100644 internal/clihelp/flag_labels.go create mode 100644 internal/clihelp/flag_labels_test.go create mode 100644 pkg/custom/parser_error_guidance_test.go diff --git a/cmd/openai/flag_descriptions_test.go b/cmd/openai/flag_descriptions_test.go index 2a1b7376..efaf78b3 100644 --- a/cmd/openai/flag_descriptions_test.go +++ b/cmd/openai/flag_descriptions_test.go @@ -61,6 +61,9 @@ func TestMainGlobalFlagDescriptions(t *testing.T) { remaining = after } text := strings.Join(strings.Fields(got.stdout), " ") + for _, format := range []string{"auto", "text", "json", "jsonl", "yaml", "explore", "pretty", "raw"} { + require.Contains(t, got.stdout, "\n - "+format+":", "format choices must stay on separate lines") + } for _, description := range []string{ "Format names are case-insensitive.", "readable text, including pipes", diff --git a/cmd/openai/image_settings_help_test.go b/cmd/openai/image_settings_help_test.go index 454647e8..5133f600 100644 --- a/cmd/openai/image_settings_help_test.go +++ b/cmd/openai/image_settings_help_test.go @@ -25,9 +25,15 @@ func TestMainImageSettingsHelpScope(t *testing.T) { short := runMainDispatchWithEnv(t, "bash", env, "openai", "images", operation, "--help") require.Zero(t, short.code, short.stderr) require.Empty(t, short.stderr) - require.LessOrEqual(t, len(strings.Split(strings.TrimSpace(short.stdout), "\n")), 8) + require.LessOrEqual(t, len(strings.Split(strings.TrimSpace(short.stdout), "\n")), 18) require.Contains(t, short.stdout, "~/Downloads/gpt-images/") - require.NotContains(t, short.stdout, "--name") + if operation != "create-variation" { + require.Contains(t, short.stdout, "--name NAME") + require.Contains(t, short.stdout, "--output-dir DIRECTORY") + if operation == "generate" { + require.Contains(t, short.stdout, "interactive terminal") + } + } require.Contains(t, short.stdout, "Full help: openai help --all images "+operation) full := runMainDispatchWithEnv(t, "bash", env, "openai", "help", "--all", "images", operation) require.Zero(t, full.code, full.stderr) diff --git a/cmd/openai/main_help_examples_test.go b/cmd/openai/main_help_examples_test.go index 03893a35..2e5d5ead 100644 --- a/cmd/openai/main_help_examples_test.go +++ b/cmd/openai/main_help_examples_test.go @@ -89,8 +89,8 @@ func TestMainHelpImagePagesStayBrief(t *testing.T) { if got.code != 0 || got.stderr != "" { t.Fatalf("short help failed: %+v", got) } - if lines := len(strings.Split(strings.TrimSpace(got.stdout), "\n")); lines > 8 { - t.Errorf("short help uses %d lines; want one example and at most 8 lines", lines) + if lines := len(strings.Split(strings.TrimSpace(got.stdout), "\n")); lines > 18 { + t.Errorf("short help uses %d lines; want one example and at most 18 lines", lines) } exampleOperation := operation if operation == "create-variation" { diff --git a/cmd/openai/main_help_test.go b/cmd/openai/main_help_test.go index 32b350a3..23d8bea0 100644 --- a/cmd/openai/main_help_test.go +++ b/cmd/openai/main_help_test.go @@ -167,7 +167,7 @@ func TestMainHelpFullReferenceRoutes(t *testing.T) { } else if got.stdout != reference { t.Errorf("args %q: full help differs between routes", args) } - for _, text := range []string{"--prompt string", "-n int", "(default: auto)", "(default: 1)", "(default: png)", "(default: 100)", "(default: 0)"} { + for _, text := range []string{"--prompt TEXT", "-n INTEGER", "(default: auto)", "(default: 1)", "(default: png)", "(default: 100)", "(default: 0)"} { if !strings.Contains(got.stdout, text) { t.Errorf("args %q: full help lost label/default %q", args, text) } @@ -197,7 +197,7 @@ func TestMainHelpKeepsAPIProjectInCommandOptions(t *testing.T) { t.Fatalf("full help failed: %+v", got) } local, global, ok := strings.Cut(got.stdout, "GLOBAL OPTIONS:") - if !ok || !strings.Contains(local, "--project any") || !strings.Contains(local, "membership is granted") { + if !ok || !strings.Contains(local, "--project VALUE") || !strings.Contains(local, "membership is granted") { t.Fatalf("API project input is missing: %s", got.stdout) } for _, heading := range []string{"Authentication", "Output", "Request options", "Troubleshooting"} { @@ -205,7 +205,7 @@ func TestMainHelpKeepsAPIProjectInCommandOptions(t *testing.T) { t.Errorf("%q should group only global flags: %s", heading, got.stdout) } } - if !strings.Contains(global, "--base-url") || !strings.Contains(global, "--format string") { + if !strings.Contains(global, "--base-url") || !strings.Contains(global, "--format FORMAT") { t.Errorf("global configuration is missing: %s", global) } } @@ -215,8 +215,8 @@ func TestMainHelpFullReferenceNullableDefaults(t *testing.T) { path []string flag, wantDefault string }{ - {[]string{"beta:assistants", "create"}, "--description string", ""}, - {[]string{"images", "generate"}, "--output-format string", "(default: png)"}, + {[]string{"beta:assistants", "create"}, "--description TEXT", ""}, + {[]string{"images", "generate"}, "--output-format TEXT", "(default: png)"}, } { t.Run(strings.Join(tc.path, "/"), func(t *testing.T) { got := runMainDispatch(t, "bash", append([]string{"openai", "help", "--all"}, tc.path...)...) diff --git a/cmd/openai/normal_help_examples_test.go b/cmd/openai/normal_help_examples_test.go new file mode 100644 index 00000000..5e818ae7 --- /dev/null +++ b/cmd/openai/normal_help_examples_test.go @@ -0,0 +1,195 @@ +package main + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestMainNormalHelpOutputOptions(t *testing.T) { + for _, args := range [][]string{nil, {"models", "list"}, {"responses", "create"}} { + t.Run(strings.Join(append([]string{"root"}, args...), "/"), func(t *testing.T) { + argv := append([]string{"openai"}, args...) + got := runMainDispatch(t, "bash", append(argv, "--help")...) + if got.code != 0 || got.stderr != "" { + t.Fatalf("normal help failed: %+v", got) + } + for _, want := range []string{"\nOUTPUT\n", "--format ", "--transform PATH", "--raw-output"} { + if !strings.Contains(got.stdout, want) { + t.Errorf("normal help lacks %q:\n%s", want, got.stdout) + } + } + }) + } + for _, args := range [][]string{{"images", "inline"}, {"images", "inline", "on"}, {"images", "inline", "off"}} { + got := runMainDispatch(t, "bash", append(append([]string{"openai"}, args...), "--help")...) + if got.code != 0 || got.stderr != "" || strings.Contains(got.stdout, "\nOUTPUT\n") { + t.Fatalf("local image help advertises API output options: %+v", got) + } + } +} + +func TestMainNormalHelpModelsLimit(t *testing.T) { + server, requests := normalHelpModelsServer(t) + for _, args := range [][]string{{"models", "list", "--help"}, {"help", "--all", "models", "list"}} { + got := runMainDispatch(t, "bash", append([]string{"openai"}, args...)...) + text := strings.Join(strings.Fields(got.stdout), " ") + if got.code != 0 || got.stderr != "" || !strings.Contains(text, "--max-items INTEGER") || + !strings.Contains(text, "omit or use -1 for unlimited, or use 0 for no items") { + t.Fatalf("help does not explain the limit contract: %+v", got) + } + if args[0] == "help" && !strings.Contains(text, "(default: unlimited)") { + t.Fatalf("full help displays the wrong omitted default: %s", text) + } + } + for _, tc := range []struct { + name, want string + flags []string + }{ + {"omitted", "synthetic-model-one\nsynthetic-model-two\n", nil}, + {"unlimited", "synthetic-model-one\nsynthetic-model-two\n", []string{"--max-items", "-1"}}, + {"zero", "", []string{"--max-items", "0"}}, + } { + t.Run(tc.name, func(t *testing.T) { + requests.Store(0) + args := append([]string{"openai", "models", "list", "--transform", "id", "--raw-output"}, tc.flags...) + got := runMainDispatchWithEnv(t, "bash", []string{ + "OPENAI_BASE_URL=" + server.URL, "OPENAI_API_KEY=fake-native-shell-key", + }, args...) + if got.code != 0 || got.stderr != "" || got.stdout != tc.want || requests.Load() != 1 { + t.Fatalf("limit contract changed: result=%+v requests=%d", got, requests.Load()) + } + }) + } +} + +func TestMainNormalHelpNativeModelExamples(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Bash/zsh invocation checks require a Unix host") + } + work := filepath.Join(t.TempDir(), "CLI with spaces & apostrophe's") + if err := os.Mkdir(work, 0o700); err != nil { + t.Fatal(err) + } + binary := filepath.Join(work, "openai") + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + build := exec.CommandContext(ctx, filepath.Join(runtime.GOROOT(), "bin", "go"), "build", "-o", binary, ".") + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build CLI: %v\n%s", err, output) + } + invocation := "'" + strings.ReplaceAll(binary, "'", "'\\''") + "'" + server, requests := normalHelpModelsServer(t) + required := strings.Split(os.Getenv("OPENAI_CLI_REQUIRE_NATIVE_SHELLS"), ",") + for _, shell := range []nativeShell{ + {"bash", "bash", "", "export OPENAI_API_KEY='fake-native-shell-key'; ", []string{"--noprofile", "--norc", "-c"}}, + {"zsh", "zsh", "", "export OPENAI_API_KEY='fake-native-shell-key'; ", []string{"-f", "-c"}}, + } { + t.Run(shell.name, func(t *testing.T) { + path, err := exec.LookPath(shell.executable) + if err != nil { + if slices.Contains(required, shell.name) { + t.Fatalf("required shell is unavailable: %v", err) + } + t.Skipf("%s is not installed", shell.name) + } + shell.executable = path + for _, lookup := range []string{"absent", "unrelated", "installed"} { + t.Run(lookup, func(t *testing.T) { + directory, home, searchPath := t.TempDir(), t.TempDir(), t.TempDir() + printedInvocation := invocation + if lookup == "unrelated" { + if err := os.WriteFile(filepath.Join(searchPath, "openai"), []byte("#!/bin/sh\nprintf 'wrong executable\\n'\nexit 99\n"), 0o700); err != nil { + t.Fatal(err) + } + } else if lookup == "installed" { + searchPath, printedInvocation = work, "openai" + } + t.Setenv("PATH", searchPath) + requests.Store(0) + help := runNativeShell(t, shell, directory, home, server.URL, invocation+" models list --help") + if help.code != 0 || help.stderr != "" || requests.Load() != 0 { + t.Fatalf("normal help failed or made a request: %+v", help) + } + var examples []string + for line := range strings.SplitSeq(help.stdout, "\n") { + if strings.HasPrefix(line, " "+printedInvocation+" models list") { + examples = append(examples, strings.TrimSpace(line)) + } + } + if len(examples) != 3 { + t.Fatalf("expected three examples using %q:\n%s", printedInvocation, help.stdout) + } + for i, suffix := range []string{"models list", "models list --format json", "models list --transform id --raw-output"} { + if examples[i] != printedInvocation+" "+suffix { + t.Fatalf("example changed: %q", examples[i]) + } + requests.Store(0) + got := runNativeShell(t, shell, directory, home, server.URL, shell.setKey+examples[i]) + if got.code != 0 || got.stderr != "" || requests.Load() != 1 { + t.Fatalf("copied example %q failed: result=%+v requests=%d", examples[i], got, requests.Load()) + } + switch i { + case 0: + for _, id := range []string{"synthetic-model-one", "synthetic-model-two"} { + if strings.Count(got.stdout, id) != 1 { + t.Fatalf("readable example lost or duplicated %q: %q", id, got.stdout) + } + } + case 1: + decoder := json.NewDecoder(strings.NewReader(got.stdout)) + for _, id := range []string{"synthetic-model-one", "synthetic-model-two"} { + var item map[string]any + if err := decoder.Decode(&item); err != nil || item["id"] != id || item["synthetic_field"] != "retained" || len(item) != 5 { + t.Fatalf("JSON example lost complete model data: %q (%v)", got.stdout, err) + } + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + t.Fatalf("JSON example contains extra output: %q (%v)", got.stdout, err) + } + case 2: + if got.stdout != "synthetic-model-one\nsynthetic-model-two\n" { + t.Fatalf("extraction example did not print unquoted IDs: %q", got.stdout) + } + } + } + }) + } + }) + } +} + +func normalHelpModelsServer(t *testing.T) (*httptest.Server, *atomic.Int32) { + t.Helper() + requests := new(atomic.Int32) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if r.Method != http.MethodGet || r.URL.Path != "/models" || r.URL.RawQuery != "" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL) + } + if r.Header.Get("Authorization") != "Bearer fake-native-shell-key" { + t.Error("request lacks the synthetic shell credential") + } + w.Header().Set("Content-Type", "application/json") + _, err := io.WriteString(w, `{"object":"list","data":[`+ + `{"id":"synthetic-model-one","object":"model","created":1,"owned_by":"test","synthetic_field":"retained"},`+ + `{"id":"synthetic-model-two","object":"model","created":2,"owned_by":"test","synthetic_field":"retained"}]}`) + if err != nil { + t.Errorf("write synthetic model response: %v", err) + } + })) + t.Cleanup(server.Close) + return server, requests +} diff --git a/cmd/openai/parser_error_guidance_test.go b/cmd/openai/parser_error_guidance_test.go new file mode 100644 index 00000000..96c2fbad --- /dev/null +++ b/cmd/openai/parser_error_guidance_test.go @@ -0,0 +1,121 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestMainParserErrorGuidance(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(server.Close) + for _, test := range []struct { + name string + args []string + want []string + }{ + {"integer", []string{"models", "list", "--max-items", "synthetic-private-value"}, []string{"Invalid value for --max-items. Expected an integer.", "openai models list --help"}}, + {"number", []string{"responses", "create", "--temperature", "synthetic-private-value"}, []string{"Invalid value for --temperature. Expected a number.", "openai responses create --help"}}, + {"boolean", []string{"responses", "create", "--background", "synthetic-private-value"}, []string{"Invalid value for --background. Expected a boolean (true or false).", "openai responses create --help"}}, + {"transposed flag", []string{"models", "list", "--max-itmes=synthetic-private-value"}, []string{"Did you mean --max-items?", "openai models list --help"}}, + {"inherited flag", []string{"models", "list", "--formta=synthetic-private-value"}, []string{"Did you mean --format?", "openai models list --help"}}, + {"missing value", []string{"models", "retrieve", "--model"}, []string{"Add a value for --model.", "openai models retrieve --help"}}, + {"required flag", []string{"embeddings", "create", "--input", "synthetic-private-input"}, []string{"Missing required options: --model.", "openai embeddings create --help"}}, + {"extra argument", []string{"models", "list", "synthetic-private-extra"}, []string{"Unexpected extra arguments.", "openai models list --help"}}, + {"private unknown", []string{"models", "list", "--synthetic-private-flag=synthetic-private-value"}, []string{"An option is not recognized.", "openai models list --help"}}, + } { + for _, format := range []string{"text", "json", "jsonl"} { + t.Run(test.name+"/"+format, func(t *testing.T) { + args := append([]string{"openai", "--base-url", server.URL, "--format-error", format}, test.args...) + got := runMainDispatch(t, "bash", args...) + if got.code != 1 || got.stdout != "" { + t.Fatalf("parser failure = %+v; want exit 1 and stderr only", got) + } + message := got.stderr + if format != "text" { + payload := decodeMainStructuredError(t, format, got.stderr) + message, _ = payload["message"].(string) + if _, exists := payload["status_code"]; exists { + t.Errorf("local parser error invented an HTTP status: %v", payload) + } + } + for _, want := range test.want { + if !strings.Contains(message, want) { + t.Errorf("message = %q, want %q", message, want) + } + } + if strings.Contains(got.stderr, "synthetic-private-") { + t.Errorf("diagnostic exposed rejected input: %q", got.stderr) + } + }) + } + } + if requests.Load() != 0 { + t.Errorf("parser failures sent %d HTTP requests", requests.Load()) + } +} + +func TestMainParserRecoveryNativeShell(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("this recovery fixture exercises native Bash and zsh") + } + work := filepath.Join(t.TempDir(), "CLI with spaces & apostrophe's") + if err := os.Mkdir(work, 0o700); err != nil { + t.Fatal(err) + } + binary := filepath.Join(work, "openai") + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Minute) + defer cancel() + build := exec.CommandContext(ctx, filepath.Join(runtime.GOROOT(), "bin", "go"), "build", "-o", binary, ".") + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build CLI: %v\n%s", err, output) + } + other := t.TempDir() + if err := os.WriteFile(filepath.Join(other, "openai"), []byte("#!/bin/sh\nexit 99\n"), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", other+string(os.PathListSeparator)+os.Getenv("PATH")) + invocation := "'" + strings.ReplaceAll(binary, "'", "'\\''") + "'" + required := strings.Split(os.Getenv("OPENAI_CLI_REQUIRE_NATIVE_SHELLS"), ",") + for _, shell := range []nativeShell{ + {name: "bash", executable: "bash", args: []string{"--noprofile", "--norc", "-c"}}, + {name: "zsh", executable: "zsh", args: []string{"-f", "-c"}}, + } { + t.Run(shell.name, func(t *testing.T) { + path, err := exec.LookPath(shell.executable) + if err != nil { + if slices.Contains(required, shell.name) { + t.Fatalf("required shell is unavailable: %v", err) + } + t.Skipf("%s is unavailable", shell.name) + } + shell.executable = path + dir := t.TempDir() + for _, args := range []string{"models list --max-itmes=synthetic-private-value", "models list --max-items=synthetic-private-value", "models retrieve --model"} { + got := runNativeShell(t, shell, other, dir, "http://127.0.0.1:1", invocation+" "+args) + _, recovery, found := strings.Cut(got.stderr, "Options and examples: ") + recovery = strings.TrimSpace(recovery) + if got.code != 1 || !found || !strings.HasPrefix(recovery, invocation+" ") || strings.Contains(got.stderr, "synthetic-private-") { + t.Fatalf("recovery lost executable identity or privacy: %+v", got) + } + copied := runNativeShell(t, shell, other, dir, "not-a-url", recovery) + if copied.code != 0 || copied.stderr != "" || !strings.Contains(copied.stdout, "models") { + t.Fatalf("copied recovery failed: %+v", copied) + } + } + }) + } +} diff --git a/internal/clihelp/brief.go b/internal/clihelp/brief.go index bc2a1d0a..cdcd6a99 100644 --- a/internal/clihelp/brief.go +++ b/internal/clihelp/brief.go @@ -36,8 +36,15 @@ func briefHelpAtWidth(command *cli.Command, invocation, path string, width int) if command.Usage != "" { out.WriteString(wrapDescription(shortDescription(command.Usage), "", width)) } - if example := examples[path]; example != "" { - fmt.Fprintf(&out, "\nEXAMPLE\n %s %s\n", invocation, example) + if commands := examples[path]; len(commands) > 0 { + heading := "EXAMPLE" + if len(commands) > 1 { + heading = "EXAMPLES" + } + fmt.Fprintf(&out, "\n%s\n", heading) + for _, example := range commands { + fmt.Fprintf(&out, " %s %s\n", invocation, example) + } if path == "files create" || path == "files upload" { out.WriteString(wrapDescription("example.txt is the path to your existing file. Replace it with your file's path.", "", width)) } @@ -66,10 +73,18 @@ func briefHelpAtWidth(command *cli.Command, invocation, path string, width int) } } if len(optional) > 0 { - out.WriteString("\nOPTIONAL INPUTS (more in full help)\n") + out.WriteString("\nOPTIONAL INPUTS") + if len(optional) > 3 { + out.WriteString(" (more in full help)") + } + out.WriteByte('\n') // Put common choices first without changing the parser's flag order. shown := make(map[int]bool) - for _, name := range []string{"model", "input", "size", "quality", "limit"} { + priority := []string{"model", "input", "size", "quality", "limit"} + if path == "responses create" || path == "responses retrieve" { + priority = []string{"model", "input", "stream"} + } + for _, name := range priority { for i, flag := range optional { if flag.Names()[0] == name && len(shown) < 3 { writeBriefFlag(&out, flag, width) @@ -87,23 +102,28 @@ func briefHelpAtWidth(command *cli.Command, invocation, path string, width int) } } } + writeBriefOutputFlags(&out, command, width) } fmt.Fprintf(&out, "\nFull help: %s help --all %s\n", invocation, path) fmt.Fprintf(&out, "Key setup: %s help setup\n", invocation) return out.String() } -var examples = map[string]string{ - "images": `images generate --prompt "A tiny orange robot"`, - "models list": "models list", - "models retrieve": "models retrieve --model gpt-5.5", - "responses create": `responses create --model gpt-5.5 --input "Say hello"`, - "files create": `files create --file ./example.txt --purpose assistants`, - "files upload": `files upload --file ./example.txt --purpose assistants`, - "images generate": `images generate --prompt "A tiny orange robot"`, - "images inline": "images inline on", - "images inline on": "images inline on", - "images inline off": "images inline off", +var examples = map[string][]string{ + "images": {`images generate --prompt "A tiny orange robot"`}, + "models list": { + "models list", + "models list --format json", + "models list --transform id --raw-output", + }, + "models retrieve": {"models retrieve --model gpt-5.5"}, + "responses create": {`responses create --model gpt-5.5 --input "Say hello"`}, + "files create": {`files create --file ./example.txt --purpose assistants`}, + "files upload": {`files upload --file ./example.txt --purpose assistants`}, + "images generate": {`images generate --prompt "A tiny orange robot"`}, + "images inline": {"images inline on"}, + "images inline on": {"images inline on"}, + "images inline off": {"images inline off"}, } func isRequired(flag cli.Flag) bool { @@ -124,8 +144,8 @@ func writeBriefFlag(out *strings.Builder, flag cli.Flag, width int) { var usage string if doc, ok := flag.(cli.DocGenerationFlag); ok { usage = shortDescription(fileInputUsage(flag, doc.GetUsage())) - if doc.TakesValue() { - name += " VALUE" + if label := flagValueLabel(flag); label != "" { + name += " " + label } } // These generated descriptions are absent or describe an SDK object. @@ -137,10 +157,55 @@ func writeBriefFlag(out *strings.Builder, flag cli.Flag, width int) { if usage == "" { usage = "Text or other input to send to the model." } + case "stream": + if flagValueLabel(flag) == "BOOLEAN" { + usage = "Stream results as they arrive when true." + } } writeHelpEntry(out, name, usage, width) } +func writeBriefOutputFlags(out *strings.Builder, command *cli.Command, width int) { + flags, _ := command.Metadata["brief-output-flags"].([]cli.Flag) + shown := make(map[string]bool) + for _, flag := range flags { + if visible, ok := flag.(cli.VisibleFlag); ok && !visible.IsVisible() { + continue + } + persistent, ok := flag.(cli.LocalFlag) + if !ok || persistent.IsLocal() || len(flag.Names()) == 0 { + continue + } + name := flag.Names()[0] + if shown[name] { + continue + } + var usage string + switch name { + case "format": + usage = "auto: readable text; json: complete JSON; jsonl: one JSON value per line." + case "transform": + usage = "Select a JSON field, such as id." + if command.Name == "list" { + usage += " With json, select from each item." + } + case "raw-output": + usage = "Print selected strings without JSON quotes." + default: + continue + } + if len(shown) == 0 { + out.WriteString("\nOUTPUT\n") + } + shown[name] = true + label := "--" + name + if value := flagValueLabel(flag); value != "" { + label += " " + value + } + writeHelpEntry(out, label, usage, width) + } +} + func shortDescription(text string) string { text = strings.Join(strings.Fields(strings.ReplaceAll(text, "\\n", " ")), " ") text = strings.ReplaceAll(text, "`", "") diff --git a/internal/clihelp/brief_test.go b/internal/clihelp/brief_test.go index 1af508e5..47d5109b 100644 --- a/internal/clihelp/brief_test.go +++ b/internal/clihelp/brief_test.go @@ -63,7 +63,7 @@ func TestBriefHelpIncludesEveryRequiredInput(t *testing.T) { } required, _, _ := strings.Cut(inputs, "OPTIONAL INPUTS") for _, name := range []string{"body-input", "path-input", "ordinary-input"} { - if !strings.Contains(required, "--"+name+" VALUE") { + if !strings.Contains(required, "--"+name+" TEXT") { t.Errorf("required help lacks --%s: %s", name, got) } } @@ -120,6 +120,84 @@ func TestBriefImageExampleUsesMinimalInput(t *testing.T) { } } +func TestBriefModelsHelpShowsOutputExamplesAndTypedLimit(t *testing.T) { + command := &cli.Command{Name: "list", Flags: []cli.Flag{ + &requestflag.Flag[int64]{Name: "max-items", Usage: "Maximum items; use -1 for unlimited."}, + }, Metadata: map[string]any{"brief-output-flags": []cli.Flag{ + &cli.StringFlag{Name: "format", Usage: "The full reference contains many format details.", Value: "auto"}, + &cli.StringFlag{Name: "transform", Usage: "The full reference contains path exceptions."}, + &cli.BoolFlag{Name: "raw-output"}, + }}} + for _, width := range []int{40, 80} { + got := briefHelpAtWidth(command, "'/tmp/CLI build/openai'", "models list", width) + for _, want := range []string{ + "EXAMPLES\n", + " '/tmp/CLI build/openai' models list\n", + " '/tmp/CLI build/openai' models list --format json\n", + " '/tmp/CLI build/openai' models list --transform id --raw-output\n", + "--max-items INTEGER", "\nOUTPUT\n", "--format FORMAT", "--transform PATH", "--raw-output", + } { + if !strings.Contains(got, want) { + t.Errorf("width %d lacks %q:\n%s", width, want, got) + } + } + if strings.Contains(got, "more in full help") || strings.Contains(got, "The full reference") { + t.Errorf("width %d repeats reference details or advertises nonexistent optional inputs:\n%s", width, got) + } + } +} + +func TestBriefResponsesHelpPrioritizesInputModelAndStream(t *testing.T) { + command := &cli.Command{Name: "create", Flags: []cli.Flag{ + &requestflag.Flag[any]{Name: "access-programs", Usage: "Other configuration."}, + &requestflag.Flag[*bool]{Name: "background", Usage: "Run in the background."}, + &requestflag.Flag[any]{Name: "input"}, + &requestflag.Flag[string]{Name: "model"}, + &requestflag.Flag[*bool]{Name: "stream", Usage: "Stream the response."}, + }} + got := briefHelp(command, "openai", "responses create") + for _, want := range []string{"--model MODEL", "--input VALUE", "--stream BOOLEAN", "more in full help"} { + if !strings.Contains(got, want) { + t.Errorf("responses help lacks %q:\n%s", want, got) + } + } + for _, excluded := range []string{"--access-programs", "--background"} { + if strings.Contains(got, excluded) { + t.Errorf("responses help prioritized %q:\n%s", excluded, got) + } + } +} + +func TestBriefOutputFlagsRequireVisiblePersistentMetadata(t *testing.T) { + format := &cli.StringFlag{Name: "format", Value: "auto", Usage: "Original format documentation."} + before := *format + for _, tc := range []struct { + name string + flags []cli.Flag + want bool + }{ + {"absent", nil, false}, + {"visible", []cli.Flag{format, format}, true}, + {"hidden", []cli.Flag{&cli.StringFlag{Name: "format", Hidden: true}}, false}, + {"local", []cli.Flag{&cli.StringFlag{Name: "format", Local: true}}, false}, + {"unrecognized", []cli.Flag{&cli.StringFlag{Name: "unrelated"}}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + command := &cli.Command{Name: "operation", Metadata: map[string]any{"brief-output-flags": tc.flags}} + got := briefHelp(command, "openai", "example operation") + if strings.Contains(got, "\nOUTPUT\n") != tc.want { + t.Fatalf("output section visibility is incorrect:\n%s", got) + } + if tc.want && strings.Count(got, "--format FORMAT") != 1 { + t.Fatalf("output flag is missing or duplicated:\n%s", got) + } + }) + } + if !reflect.DeepEqual(before, *format) { + t.Fatal("brief output help changed the root flag definition") + } +} + func TestGoRunInvocationIsCopyableFromSourceCheckout(t *testing.T) { project := t.TempDir() t.Chdir(project) diff --git a/internal/clihelp/flag_labels.go b/internal/clihelp/flag_labels.go new file mode 100644 index 00000000..6deeae13 --- /dev/null +++ b/internal/clihelp/flag_labels.go @@ -0,0 +1,61 @@ +package clihelp + +import ( + "strings" + + "github.com/urfave/cli/v3" +) + +// Labels describe accepted input without reading configured values or defaults. +func flagValueLabel(flag cli.Flag) string { + doc, ok := flag.(cli.DocGenerationFlag) + if !ok || !doc.TakesValue() { + return "" + } + if input, ok := flag.(interface{ IsFileInput() bool }); ok && input.IsFileInput() { + return "PATH" + } + if doc.TypeName() == "string" { + if names := flag.Names(); len(names) > 0 { + switch names[0] { + case "model": + return "MODEL" + case "format", "format-error": + return "FORMAT" + case "transform", "transform-error": + return "PATH" + case "output-dir": + return "DIRECTORY" + case "base-url": + return "URL" + case "prompt", "instructions": + return "TEXT" + } + } + } + return flagTypeLabel(doc.TypeName()) +} + +func flagTypeLabel(name string) string { + if key, value, ok := strings.Cut(name, "="); ok { + return flagTypeLabel(key) + "=" + flagTypeLabel(value) + } + switch name { + case "int", "int8", "int16", "int32", "int64", "uint", "uint8", "uint16", "uint32", "uint64": + return "INTEGER" + case "float", "float32", "float64": + return "NUMBER" + case "bool", "boolean": + return "BOOLEAN" + case "string": + return "TEXT" + case "date": + return "DATE" + case "datetime": + return "DATETIME" + case "time": + return "TIME" + default: + return "VALUE" + } +} diff --git a/internal/clihelp/flag_labels_test.go b/internal/clihelp/flag_labels_test.go new file mode 100644 index 00000000..9d9cd881 --- /dev/null +++ b/internal/clihelp/flag_labels_test.go @@ -0,0 +1,53 @@ +package clihelp + +import ( + "testing" + + "github.com/openai/openai-cli/internal/requestflag" + "github.com/urfave/cli/v3" +) + +func TestFlagValueLabelsDescribeAcceptedInputs(t *testing.T) { + for _, tc := range []struct { + name string + flag cli.Flag + want string + }{ + {"integer", &requestflag.Flag[int64]{Name: "max-items"}, "INTEGER"}, + {"nullable integer", &requestflag.Flag[*int64]{Name: "n"}, "INTEGER"}, + {"number", &requestflag.Flag[*float64]{Name: "temperature"}, "NUMBER"}, + {"nullable boolean", &requestflag.Flag[*bool]{Name: "stream"}, "BOOLEAN"}, + {"boolean switch", &cli.BoolFlag{Name: "debug"}, ""}, + {"request boolean switch", &requestflag.Flag[bool]{Name: "active"}, ""}, + {"model", &requestflag.Flag[string]{Name: "model"}, "MODEL"}, + {"format", &cli.StringFlag{Name: "format"}, "FORMAT"}, + {"JSON path", &cli.StringFlag{Name: "transform"}, "PATH"}, + {"directory", &cli.StringFlag{Name: "output-dir"}, "DIRECTORY"}, + {"URL", &cli.StringFlag{Name: "base-url"}, "URL"}, + {"prompt", &requestflag.Flag[string]{Name: "prompt"}, "TEXT"}, + {"file path", &requestflag.Flag[any]{Name: "image", FileInput: true}, "PATH"}, + {"ordinary string", &cli.StringFlag{Name: "file"}, "TEXT"}, + {"string map", &cli.StringMapFlag{Name: "field"}, "TEXT=TEXT"}, + {"polymorphic input", &requestflag.Flag[any]{Name: "input"}, "VALUE"}, + {"polymorphic model", &requestflag.Flag[any]{Name: "model"}, "VALUE"}, + {"numeric format", &cli.IntFlag{Name: "format"}, "INTEGER"}, + {"credential", &cli.StringFlag{Name: "api-key", Value: "fake-private-test-key", HideDefault: true}, "TEXT"}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := flagValueLabel(tc.flag); got != tc.want { + t.Fatalf("label = %q; want %q", got, tc.want) + } + }) + } +} + +func TestFlagTypeLabelsPreserveMapsAndDates(t *testing.T) { + for name, want := range map[string]string{ + "date": "DATE", "datetime": "DATETIME", "time": "TIME", + "string=string": "TEXT=TEXT", "string=int": "TEXT=INTEGER", "string=any": "TEXT=VALUE", + } { + if got := flagTypeLabel(name); got != want { + t.Errorf("type %q label = %q; want %q", name, got, want) + } + } +} diff --git a/internal/clihelp/full.go b/internal/clihelp/full.go index 48dd56f1..c58a113e 100644 --- a/internal/clihelp/full.go +++ b/internal/clihelp/full.go @@ -79,7 +79,7 @@ func fullFlagGroups(command *cli.Command, flags []cli.Flag, width int) string { for _, flag := range flags { heading, description, _ := strings.Cut(fullFlag(flag), "\t") fmt.Fprintf(&out, "\n %s\n", heading) - out.WriteString(strings.TrimSuffix(wrapDescription(description, " ", width), "\n")) + out.WriteString(wrapDescription(description, " ", width)) } delete(grouped, group.Title) } @@ -92,25 +92,24 @@ func fullFlag(flag cli.Flag) string { if !ok { return rendered } + placeholder := "" + if doc.TakesValue() { + placeholder = doc.TypeName() + if placeholder == "" { + placeholder = "value" + } + } _, quoted, found := strings.Cut(doc.GetUsage(), "`") - placeholder, _, closed := strings.Cut(quoted, "`") - if !found || !closed || placeholder == "" { - return rendered + if value, _, closed := strings.Cut(quoted, "`"); found && closed && value != "" { + placeholder = value } heading, details, separated := strings.Cut(rendered, "\t") if !separated || heading != fullFlagNames(flag, placeholder) { // Preserve flags with their own presentation, such as --[no-]color. return rendered } - label := "" - if doc.TakesValue() { - label = doc.TypeName() - if label == "" { - label = "value" - } - } // Keep descriptions, defaults, and environment hints after file-input wording. - return fullFlagNames(flag, label) + "\t" + details + return fullFlagNames(flag, flagValueLabel(flag)) + "\t" + details } func fileInputUsage(flag cli.Flag, text string) string { diff --git a/internal/clihelp/full_test.go b/internal/clihelp/full_test.go index c6df2d22..a6eeb5d3 100644 --- a/internal/clihelp/full_test.go +++ b/internal/clihelp/full_test.go @@ -14,42 +14,42 @@ func TestFullFlagUsesTypesInsteadOfExamples(t *testing.T) { name, heading, details string flag cli.Flag }{ - {"prompt", "--prompt string", "32000 characters", &requestflag.Flag[string]{ + {"prompt", "--prompt TEXT", "32000 characters", &requestflag.Flag[string]{ Name: "prompt", Required: true, BodyPath: "prompt", Usage: "A text description, up to 32000 characters; use `dall-e-2` for legacy generation.", }}, - {"count", "-n int", "(default: 1)", &requestflag.Flag[*int64]{ + {"count", "-n INTEGER", "(default: 1)", &requestflag.Flag[*int64]{ Name: "n", Default: requestflag.Ptr[int64](1), Usage: "Number of images. For `dall-e-3`, only one is supported.", }}, - {"nullable boolean", "--stream boolean", "(default: false)", &requestflag.Flag[*bool]{ + {"nullable boolean", "--stream BOOLEAN", "(default: false)", &requestflag.Flag[*bool]{ Name: "stream", Default: requestflag.Ptr(false), Usage: "Defaults to `false`.", }}, {"boolean switch", "--debug, -d", "Enable debug mode.", &cli.BoolFlag{ Name: "debug", Aliases: []string{"d"}, Usage: "Enable `debug` mode.", }}, - {"native int", "--limit int, -l int", "(default: 12)", &cli.IntFlag{ + {"native int", "--limit INTEGER, -l INTEGER", "(default: 12)", &cli.IntFlag{ Name: "limit", Aliases: []string{"l"}, Value: 12, Usage: "Number of `items`.", }}, - {"list", "--tag string, -t string [ --tag string, -t string ]", "(default: \"one\")", &cli.StringSliceFlag{ + {"list", "--tag TEXT, -t TEXT [ --tag TEXT, -t TEXT ]", "(default: \"one\")", &cli.StringSliceFlag{ Name: "tag", Aliases: []string{"t"}, Value: []string{"one"}, Usage: "Repeat for each `tag`.", }}, - {"map", "--field string=string [ --field string=string ]", "Each key has a value.", &cli.StringMapFlag{ + {"map", "--field TEXT=TEXT [ --field TEXT=TEXT ]", "Each key has a value.", &cli.StringMapFlag{ Name: "field", Usage: "Each `key` has a value.", }}, - {"environment", "--mode string", "OPENAI_TEST_HELP_MODE", &cli.StringFlag{ + {"environment", "--mode TEXT", "OPENAI_TEST_HELP_MODE", &cli.StringFlag{ Name: "mode", Value: "auto", Sources: cli.EnvVars("OPENAI_TEST_HELP_MODE"), Usage: "Use `auto` to choose.", }}, - {"required native", "--required string", "Required text.", &cli.StringFlag{ + {"required native", "--required TEXT", "Required text.", &cli.StringFlag{ Name: "required", Value: "not-a-default", Required: true, Usage: "Required `text`.", }}, - {"default label", "--color string", "(default: automatic)", &cli.StringFlag{ + {"default label", "--color TEXT", "(default: automatic)", &cli.StringFlag{ Name: "color", Value: "auto", DefaultText: "automatic", Usage: "Choose `auto`.", }}, - {"credential", "--api-key string", "API key.", &requestflag.Flag[string]{ + {"credential", "--api-key TEXT", "API key.", &requestflag.Flag[string]{ Name: "api-key", Default: "fake-help-test-key", HideDefault: true, Usage: "API `key`.", }}, - {"unclosed quote", "--input string", "A `text value", &cli.StringFlag{ + {"unclosed quote", "--input TEXT", "A `text value", &cli.StringFlag{ Name: "input", Usage: "A `text value", }}, {"inverse switch", "--[no-]color, -c", "Use color.", &cli.BoolWithInverseFlag{ @@ -102,7 +102,7 @@ func TestFullHelpPreservesCategoriesGlobalOptionsAndDescriptions(t *testing.T) { t.Fatal(err) } got := out.String() - for _, want := range []string{"GLOBAL OPTIONS:", "--api-key string", "OPENAI_TEST_HELP_KEY"} { + for _, want := range []string{"GLOBAL OPTIONS:", "--api-key TEXT", "OPENAI_TEST_HELP_KEY"} { if !strings.Contains(got, want) { t.Errorf("full help missing %q:\n%s", want, got) } @@ -111,7 +111,7 @@ func TestFullHelpPreservesCategoriesGlobalOptionsAndDescriptions(t *testing.T) { t.Fatal("hidden information appeared in full help") } if args[len(args)-1] == "generate" { - for _, want := range []string{"Image inputs", "--prompt string", "Complete image reference.", "Use a description with blue and `cat`.", "All limits stay here."} { + for _, want := range []string{"Image inputs", "--prompt TEXT", "Complete image reference.", "Use a description with blue and `cat`.", "All limits stay here."} { if !strings.Contains(got, want) { t.Errorf("leaf help missing %q:\n%s", want, got) } @@ -154,3 +154,14 @@ func TestFullHelpGroupingKeepsEachFlagAndItsDetails(t *testing.T) { } } } + +func TestFullHelpSeparatesFlagEntries(t *testing.T) { + flags := []cli.Flag{ + &cli.StringFlag{Name: "first", Usage: "First setting."}, + &cli.StringFlag{Name: "second", Usage: "Second setting."}, + } + got := fullFlagGroups(&cli.Command{}, flags, 80) + if !strings.Contains(got, "First setting.\n\n --second TEXT\n") { + t.Fatalf("flag entries lack a blank line: %q", got) + } +} diff --git a/internal/clihelp/help.go b/internal/clihelp/help.go index f78ff069..6c221fac 100644 --- a/internal/clihelp/help.go +++ b/internal/clihelp/help.go @@ -24,6 +24,11 @@ START HERE {{$run}} models list List models available to your key {{call (index .Metadata "help-command-list")}} +OUTPUT + --format json Return complete JSON + --transform PATH Select a JSON field, such as id + --raw-output Print selected strings without quotes + GET HELP {{$run}} images --help Browse image commands {{$run}} images generate --help Example and common inputs diff --git a/pkg/custom/help.go b/pkg/custom/help.go index 7ffd78e2..079900e0 100644 --- a/pkg/custom/help.go +++ b/pkg/custom/help.go @@ -1,6 +1,7 @@ package custom import ( + "slices" "strings" "github.com/openai/openai-cli/internal/clihelp" @@ -27,6 +28,9 @@ func configureGlobalFlagDescriptions(root *cli.Command) { }); ok { flag = wrapped.RequestFlag() } + if wrapped, ok := flag.(interface{ CLIStringFlag() *cli.StringFlag }); ok { + flag = wrapped.CLIStringFlag() + } switch flag := flag.(type) { case *requestflag.Flag[string]: switch flag.Name { @@ -141,9 +145,45 @@ func configureHelpGroups(root *cli.Command) { }, sections...) } command.Metadata["help-flag-groups"] = sections + for _, flag := range command.Flags { + if limit, ok := flag.(*requestflag.Flag[int64]); ok && limit.Name == "max-items" && + limit.Usage == "The maximum number of items to return (use -1 for unlimited)." { + // Generated handlers distinguish omission from an explicit zero. + limit.DefaultText = "unlimited" + limit.Usage = "Maximum items to return; omit or use -1 for unlimited, or use 0 for no items." + } + } for _, child := range command.Commands { visit(child, image || child == root.Command("images")) } } visit(root, false) + // These API commands support every output option shown in their brief help. + for _, path := range [][]string{{"models", "list"}, {"models", "retrieve"}, {"responses", "create"}, {"responses", "retrieve"}} { + command := root + var local []cli.Flag + for _, name := range path { + command = command.Command(name) + if command == nil { + break + } + local = append(local, command.Flags...) + } + if command != nil { + var output []cli.Flag + for _, name := range []string{"format", "transform", "raw-output"} { + for _, flag := range root.Flags { + shadowed := slices.ContainsFunc(local, func(other cli.Flag) bool { + return slices.ContainsFunc(other.Names(), func(alias string) bool { + return slices.Contains(flag.Names(), alias) + }) + }) + if len(flag.Names()) > 0 && flag.Names()[0] == name && !shadowed { + output = append(output, flag) + } + } + } + command.Metadata["brief-output-flags"] = output + } + } } diff --git a/pkg/custom/help_test.go b/pkg/custom/help_test.go index e4b89bf8..4c440e3b 100644 --- a/pkg/custom/help_test.go +++ b/pkg/custom/help_test.go @@ -16,6 +16,48 @@ type helpRequestFlag struct{ *requestflag.Flag[string] } func (f *helpRequestFlag) RequestFlag() *requestflag.Flag[string] { return f.Flag } func (*helpRequestFlag) IsLocal() bool { return false } +type helpStringFlag struct{ *cli.StringFlag } + +func (f *helpStringFlag) CLIStringFlag() *cli.StringFlag { return f.StringFlag } + +func TestConfigureHelpWrappedStringMetadata(t *testing.T) { + flag := &cli.StringFlag{Name: mtlsClientKeyFileFlag, Value: "synthetic-private-path"} + root := &cli.Command{Flags: []cli.Flag{&helpStringFlag{flag}}} + configureGlobalFlagDescriptions(root) + if !flag.HideDefault || !strings.Contains(flag.Usage, "matching") || strings.Contains(root.Flags[0].String(), flag.Value) { + t.Fatal("wrapped string flag lost its description or exposed its configured path") + } +} + +func TestConfigureHelpPaginationMetadataPreservesValues(t *testing.T) { + flag := &requestflag.Flag[int64]{Name: "max-items", Usage: "The maximum number of items to return (use -1 for unlimited)."} + command := &cli.Command{Name: "list", Flags: []cli.Flag{flag}} + configureHelpGroups(command) + configureHelpGroups(command) + if flag.Default != 0 || flag.IsSet() || flag.DefaultText != "unlimited" || !strings.Contains(flag.Usage, "use 0 for no items") { + t.Fatal("pagination help changed parser state or lost omitted/zero guidance") + } +} + +func TestConfigureHelpOutputMetadataRespectsShadowing(t *testing.T) { + format := &cli.StringFlag{Name: "format", Aliases: []string{"f"}} + transform := &cli.StringFlag{Name: "transform"} + local := &cli.StringFlag{Name: "field", Aliases: []string{"f"}} + leaf := &cli.Command{Name: "list", Flags: []cli.Flag{local}} + root := &cli.Command{Name: "openai", Flags: []cli.Flag{format, transform}, Commands: []*cli.Command{ + {Name: "models", Commands: []*cli.Command{leaf}}, + {Name: "local"}, + }} + configureHelpGroups(root) + flags := leaf.Metadata["brief-output-flags"].([]cli.Flag) + if len(flags) != 1 || flags[0] != transform { + t.Fatalf("output help included a shadowed root flag: %v", flags) + } + if _, exists := root.Command("local").Metadata["brief-output-flags"]; exists { + t.Fatal("local command received API output help") + } +} + func TestConfigureHelpHidesConfiguredGlobalValues(t *testing.T) { for _, name := range []string{"api-key", "admin-api-key", "webhook-secret", "organization", "project"} { for _, order := range []string{"plain", "wrapped-first", "wrapped-after"} { diff --git a/pkg/custom/image_upload_help.go b/pkg/custom/image_upload_help.go index 1efb10ea..4a06a62c 100644 --- a/pkg/custom/image_upload_help.go +++ b/pkg/custom/image_upload_help.go @@ -5,6 +5,13 @@ const imageUploadQuickHelp = `{{$bin := or (index .Root.Metadata "help-invocatio Replace photo.png with your image's path and the prompt with your changes. Saves to ~/Downloads/gpt-images/. Keeps your original. + +COMMON OPTIONS + --model MODEL Choose a model ID + --size SIZE Image dimensions, such as 1024x1024 + --quality QUALITY GPT Image: auto, low, medium, or high + --name NAME Name the saved image + --output-dir DIRECTORY Save in an existing folder {{else}}Image variations are retired and no longer available. Use images edit with a GPT Image model and a prompt: {{$bin}} images edit --image "photo.png" --prompt "Create a variation of this image" diff --git a/pkg/custom/local_errors.go b/pkg/custom/local_errors.go index bf98814b..8c3bfece 100644 --- a/pkg/custom/local_errors.go +++ b/pkg/custom/local_errors.go @@ -137,13 +137,17 @@ func knownLocalError(command *cli.Command, message string) string { if flag == "--format" || flag == "--format-error" { return "Invalid output format. Choose one of: " + strings.Join(OutputFormats, ", ") + "." } - return "Invalid value for " + flag + ". Check the expected type and supported values with --help." + message := "Invalid value for " + flag + "." + if expected := parserFlagExpectedType(command, name); expected != "" { + message += " Expected " + expected + "." + } + return parserErrorGuidance(command, message) } } } if rest, ok := strings.CutPrefix(message, "flag needs an argument: "); ok { if flag := declaredErrorFlag(command, strings.TrimLeft(rest, "-")); flag != "" { - return "Add a value for " + flag + "." + return parserErrorGuidance(command, "Add a value for "+flag+".") } } // Required flag names originate in declarations, but verify them before @@ -164,12 +168,12 @@ func knownLocalError(command *cli.Command, message string) string { } flags = append(flags, flag) } - return "Missing required options: " + strings.Join(flags, ", ") + ". Check --help for usage." + return parserErrorGuidance(command, "Missing required options: "+strings.Join(flags, ", ")+".") } } switch { case strings.HasPrefix(message, "Unexpected extra arguments: "): - return "Unexpected extra arguments. Check the command's accepted arguments with --help." + return parserErrorGuidance(command, "Unexpected extra arguments.") case strings.HasPrefix(message, "no shell provided for completion command. available shells are "): return "Choose a shell for completion: openai @completion bash (or fish, pwsh, zsh)." case strings.HasPrefix(message, "unknown shell ") && strings.Contains(message, ", available shells are "): @@ -178,7 +182,12 @@ func knownLocalError(command *cli.Command, message string) string { strings.HasPrefix(message, "Invalid format: ") && strings.HasSuffix(message, ", valid formats are: "+strings.Join(OutputFormats, ", ")): return "Invalid output format. Choose one of: " + strings.Join(OutputFormats, ", ") + "." case strings.HasPrefix(message, "flag provided but not defined: -"): - return "An option is not recognized. Check the command's available options with --help." + name := strings.TrimPrefix(message, "flag provided but not defined: -") + message := "An option is not recognized." + if suggestion := suggestParserFlag(command, name); suggestion != "" { + message += " Did you mean " + suggestion + "?" + } + return parserErrorGuidance(command, message) case strings.HasPrefix(message, "No help topic for '"): return unknownCommandErrorMessage(command) case strings.HasPrefix(message, "Unknown help topic "): @@ -194,6 +203,80 @@ func knownLocalError(command *cli.Command, message string) string { return "" } +func parserErrorGuidance(command *cli.Command, message string) string { + invocation := "openai" + if command != nil { + invocation = errorHelpInvocation(command.Root()) + if path := command.Path(); len(path) > 1 { + invocation += " " + strings.Join(path[1:], " ") + } + } + return message + "\nOptions and examples: " + invocation + " --help" +} + +// These flags match the parser's current scope, including inherited flags and +// alias shadowing. Do not suggest a local flag from an ancestor command. +func parserErrorFlags(command *cli.Command) []cli.Flag { + if command == nil { + return nil + } + return append(command.VisibleFlags(), command.VisiblePersistentFlags()...) +} + +func parserFlagExpectedType(command *cli.Command, name string) string { + for _, flag := range parserErrorFlags(command) { + if !slices.Contains(flag.Names(), name) { + continue + } + if documented, ok := flag.(cli.DocGenerationFlag); ok { + switch documented.TypeName() { + case "int", "uint": + return "an integer" + case "float": + return "a number" + case "bool", "boolean": + return "a boolean (true or false)" + } + } + return "" + } + return "" +} + +func suggestParserFlag(command *cli.Command, provided string) string { + // Diagnostics are untrusted. Compare a plain flag name only, then emit the + // canonical name from its declaration. Never copy the rejected input. + for _, character := range provided { + if !(character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || + character >= '0' && character <= '9' || character == '-' || character == '_' || character == '.') { + return "" + } + } + if len(provided) < 2 { + return "" + } + provided = strings.ToLower(provided) + suggestion := "" + for _, flag := range parserErrorFlags(command) { + names := flag.Names() + for _, name := range names { + if !withinOneEdit(strings.ToLower(name), provided) { + continue + } + canonical := "--" + names[0] + if len(names[0]) == 1 { + canonical = "-" + names[0] + } + if suggestion != "" && suggestion != canonical { + return "" + } + suggestion = canonical + break + } + } + return suggestion +} + func unknownCommandErrorMessage(command *cli.Command) string { // Reuse the matcher with parsed arguments, never a suggestion copied from // the error string. It emits only names from the local command declarations. diff --git a/pkg/custom/parser_error_guidance_test.go b/pkg/custom/parser_error_guidance_test.go new file mode 100644 index 00000000..f00a1b98 --- /dev/null +++ b/pkg/custom/parser_error_guidance_test.go @@ -0,0 +1,109 @@ +package custom + +import ( + "errors" + "io" + "strings" + "testing" + + "github.com/openai/openai-cli/internal/requestflag" + "github.com/urfave/cli/v3" +) + +func TestParserErrorGuidanceTypes(t *testing.T) { + for _, test := range []struct { + name string + flag cli.Flag + want string + }{ + {"integer", &cli.IntFlag{Name: "value"}, "Expected an integer."}, + {"unsigned integer", &cli.UintFlag{Name: "value"}, "Expected an integer."}, + {"number", &cli.FloatFlag{Name: "value"}, "Expected a number."}, + {"boolean", &cli.BoolFlag{Name: "value"}, "Expected a boolean (true or false)."}, + {"nullable integer", &requestflag.Flag[*int64]{Name: "value"}, "Expected an integer."}, + {"nullable number", &requestflag.Flag[*float64]{Name: "value"}, "Expected a number."}, + {"nullable boolean", &requestflag.Flag[*bool]{Name: "value"}, "Expected a boolean (true or false)."}, + } { + t.Run(test.name, func(t *testing.T) { + root := &cli.Command{Name: "openai", Flags: []cli.Flag{test.flag}} + got := localErrorMessage(root, errors.New(`invalid value "synthetic-private-value" for flag -value: synthetic-private-reason`)) + want := "Invalid value for --value. " + test.want + "\nOptions and examples: openai --help" + if got != want { + t.Errorf("message = %q, want %q", got, want) + } + }) + } + if got := localErrorMessage(nil, errors.New("flag provided but not defined: -synthetic-private-flag")); got != "An option is not recognized.\nOptions and examples: openai --help" { + t.Errorf("nil command diagnostic = %q", got) + } +} + +func TestParserErrorSuggestionsRespectScope(t *testing.T) { + for _, test := range []struct { + name, argument, suggestion string + }{ + {"transposition", "max-itmes", "--max-items"}, + {"insertion", "max-iteems", "--max-items"}, + {"deletion", "max-ites", "--max-items"}, + {"substitution", "max-itemz", "--max-items"}, + {"uppercase", "MAX-ITEMS", "--max-items"}, + {"alias", "coutn", "--max-items"}, + {"inherited", "formta-error", "--format-error"}, + {"shadowed alias", "formta", ""}, + {"ancestor local", "api-ke", ""}, + {"hidden", "hiden", ""}, + {"ambiguous", "mile", ""}, + {"secret", "synthetic-private-token", ""}, + {"control", "max-items\x1b", ""}, + {"line break", "max-items\n", ""}, + {"suffix", "max-items=synthetic-private-value", ""}, + {"short unknown", "Z", ""}, + } { + t.Run(test.name, func(t *testing.T) { + leaf := &cli.Command{Name: "list", Flags: []cli.Flag{ + &cli.IntFlag{Name: "max-items", Aliases: []string{"count", "counts"}}, + &cli.StringFlag{Name: "file", Aliases: []string{"f"}}, + &cli.StringFlag{Name: "tile"}, + &cli.StringFlag{Name: "hidden", Hidden: true}, + }} + root := &cli.Command{Name: "openai", Writer: io.Discard, ErrWriter: io.Discard, + Flags: []cli.Flag{ + &cli.StringFlag{Name: "format", Aliases: []string{"f"}}, + &cli.StringFlag{Name: "format-error"}, + &requestflag.Flag[string]{Name: "api-key"}, + }, Commands: []*cli.Command{{Name: "models", Commands: []*cli.Command{leaf}}}, + } + ConfigureCommandErrors(root) + err := root.Run(t.Context(), []string{"openai", "models", "list", "--" + test.argument}) + if err == nil { + t.Fatal("expected parser failure") + } + got := localErrorMessage(root, err) + if !strings.Contains(got, "Options and examples: openai models list --help") { + t.Errorf("missing scoped recovery command: %q", got) + } + if test.suggestion == "" { + if strings.Contains(got, "Did you mean") { + t.Errorf("unexpected suggestion: %q", got) + } + } else if !strings.Contains(got, "Did you mean "+test.suggestion+"?") { + t.Errorf("missing suggestion %q: %q", test.suggestion, got) + } + if strings.Contains(got, "synthetic-private-") || strings.ContainsAny(got, "\x1b\r") { + t.Errorf("diagnostic exposed rejected input: %q", got) + } + }) + } +} + +func TestParserErrorTypeUsesChildFlag(t *testing.T) { + leaf := &cli.Command{Name: "create", Flags: []cli.Flag{&cli.IntFlag{Name: "project", Aliases: []string{"p"}}}} + root := &cli.Command{Name: "openai", Writer: io.Discard, ErrWriter: io.Discard, + Flags: []cli.Flag{&cli.StringFlag{Name: "project", Aliases: []string{"p"}}}, Commands: []*cli.Command{leaf}} + ConfigureCommandErrors(root) + err := root.Run(t.Context(), []string{"openai", "create", "-p", "synthetic-private-value"}) + got := localErrorMessage(root, err) + if got != "Invalid value for --project. Expected an integer.\nOptions and examples: openai create --help" { + t.Errorf("shadowed flag diagnostic = %q", got) + } +} diff --git a/pkg/custom/save_generated_images.go b/pkg/custom/save_generated_images.go index b4dc5254..b1a73b91 100644 --- a/pkg/custom/save_generated_images.go +++ b/pkg/custom/save_generated_images.go @@ -20,9 +20,16 @@ const defaultSavedImageModel = "gpt-image-2.5-sunburst" const imageGenerationQuickHelp = `{{$bin := or (index .Root.Metadata "help-invocation") "openai"}}Generate an image {{$bin}} images generate --prompt "A tiny orange robot" -Run without flags to choose settings. Ctrl+C exits. +Run without flags to choose settings in an interactive terminal. Ctrl+C exits. Saves to ~/Downloads/gpt-images/. +COMMON OPTIONS + --model MODEL Choose a model ID + --size SIZE Image dimensions, such as 1024x1024 + --quality QUALITY GPT Image: auto, low, medium, or high + --name NAME Name the saved image + --output-dir DIRECTORY Save in an existing folder + Full help: {{$bin}} help --all images generate Key setup: {{$bin}} help setup ` diff --git a/scripts/demos/record-flag-descriptions.sh b/scripts/demos/record-flag-descriptions.sh index b930cb48..e3d6030d 100755 --- a/scripts/demos/record-flag-descriptions.sh +++ b/scripts/demos/record-flag-descriptions.sh @@ -42,7 +42,7 @@ for demo_topic in "${demo_topics[@]}"; do case "$demo_topic" in root-full) demo_args=(help --all); demo_rows="$demo_full_rows";; images-full) demo_args=(help --all images generate); demo_rows="$demo_full_rows";; - root-short) demo_args=(--help); demo_rows=48;; + root-short) demo_args=(--help); demo_rows=96;; models-short) demo_args=(models list --help); demo_rows=48;; responses-short) demo_args=(responses create --help); demo_rows=48;; images-short) demo_args=(images generate --help); demo_rows=48;; From 3760a4d15041c4f86a2579f5680106a77e1d66ef Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Wed, 7 Oct 2026 15:44:43 -0700 Subject: [PATCH 04/44] Keep common image options readable in narrow terminals --- cmd/openai/main_help_examples_test.go | 5 +++++ pkg/custom/image_upload_help.go | 5 +++-- pkg/custom/save_generated_images.go | 8 +++++--- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/cmd/openai/main_help_examples_test.go b/cmd/openai/main_help_examples_test.go index 2e5d5ead..f4326613 100644 --- a/cmd/openai/main_help_examples_test.go +++ b/cmd/openai/main_help_examples_test.go @@ -92,6 +92,11 @@ func TestMainHelpImagePagesStayBrief(t *testing.T) { if lines := len(strings.Split(strings.TrimSpace(got.stdout), "\n")); lines > 18 { t.Errorf("short help uses %d lines; want one example and at most 18 lines", lines) } + for line := range strings.SplitSeq(got.stdout, "\n") { + if strings.HasPrefix(line, " --") && len(line) > 56 { + t.Errorf("common option wraps at 56 columns: %q", line) + } + } exampleOperation := operation if operation == "create-variation" { exampleOperation = "edit" diff --git a/pkg/custom/image_upload_help.go b/pkg/custom/image_upload_help.go index 4a06a62c..fb165b69 100644 --- a/pkg/custom/image_upload_help.go +++ b/pkg/custom/image_upload_help.go @@ -8,10 +8,11 @@ Saves to ~/Downloads/gpt-images/. Keeps your original. COMMON OPTIONS --model MODEL Choose a model ID - --size SIZE Image dimensions, such as 1024x1024 - --quality QUALITY GPT Image: auto, low, medium, or high + --size SIZE e.g. 1024x1024 + --quality QUALITY auto, low, medium, high --name NAME Name the saved image --output-dir DIRECTORY Save in an existing folder +Size and quality choices depend on the model. {{else}}Image variations are retired and no longer available. Use images edit with a GPT Image model and a prompt: {{$bin}} images edit --image "photo.png" --prompt "Create a variation of this image" diff --git a/pkg/custom/save_generated_images.go b/pkg/custom/save_generated_images.go index b1a73b91..b7aa8b2e 100644 --- a/pkg/custom/save_generated_images.go +++ b/pkg/custom/save_generated_images.go @@ -20,15 +20,17 @@ const defaultSavedImageModel = "gpt-image-2.5-sunburst" const imageGenerationQuickHelp = `{{$bin := or (index .Root.Metadata "help-invocation") "openai"}}Generate an image {{$bin}} images generate --prompt "A tiny orange robot" -Run without flags to choose settings in an interactive terminal. Ctrl+C exits. +Run without flags to choose settings +in an interactive terminal. Ctrl+C exits. Saves to ~/Downloads/gpt-images/. COMMON OPTIONS --model MODEL Choose a model ID - --size SIZE Image dimensions, such as 1024x1024 - --quality QUALITY GPT Image: auto, low, medium, or high + --size SIZE e.g. 1024x1024 + --quality QUALITY auto, low, medium, high --name NAME Name the saved image --output-dir DIRECTORY Save in an existing folder +Size and quality choices depend on the model. Full help: {{$bin}} help --all images generate Key setup: {{$bin}} help setup From 062a69155347b845b15f7b03f2da104f1a2c0133 Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Wed, 7 Oct 2026 15:54:41 -0700 Subject: [PATCH 05/44] Label string file options as paths in help --- cmd/openai/flag_descriptions_test.go | 3 +++ internal/clihelp/flag_labels.go | 7 +++++++ internal/clihelp/flag_labels_test.go | 25 +++++++++++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/cmd/openai/flag_descriptions_test.go b/cmd/openai/flag_descriptions_test.go index efaf78b3..f1d137f1 100644 --- a/cmd/openai/flag_descriptions_test.go +++ b/cmd/openai/flag_descriptions_test.go @@ -61,6 +61,9 @@ func TestMainGlobalFlagDescriptions(t *testing.T) { remaining = after } text := strings.Join(strings.Fields(got.stdout), " ") + for _, flag := range []string{"--mtls-client-cert-file", "--mtls-client-key-file"} { + require.Contains(t, got.stdout, "\n "+flag+" PATH\n") + } for _, format := range []string{"auto", "text", "json", "jsonl", "yaml", "explore", "pretty", "raw"} { require.Contains(t, got.stdout, "\n - "+format+":", "format choices must stay on separate lines") } diff --git a/internal/clihelp/flag_labels.go b/internal/clihelp/flag_labels.go index 6deeae13..c66a1281 100644 --- a/internal/clihelp/flag_labels.go +++ b/internal/clihelp/flag_labels.go @@ -15,6 +15,13 @@ func flagValueLabel(flag cli.Flag) string { if input, ok := flag.(interface{ IsFileInput() bool }); ok && input.IsFileInput() { return "PATH" } + fileFlag := flag + if wrapped, ok := flag.(interface{ CLIStringFlag() *cli.StringFlag }); ok { + fileFlag = wrapped.CLIStringFlag() + } + if value, ok := fileFlag.(*cli.StringFlag); ok && value != nil && value.TakesFile { + return "PATH" + } if doc.TypeName() == "string" { if names := flag.Names(); len(names) > 0 { switch names[0] { diff --git a/internal/clihelp/flag_labels_test.go b/internal/clihelp/flag_labels_test.go index 9d9cd881..b78c6986 100644 --- a/internal/clihelp/flag_labels_test.go +++ b/internal/clihelp/flag_labels_test.go @@ -1,12 +1,17 @@ package clihelp import ( + "strings" "testing" "github.com/openai/openai-cli/internal/requestflag" "github.com/urfave/cli/v3" ) +type wrappedStringHelpFlag struct{ *cli.StringFlag } + +func (f *wrappedStringHelpFlag) CLIStringFlag() *cli.StringFlag { return f.StringFlag } + func TestFlagValueLabelsDescribeAcceptedInputs(t *testing.T) { for _, tc := range []struct { name string @@ -26,6 +31,9 @@ func TestFlagValueLabelsDescribeAcceptedInputs(t *testing.T) { {"URL", &cli.StringFlag{Name: "base-url"}, "URL"}, {"prompt", &requestflag.Flag[string]{Name: "prompt"}, "TEXT"}, {"file path", &requestflag.Flag[any]{Name: "image", FileInput: true}, "PATH"}, + {"string file path", &cli.StringFlag{Name: "certificate", TakesFile: true}, "PATH"}, + {"wrapped file path", &wrappedStringHelpFlag{&cli.StringFlag{Name: "certificate", TakesFile: true}}, "PATH"}, + {"wrapped ordinary string", &wrappedStringHelpFlag{&cli.StringFlag{Name: "certificate"}}, "TEXT"}, {"ordinary string", &cli.StringFlag{Name: "file"}, "TEXT"}, {"string map", &cli.StringMapFlag{Name: "field"}, "TEXT=TEXT"}, {"polymorphic input", &requestflag.Flag[any]{Name: "input"}, "VALUE"}, @@ -41,6 +49,23 @@ func TestFlagValueLabelsDescribeAcceptedInputs(t *testing.T) { } } +func TestFileValueLabelsPreserveHiddenDefaults(t *testing.T) { + for _, wrapped := range []bool{false, true} { + value := &cli.StringFlag{Name: "certificate", TakesFile: true, Value: "synthetic-private-path", HideDefault: true} + var flag cli.Flag = value + if wrapped { + flag = &wrappedStringHelpFlag{value} + } + before := flag.String() + if got := fullFlag(flag); !strings.Contains(got, "--certificate PATH") || strings.Contains(got, value.Value) { + t.Fatalf("file help lost its label or exposed its value: %q", got) + } + if flag.String() != before || !value.HideDefault || value.Value != "synthetic-private-path" { + t.Fatal("file label changed flag metadata or values") + } + } +} + func TestFlagTypeLabelsPreserveMapsAndDates(t *testing.T) { for name, want := range map[string]string{ "date": "DATE", "datetime": "DATETIME", "time": "TIME", From 40e1888aed1c411592dba4d1887fd0d9875424de Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Wed, 7 Oct 2026 16:51:56 -0700 Subject: [PATCH 06/44] Unify complete command help and preserve help routing --- README.md | 13 +- cmd/openai/command_subgroups_test.go | 2 +- cmd/openai/complete_help_test.go | 158 +++++++++ cmd/openai/flag_descriptions_test.go | 10 +- cmd/openai/help_experience_test.go | 2 +- cmd/openai/image_edit_saving_test.go | 3 +- cmd/openai/image_settings_help_test.go | 22 +- cmd/openai/main_help_examples_test.go | 35 +- cmd/openai/main_help_test.go | 63 ++-- cmd/openai/main_image_models_test.go | 2 +- cmd/openai/main_readable_errors_test.go | 8 +- cmd/openai/main_shell_test.go | 38 +-- cmd/openai/main_suggest_test.go | 4 +- cmd/openai/normal_help_examples_test.go | 25 +- cmd/openai/run_help_test.go | 8 +- docs/image-generation-saving.md | 2 +- internal/autocomplete/autocomplete.go | 19 +- .../completion_experience_test.go | 2 +- internal/autocomplete/legacy_help_test.go | 51 +++ internal/clihelp/brief.go | 216 ------------- internal/clihelp/brief_test.go | 306 ------------------ internal/clihelp/command_help.go | 153 +++++++++ internal/clihelp/command_help_test.go | 220 +++++++++++++ internal/clihelp/command_navigation_test.go | 4 +- internal/clihelp/complete_routing_test.go | 253 +++++++++++++++ internal/clihelp/file_input_help_test.go | 2 +- internal/clihelp/flag_labels.go | 23 ++ internal/clihelp/flag_metadata_test.go | 73 +++++ internal/clihelp/full.go | 94 ++++-- internal/clihelp/full_test.go | 9 +- internal/clihelp/help.go | 210 +++++++++--- internal/clihelp/help_test.go | 6 +- internal/clihelp/wrap_descriptions_test.go | 4 +- pkg/custom/help.go | 124 ++++--- pkg/custom/help_test.go | 114 +++++-- pkg/custom/image_upload_help.go | 2 +- pkg/custom/local_errors.go | 8 +- pkg/custom/local_errors_test.go | 2 +- pkg/custom/preview_saved_image.go | 2 +- pkg/custom/readable_errors.go | 2 +- pkg/custom/save_generated_images.go | 2 +- 41 files changed, 1510 insertions(+), 786 deletions(-) create mode 100644 cmd/openai/complete_help_test.go create mode 100644 internal/autocomplete/legacy_help_test.go delete mode 100644 internal/clihelp/brief.go delete mode 100644 internal/clihelp/brief_test.go create mode 100644 internal/clihelp/command_help.go create mode 100644 internal/clihelp/command_help_test.go create mode 100644 internal/clihelp/complete_routing_test.go create mode 100644 internal/clihelp/flag_metadata_test.go diff --git a/README.md b/README.md index 74717d35..55c49d30 100644 --- a/README.md +++ b/README.md @@ -88,13 +88,16 @@ Help works without an API key or network connection: ```sh openai help setup # Enter your API key safely -openai images generate --help # Example and common inputs -openai help --all images generate # Every image option and its details -openai help --all # Command groups and global options +openai images generate --help # Example and every image option +openai help images generate # The same complete image help +openai --help # Command groups and global options ``` -Add `--help` (or `-h`) to any command for its short guide. Help changes only -what is displayed; command behavior, defaults, and output formats are unchanged. +Add `--help` (or `-h`) to any command for its complete help page. +`openai help COMMAND` shows the same page. +You can place `--help` after valid command options. +The old `help --all` spelling prints migration guidance and the complete page. +Command behavior, defaults, and output formats remain unchanged. Tab completion follows the same group order and supports help topics and file input paths. After upgrading, [reload shell completion](docs/shell-completion.md) to enable the new file-path behavior. Older loaded completion scripts keep their previous behavior until refreshed. diff --git a/cmd/openai/command_subgroups_test.go b/cmd/openai/command_subgroups_test.go index 537cb3c0..b22daf7d 100644 --- a/cmd/openai/command_subgroups_test.go +++ b/cmd/openai/command_subgroups_test.go @@ -203,7 +203,7 @@ func TestMainCommandSubgroupsPreserveAudioAndErrors(t *testing.T) { for _, resource := range [][]string{{"audio:transcriptions"}, {"audio", "transcriptions"}} { command := append(append([]string{}, resource...), args[1:]...) failure := runMainCommandAPIErrorResponse(t, 400, "application/json", `{"error":{"message":"synthetic","type":"invalid_request_error","code":"invalid_value","param":"response_format"}}`, command) - if failure.code == 0 || failure.stdout != "" || !strings.Contains(failure.stderr, "json, text, srt") || !strings.Contains(failure.stderr, "help --all "+strings.Join(resource, " ")+" create") { + if failure.code == 0 || failure.stdout != "" || !strings.Contains(failure.stderr, "json, text, srt") || !strings.Contains(failure.stderr, "help "+strings.Join(resource, " ")+" create") { t.Fatalf("audio error context changed: %+v", failure) } } diff --git a/cmd/openai/complete_help_test.go b/cmd/openai/complete_help_test.go new file mode 100644 index 00000000..b42e6d2b --- /dev/null +++ b/cmd/openai/complete_help_test.go @@ -0,0 +1,158 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +func TestMainCompleteHelpAfterOptions(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + http.Error(w, "help must remain offline", http.StatusBadRequest) + })) + defer server.Close() + env := []string{"OPENAI_BASE_URL=" + server.URL, "OPENAI_API_KEY="} + for _, tc := range []struct { + path, flags []string + }{ + {[]string{"images", "generate"}, []string{"--size", "1024x1024", "--prompt", "--help"}}, + {[]string{"images", "edit"}, []string{"--image", "missing-help-fixture.png", "--prompt", "help"}}, + {[]string{"images", "preview"}, []string{"missing-help-fixture.png"}}, + {[]string{"models", "retrieve"}, []string{"--model", "help"}}, + {[]string{"models", "list"}, []string{"--max-items", "0", "--format", "json"}}, + {[]string{"admin", "organization", "audit-logs", "list"}, []string{"--max-items", "1"}}, + } { + t.Run(strings.Join(tc.path, "/"), func(t *testing.T) { + base := append([]string{"openai"}, tc.path...) + want := runMainDispatchWithEnv(t, "bash", env, append(base, "--help")...) + if want.code != 0 || want.stderr != "" { + t.Fatalf("canonical help failed: %+v", want) + } + for _, trigger := range []string{"--help", "-h", "--help=true", "-h=true"} { + args := append(append(append([]string{}, base...), tc.flags...), trigger) + if got := runMainDispatchWithEnv(t, "bash", env, args...); got != want { + t.Errorf("help after options changed the page: args=%q got=%+v", args, got) + } + args = append(append([]string{"openai", trigger}, tc.path...), tc.flags...) + if got := runMainDispatchWithEnv(t, "bash", env, args...); got != want { + t.Errorf("help before command changed the page: args=%q got=%+v", args, got) + } + } + }) + } + if requests.Load() != 0 { + t.Fatalf("help made %d requests", requests.Load()) + } +} + +func TestMainCompleteHelpLegacyMigration(t *testing.T) { + for _, path := range [][]string{nil, {"images", "generate"}, {"admin", "organization", "audit-logs", "list"}} { + t.Run(strings.Join(append([]string{"root"}, path...), "/"), func(t *testing.T) { + want := runMainDispatch(t, "bash", append([]string{"openai", "help"}, path...)...) + if want.code != 0 || want.stderr != "" { + t.Fatalf("canonical help failed: %+v", want) + } + routes := [][]string{ + append([]string{"openai", "help", "--all"}, path...), + append([]string{"openai", "help", "-all"}, path...), + append([]string{"openai", "help", "--all=false"}, path...), + append(append([]string{"openai", "help"}, path...), "--all"), + append([]string{"openai", "--help", "--all"}, path...), + } + for _, args := range routes { + got := runMainDispatch(t, "bash", args...) + if got.code != 0 || got.stderr != "" || !strings.HasSuffix(got.stdout, want.stdout) { + t.Errorf("legacy help lost canonical content: args=%q got=%+v", args, got) + continue + } + notice := strings.TrimSuffix(got.stdout, want.stdout) + replacement := strings.TrimSpace("openai help " + strings.Join(path, " ")) + if !strings.Contains(notice, replacement) || strings.Count(notice, "--all") != 1 { + t.Errorf("legacy help lacks one precise migration notice: %q", notice) + } + } + }) + } +} + +func TestMainCompleteHelpTypedLegacyCompletion(t *testing.T) { + for _, style := range []string{"bash", "zsh", "fish", "pwsh"} { + for _, flag := range []string{"--all", "-all", "--all=false", "-all=true"} { + got := runMainDispatch(t, style, mainCompletionArgs(style, "help", flag, "responses", "cr")...) + if got.code != 0 || got.stderr != "" || !strings.HasPrefix(got.stdout, "create") { + t.Errorf("%s lost supported legacy %q completion: %+v", style, flag, got) + } + } + for _, args := range [][]string{ + {"help", "--all=invalid", "responses", "cr"}, + {"help", "-all=invalid", "responses", "cr"}, + {"help", "---all", "responses", "cr"}, + {"help", "--", "--all", "responses", "cr"}, + } { + got := runMainDispatch(t, style, mainCompletionArgs(style, args...)...) + if got.code != 11 || got.stderr != "" || got.stdout != "" { + t.Errorf("%s completed malformed or literal arguments %q: %+v", style, args, got) + } + } + } +} + +func TestMainCompleteHelpRejectsMalformedLegacyPrefix(t *testing.T) { + for _, args := range [][]string{ + {"openai", "---all", "--help"}, + {"openai", "---all=false", "--help"}, + {"openai", "----all", "--help", "images", "generate"}, + } { + got := runMainDispatch(t, "bash", args...) + if got.code != 1 || got.stdout != "" || got.stderr == "" { + t.Errorf("malformed legacy flag bypassed parsing: args=%q got=%+v", args, got) + } + } +} + +func TestMainCompleteHelpCompletionHidesLegacyFlag(t *testing.T) { + for _, style := range []string{"bash", "zsh", "fish", "pwsh"} { + for _, path := range [][]string{{"help"}, {"help", "images", "generate"}} { + got := runMainDispatch(t, style, mainCompletionArgs(style, append(path, "--")...)...) + if got.code != 0 || got.stderr != "" || strings.Contains(got.stdout, "--all") { + t.Errorf("%s advertises retired help flag: %+v", style, got) + } + } + got := runMainDispatch(t, style, mainCompletionArgs(style, "images", "models", "--")...) + if got.code != 0 || got.stderr != "" || !strings.Contains(got.stdout, "--all") { + t.Errorf("%s lost image-model discovery flag: %+v", style, got) + } + } +} + +func TestMainCompleteHelpGlobalMetadata(t *testing.T) { + got := runMainDispatch(t, "bash", "openai", "--help") + if got.code != 0 || got.stderr != "" { + t.Fatalf("root help failed: %+v", got) + } + for _, heading := range []string{ + "--api-key KEY", "--admin-api-key KEY", "--organization ID", "--project ID", "--webhook-secret SECRET", + "--header, -H 'NAME: VALUE'", "--base-url URL", "--mtls-client-cert-file PATH", "--mtls-client-key-file PATH", + } { + if !strings.Contains(got.stdout, "\n "+heading+"\n") { + t.Errorf("missing semantic flag heading %q", heading) + } + } + for _, line := range []string{ + "Env: OPENAI_API_KEY", "Env: OPENAI_ADMIN_KEY", "Env: OPENAI_BASE_URL", + "Env: OPENAI_ORG_ID", "Env: OPENAI_PROJECT_ID", "Env: OPENAI_WEBHOOK_SECRET", + "Env: OPENAI_MTLS_CLIENT_CERT_FILE", "Env: OPENAI_MTLS_CLIENT_KEY_FILE", + "Default: https://api.openai.com/v1", + } { + if !strings.Contains(got.stdout, "\n "+line+"\n") { + t.Errorf("missing separate metadata line %q", line) + } + } + if strings.Contains(got.stdout, "(default:") || strings.Contains(got.stdout, "[$OPENAI_") { + t.Error("default or environment metadata remains embedded in a description") + } +} diff --git a/cmd/openai/flag_descriptions_test.go b/cmd/openai/flag_descriptions_test.go index f1d137f1..747b35cc 100644 --- a/cmd/openai/flag_descriptions_test.go +++ b/cmd/openai/flag_descriptions_test.go @@ -9,7 +9,7 @@ import ( ) func TestMainGlobalFlagErrorTransformExample(t *testing.T) { - help := runMainDispatch(t, "bash", "openai", "help", "--all") + help := runMainDispatch(t, "bash", "openai", "help") require.Zero(t, help.code, help.stderr) _, description, found := strings.Cut(help.stdout, "\n --transform-error ") require.True(t, found) @@ -38,7 +38,7 @@ func TestMainGlobalFlagDescriptions(t *testing.T) { } for _, path := range [][]string{nil, {"models", "list"}, {"images"}, {"images", "generate"}} { t.Run(strings.Join(append([]string{"root"}, path...), "/"), func(t *testing.T) { - args := append([]string{"openai", "help", "--all"}, path...) + args := append([]string{"openai", "help"}, path...) got := runMainDispatchWithEnv(t, "bash", nil, args...) require.Zero(t, got.code, got.stderr) require.Empty(t, got.stderr) @@ -81,7 +81,7 @@ func TestMainGlobalFlagDescriptions(t *testing.T) { "Inherits json, jsonl, raw, or yaml from --format unless explicitly set.", "API and local errors use 'message'; streamed errors can use 'error.message'.", "Repeat for multiple headers; the last value for each header name wins.", - "OPENAI_CUSTOM_HEADERS accepts one 'Name: Value' header per line.", + "Environment headers use one 'Name: Value' header per line.", "Flags override matching environment headers.", "https://api.openai.com/v1", } { @@ -128,7 +128,7 @@ func TestMainGlobalFlagHelpHidesConfiguredValues(t *testing.T) { for _, explicit := range []bool{false, true} { for _, route := range [][]string{ {"--help"}, {"images", "generate", "--help"}, {"models", "list", "--help"}, - {"help", "--all"}, {"help", "--all", "images", "generate"}, {"help", "--all", "models", "list"}, + {"help"}, {"help", "images", "generate"}, {"help", "models", "list"}, } { name := "environment/" if explicit { @@ -158,7 +158,7 @@ func TestMainGlobalFlagHelpHidesConfiguredValues(t *testing.T) { func TestMainGlobalFlagDescriptionsPreserveRequestProject(t *testing.T) { for _, path := range [][]string{{"admin", "organization", "invites", "create"}, {"admin:organization:invites", "create"}} { t.Run(strings.Join(path, "/"), func(t *testing.T) { - args := append([]string{"openai", "help", "--all"}, path...) + args := append([]string{"openai", "help"}, path...) got := runMainDispatchWithEnv(t, "bash", nil, args...) require.Zero(t, got.code, got.stderr) require.Empty(t, got.stderr) diff --git a/cmd/openai/help_experience_test.go b/cmd/openai/help_experience_test.go index ae3a1fd6..6472fcc0 100644 --- a/cmd/openai/help_experience_test.go +++ b/cmd/openai/help_experience_test.go @@ -59,7 +59,7 @@ func TestMainHelpExperienceUnknownTopicUsesParent(t *testing.T) { t.Errorf("direct typo suggestion differs from help: %+v", got) } got = runMainDispatch(t, "bash", "openai", "help", "admin", "organization", "zzzzzzzzzz") - if got.code != 3 || got.stdout != "" || !strings.Contains(got.stderr, "openai help --all admin organization") || strings.Contains(got.stderr, "zzzzzzzzzz") { + if got.code != 3 || got.stdout != "" || !strings.Contains(got.stderr, "openai help admin organization") || strings.Contains(got.stderr, "zzzzzzzzzz") { t.Errorf("unknown topic lacks contextual, value-safe recovery: %+v", got) } got = runMainDispatch(t, "bash", "openai", "--format-error", "json", "help", "admin", "organization", "projcts") diff --git a/cmd/openai/image_edit_saving_test.go b/cmd/openai/image_edit_saving_test.go index 830f976f..3471dbb6 100644 --- a/cmd/openai/image_edit_saving_test.go +++ b/cmd/openai/image_edit_saving_test.go @@ -357,7 +357,8 @@ func TestMainImageUploadHelpIsOffline(t *testing.T) { require.Contains(t, got.stdout, flag) } } else { - require.Contains(t, got.stdout, "openai help --all images "+operation) + require.Contains(t, got.stdout, "OPTIONS:") + require.Contains(t, got.stdout, "--output-dir") } require.Contains(t, strings.ToLower(got.stdout), "save") } diff --git a/cmd/openai/image_settings_help_test.go b/cmd/openai/image_settings_help_test.go index 5133f600..ee08bf1d 100644 --- a/cmd/openai/image_settings_help_test.go +++ b/cmd/openai/image_settings_help_test.go @@ -25,25 +25,29 @@ func TestMainImageSettingsHelpScope(t *testing.T) { short := runMainDispatchWithEnv(t, "bash", env, "openai", "images", operation, "--help") require.Zero(t, short.code, short.stderr) require.Empty(t, short.stderr) - require.LessOrEqual(t, len(strings.Split(strings.TrimSpace(short.stdout), "\n")), 18) - require.Contains(t, short.stdout, "~/Downloads/gpt-images/") + require.Contains(t, short.stdout, "OPTIONS:") + require.NotContains(t, short.stdout, "Full help:") if operation != "create-variation" { - require.Contains(t, short.stdout, "--name NAME") + require.Contains(t, short.stdout, "~/Downloads/gpt-images/") + } + if operation != "create-variation" { + require.Contains(t, short.stdout, "--name TEXT") require.Contains(t, short.stdout, "--output-dir DIRECTORY") if operation == "generate" { - require.Contains(t, short.stdout, "interactive terminal") + require.Contains(t, short.stdout, "choose settings in an interactive terminal") } } - require.Contains(t, short.stdout, "Full help: openai help --all images "+operation) - full := runMainDispatchWithEnv(t, "bash", env, "openai", "help", "--all", "images", operation) + + full := runMainDispatchWithEnv(t, "bash", env, "openai", "help", "images", operation) require.Zero(t, full.code, full.stderr) require.Empty(t, full.stderr) + require.Equal(t, short.stdout, full.stdout) _, description, found := strings.Cut(full.stdout, "\nDESCRIPTION:\n") require.True(t, found, "full help must include the command description") - description, _, found = strings.Cut(description, "\nOPTIONS:") + description, _, found = strings.Cut(description, "\nEXAMPLES:") require.True(t, found, "full help must retain the separate option reference") require.LessOrEqual(t, len(strings.Fields(description)), 65, "keep the introduction concise; details belong in the option reference") - require.Equal(t, 1, strings.Count(description, "openai images "), "lead with one useful example") + require.Equal(t, 1, strings.Count(full.stdout, "--prompt \""), "lead with one useful example") text := strings.Join(strings.Fields(full.stdout), " ") for _, want := range []string{"--name", "-2, -3", "Uses your saved preference unless set", "Pipes and CI never show previews"} { require.Contains(t, text, want) @@ -94,7 +98,7 @@ func TestMainImageSettingsCompleteExamples(t *testing.T) { } settings[strings.TrimPrefix(flag, "--")] = value } - help := runMainDispatch(t, "bash", "openai", "help", "--all", "images", tc.helpTopic) + help := runMainDispatch(t, "bash", "openai", "help", "images", tc.helpTopic) require.Zero(t, help.code, help.stderr) require.Contains(t, help.stdout, example.String(), "the exact example must remain copyable") home := t.TempDir() diff --git a/cmd/openai/main_help_examples_test.go b/cmd/openai/main_help_examples_test.go index f4326613..f2b4158e 100644 --- a/cmd/openai/main_help_examples_test.go +++ b/cmd/openai/main_help_examples_test.go @@ -19,9 +19,9 @@ func TestMainHelpCompleteImageExamples(t *testing.T) { helpTopic, operation, example string args []string }{ - {"generate", "generate", `openai images generate --prompt "A tiny orange robot"`, []string{"--prompt", "A tiny orange robot"}}, - {"edit", "edit", `openai images edit --image "photo.png" --prompt "Make the sky purple"`, []string{"--image", "photo.png", "--prompt", "Make the sky purple"}}, - {"create-variation", "edit", `openai images edit --image "photo.png" --prompt "Create a variation of this image"`, []string{"--image", "photo.png", "--prompt", "Create a variation of this image"}}, + {"generate", "generate", `openai images generate --prompt "A tiny cat" --name cat`, []string{"--prompt", "A tiny cat", "--name", "cat"}}, + {"edit", "edit", `openai images edit --image "photo.png" --prompt "Make the sky purple" --name purple-sky`, []string{"--image", "photo.png", "--prompt", "Make the sky purple", "--name", "purple-sky"}}, + {"create-variation", "edit", `openai images edit --image "photo.png" --prompt "Create a variation of this image" --name variation`, []string{"--image", "photo.png", "--prompt", "Create a variation of this image", "--name", "variation"}}, } { t.Run(tc.example, func(t *testing.T) { help := runMainDispatch(t, "bash", "openai", "images", tc.helpTopic, "--help") @@ -52,7 +52,7 @@ func TestMainHelpCompleteImageExamples(t *testing.T) { count := 1 if tc.operation == "generate" { var body map[string]any - if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body["prompt"] != "A tiny orange robot" { + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body["prompt"] != "A tiny cat" { t.Errorf("example prompt changed: %v, %v", body, err) } count = int(body["n"].(float64)) @@ -82,30 +82,39 @@ func TestMainHelpCompleteImageExamples(t *testing.T) { } } -func TestMainHelpImagePagesStayBrief(t *testing.T) { +func TestMainHelpImagePagesAreComplete(t *testing.T) { for _, operation := range []string{"generate", "edit", "create-variation", "preview"} { t.Run(operation, func(t *testing.T) { got := runMainDispatch(t, "bash", "openai", "images", operation, "--help") if got.code != 0 || got.stderr != "" { t.Fatalf("short help failed: %+v", got) } - if lines := len(strings.Split(strings.TrimSpace(got.stdout), "\n")); lines > 18 { - t.Errorf("short help uses %d lines; want one example and at most 18 lines", lines) + for _, heading := range []string{"SYNOPSIS:", "EXAMPLES:", "OPTIONS:", "GLOBAL OPTIONS:"} { + if !strings.Contains(got.stdout, heading) { + t.Errorf("complete page lacks %q", heading) + } } - for line := range strings.SplitSeq(got.stdout, "\n") { - if strings.HasPrefix(line, " --") && len(line) > 56 { - t.Errorf("common option wraps at 56 columns: %q", line) + for _, name := range map[string][]string{ + "generate": {"--output-compression", "--partial-images", "--moderation"}, + "edit": {"--mask", "--input-fidelity", "--output-compression"}, + "create-variation": {"--image", "--response-format", "--output-dir"}, + "preview": {"--inline"}, + }[operation] { + if !strings.Contains(got.stdout, name) { + t.Errorf("complete help omitted %q", name) } } exampleOperation := operation if operation == "create-variation" { exampleOperation = "edit" } - if strings.Count(got.stdout, " openai images "+exampleOperation+" ") != 1 { + _, examples, _ := strings.Cut(got.stdout, "EXAMPLES:\n") + examples, _, _ = strings.Cut(examples, "\nOPTIONS:") + if strings.Count(examples, "openai images "+exampleOperation+" ") != 1 { t.Errorf("expected one complete example: %s", got.stdout) } - if !strings.Contains(got.stdout, "Full help: openai help --all images "+operation) { - t.Errorf("full help is not discoverable: %s", got.stdout) + if strings.Contains(got.stdout, "Full help:") || strings.Contains(got.stdout, "help --all") { + t.Errorf("complete help advertises a second help mode: %s", got.stdout) } }) } diff --git a/cmd/openai/main_help_test.go b/cmd/openai/main_help_test.go index 23d8bea0..fc64ff21 100644 --- a/cmd/openai/main_help_test.go +++ b/cmd/openai/main_help_test.go @@ -22,10 +22,8 @@ func TestMainHelpWelcome(t *testing.T) { if want.code != 0 || want.stderr != "" { t.Fatalf("welcome failed: %+v", want) } - if lines := len(strings.Split(strings.TrimSpace(want.stdout), "\n")); lines > 75 { - t.Errorf("complete command overview uses %d lines; want at most 75", lines) - } - for _, text := range []string{"openai help setup", "openai models list", "--help", "help --all", "openai images --help", "openai images generate --help"} { + + for _, text := range []string{"openai help setup", "openai models list", "NAME:", "SYNOPSIS:", "GLOBAL OPTIONS:", "--base-url URL", "--header"} { if !strings.Contains(want.stdout, text) { t.Errorf("welcome lacks %q: %s", text, want.stdout) } @@ -43,20 +41,26 @@ func TestMainHelpWelcome(t *testing.T) { } func TestMainHelpCommandRoutes(t *testing.T) { - for _, path := range [][]string{{"images"}, {"images", "generate"}, {"models", "retrieve"}, {"responses", "create"}} { + for _, path := range [][]string{{"images"}, {"images", "generate"}, {"images", "edit"}, {"images", "preview"}, {"images", "models"}, {"models", "list"}, {"models", "retrieve"}, {"responses", "create"}, {"admin", "organization", "audit-logs", "list"}, {"admin:organization:audit-logs", "list"}, {"files", "upload"}} { t.Run(strings.Join(path, "/"), func(t *testing.T) { control := append([]string{"openai"}, path...) want := runMainDispatch(t, "bash", append(control, "--help")...) if want.code != 0 || want.stderr != "" || want.stdout == "" { t.Fatalf("command help failed: %+v", want) } - if lines := len(strings.Split(strings.TrimSpace(want.stdout), "\n")); lines > 30 { - t.Errorf("short help uses %d lines; want at most 30", lines) + for _, heading := range []string{"NAME:", "SYNOPSIS:", "GLOBAL OPTIONS:"} { + if !strings.Contains(want.stdout, heading) { + t.Errorf("complete help lacks %q: %s", heading, want.stdout) + } } - if !strings.Contains(want.stdout, "help --all "+strings.Join(path, " ")) { - t.Errorf("short help lacks the full reference command: %s", want.stdout) + for _, stale := range []string{"Full help:", "more in full help", "help --all"} { + if strings.Contains(want.stdout, stale) { + t.Errorf("complete help advertises obsolete guidance %q", stale) + } } routes := [][]string{ + append([]string{"openai", "--help"}, path...), + append([]string{"openai", "-h"}, path...), append([]string{"openai", "help"}, path...), append(append([]string{"openai"}, path...), "-h"), append(append([]string{"openai"}, path...), "--h"), @@ -82,14 +86,16 @@ func TestMainHelpCommandRoutes(t *testing.T) { // Check the public executable rather than only template configuration: the // generated command tree remains the authority for each available option. func TestMainHelpFullReferencePreservesEveryFlag(t *testing.T) { + visited := 0 var visit func(*cli.Command, []string, []cli.Flag) visit = func(command *cli.Command, path []string, inherited []cli.Flag) { if command.Hidden { return } + visited++ flags := append(slices.Clone(inherited), command.VisibleFlags()...) t.Run(strings.Join(append([]string{"openai"}, path...), "/"), func(t *testing.T) { - got := runMainDispatch(t, "bash", append([]string{"openai", "help", "--all"}, path...)...) + got := runMainDispatch(t, "bash", append([]string{"openai", "help"}, path...)...) if got.code != 0 || got.stderr != "" { t.Fatalf("full help failed: %+v", got) } @@ -145,18 +151,21 @@ func TestMainHelpFullReferencePreservesEveryFlag(t *testing.T) { } } visit(cmd.Command, nil, nil) + if visited < 100 { + t.Fatalf("complete-help coverage visited only %d commands", visited) + } } func TestMainHelpFullReferenceRoutes(t *testing.T) { // Full reference routes must also agree on default values, not just names. var reference string for _, args := range [][]string{ - {"openai", "help", "--all", "images", "generate"}, - {"openai", "help", "images", "generate", "--all"}, - {"openai", "images", "help", "--all", "generate"}, - {"openai", "--debug", "help", "--all", "images", "generate"}, - {"openai", "images", "--debug", "help", "--all", "generate"}, - {"openai", "images", "help", "--all", "--", "generate"}, + {"openai", "help", "images", "generate"}, + {"openai", "help", "images", "generate", "-h"}, + {"openai", "images", "help", "generate"}, + {"openai", "--debug", "help", "images", "generate"}, + {"openai", "images", "--debug", "help", "generate"}, + {"openai", "images", "help", "--", "generate"}, } { got := runMainDispatch(t, "bash", args...) if got.code != 0 || got.stderr != "" { @@ -167,7 +176,7 @@ func TestMainHelpFullReferenceRoutes(t *testing.T) { } else if got.stdout != reference { t.Errorf("args %q: full help differs between routes", args) } - for _, text := range []string{"--prompt TEXT", "-n INTEGER", "(default: auto)", "(default: 1)", "(default: png)", "(default: 100)", "(default: 0)"} { + for _, text := range []string{"--prompt TEXT", "-n, --count INTEGER", "Default: auto", "Default: 1", "Default: png", "Default: 100", "Default: 0"} { if !strings.Contains(got.stdout, text) { t.Errorf("args %q: full help lost label/default %q", args, text) } @@ -192,7 +201,7 @@ func TestMainHelpFullReferenceRoutes(t *testing.T) { } func TestMainHelpKeepsAPIProjectInCommandOptions(t *testing.T) { - got := runMainDispatch(t, "bash", "openai", "help", "--all", "admin:organization:invites", "create") + got := runMainDispatch(t, "bash", "openai", "help", "admin:organization:invites", "create") if got.code != 0 || got.stderr != "" { t.Fatalf("full help failed: %+v", got) } @@ -216,10 +225,10 @@ func TestMainHelpFullReferenceNullableDefaults(t *testing.T) { flag, wantDefault string }{ {[]string{"beta:assistants", "create"}, "--description TEXT", ""}, - {[]string{"images", "generate"}, "--output-format TEXT", "(default: png)"}, + {[]string{"images", "generate"}, "--output-format TEXT", "Default: png"}, } { t.Run(strings.Join(tc.path, "/"), func(t *testing.T) { - got := runMainDispatch(t, "bash", append([]string{"openai", "help", "--all"}, tc.path...)...) + got := runMainDispatch(t, "bash", append([]string{"openai", "help"}, tc.path...)...) if got.code != 0 || got.stderr != "" { t.Fatalf("full reference failed: %+v", got) } @@ -231,7 +240,7 @@ func TestMainHelpFullReferenceNullableDefaults(t *testing.T) { details, _, _ = strings.Cut(details, "\n --") details = strings.Join(strings.Fields(details), " ") if tc.wantDefault == "" { - if strings.Contains(details, "(default:") { + if strings.Contains(details, "Default:") { t.Errorf("unset nullable flag advertises a default: %s%s", tc.flag, details) } } else if !strings.Contains(details, tc.wantDefault) { @@ -251,8 +260,8 @@ func TestMainHelpFullReferenceDoesNotPrintCredentials(t *testing.T) { {"openai", "--api-key", "fake-argument-api-key", "--admin-api-key", "fake-argument-admin-key", "--webhook-secret", "fake-argument-webhook-secret"}, } { for _, route := range [][]string{ - {"help", "--all"}, {"help", "--all", "images", "generate"}, - {"images", "help", "--all", "generate"}, + {"help"}, {"help", "images", "generate"}, + {"images", "help", "generate"}, } { args := append(slices.Clone(prefix), route...) got := runMainDispatchWithEnv(t, "bash", env, args...) @@ -283,7 +292,7 @@ func TestMainHelpDoesNotAdvertiseUnshippedFeatures(t *testing.T) { } } } - imageHelp := runMainDispatch(t, "bash", "openai", "help", "--all", "images", "generate") + imageHelp := runMainDispatch(t, "bash", "openai", "help", "images", "generate") for _, text := range []string{"--model", "--prompt", "JSON", "~/Downloads/gpt-images/", "--output-dir", "--name", "--count", "gpt-image-2.5-sunburst"} { if !strings.Contains(imageHelp.stdout, text) { t.Errorf("image help does not explain the existing command's %q: %s", text, imageHelp.stdout) @@ -295,10 +304,10 @@ func TestMainHelpIgnoresBrokenRequestConfiguration(t *testing.T) { env := []string{"OPENAI_BASE_URL=not-a-request-url", "OPENAI_MTLS_CLIENT_CERT_FILE=/nonexistent/cert.pem", "OPENAI_MTLS_CLIENT_KEY_FILE=/nonexistent/key.pem"} for _, args := range [][]string{ {"openai"}, {"openai", "help"}, {"openai", "--help"}, {"openai", "-h"}, - {"openai", "help", "setup"}, {"openai", "help", "--all"}, + {"openai", "help", "setup"}, {"openai", "help"}, {"openai", "images", "generate", "--help"}, {"openai", "images", "generate", "--prompt", "help", "--help"}, - {"openai", "--debug", "help", "--all", "images", "generate"}, + {"openai", "--debug", "help", "images", "generate"}, } { t.Run(strings.Join(args, "/"), func(t *testing.T) { got := runMainDispatchWithEnv(t, "bash", env, args...) @@ -409,7 +418,7 @@ func TestMainHelpDoesNotBypassRequestValidation(t *testing.T) { func TestMainHelpRejectsUnknownTopics(t *testing.T) { for _, args := range [][]string{ {"openai", "help", "imaginary"}, {"openai", "help", "images", "imaginary"}, - {"openai", "images", "help", "imaginary"}, {"openai", "help", "--all", "images", "imaginary"}, + {"openai", "images", "help", "imaginary"}, {"openai", "help", "images", "imaginary"}, {"openai", "help", "setup", "unexpected"}, {"openai", "images", "help", "--", "--help"}, {"openai", "images", "help", "--", "--all"}, diff --git a/cmd/openai/main_image_models_test.go b/cmd/openai/main_image_models_test.go index 93b93aa4..25a6d10d 100644 --- a/cmd/openai/main_image_models_test.go +++ b/cmd/openai/main_image_models_test.go @@ -48,7 +48,7 @@ func TestMainImageModelsHelpAndOfflineMakeNoRequests(t *testing.T) { } assertMainImageModelsRows(t, report, args[len(args)-1] == "--all", imagemodels.StatusNotChecked) } else { - for _, want := range []string{"openai images models", "--offline", "--all", "exact model names", "API key setup: openai help setup", "--model gpt-image-2.5-flare"} { + for _, want := range []string{"openai images models", "--offline", "--all", "exact model names", "Key setup: openai help setup", "--model gpt-image-2.5-flare"} { if !strings.Contains(got.stdout, want) { t.Errorf("help missing %q: %s", want, got.stdout) } diff --git a/cmd/openai/main_readable_errors_test.go b/cmd/openai/main_readable_errors_test.go index 11dcb2d3..c11b1dfa 100644 --- a/cmd/openai/main_readable_errors_test.go +++ b/cmd/openai/main_readable_errors_test.go @@ -48,11 +48,11 @@ func TestMainReadableAPIArgumentGuidance(t *testing.T) { args, want []string }{ {"encoding choice", "encoding_format", "invalid_value", 400, - []string{"embeddings", "create", "--model", "model-synthetic", "--input", "synthetic"}, []string{"--encoding-format", "float or base64", "help --all embeddings create"}}, + []string{"embeddings", "create", "--model", "model-synthetic", "--input", "synthetic"}, []string{"--encoding-format", "float or base64", "help embeddings create"}}, {"nested indexed field", "messages[0].content", "invalid_type", 422, - []string{"chat:completions", "create", "--model", "model-synthetic", "--message", `{"role":"user","content":"synthetic"}`}, []string{"--message", "Check the value's type", "help --all chat:completions create"}}, + []string{"chat:completions", "create", "--model", "model-synthetic", "--message", `{"role":"user","content":"synthetic"}`}, []string{"--message", "Check the value's type", "help chat:completions create"}}, {"nested command flag", "session.model", "invalid_value", 400, - []string{"live:sessions", "accept", "--session-id", "sess_synthetic", "--session", `{"type":"live","model":"synthetic"}`}, []string{"--session.model", "help --all live:sessions accept"}}, + []string{"live:sessions", "accept", "--session-id", "sess_synthetic", "--session", `{"type":"live","model":"synthetic"}`}, []string{"--session.model", "help live:sessions accept"}}, {"required value", "input", "missing_required_parameter", 400, []string{"embeddings", "create", "--model", "model-synthetic", "--input", "synthetic"}, []string{"Add --input with a value"}}, {"unsupported field", "temperature", "unsupported_parameter", 400, @@ -60,7 +60,7 @@ func TestMainReadableAPIArgumentGuidance(t *testing.T) { {"model context", "input", "context_length_exceeded", 400, []string{"responses", "create", "--model", "model-synthetic", "--input", "synthetic"}, []string{"Shorten the input or conversation"}}, {"unknown sensitive param", "input\x1b]52;c;synthetic-private-secret\a\u202e", "synthetic-private-code", 400, - []string{"embeddings", "create", "--model", "model-synthetic", "--input", "synthetic"}, []string{"The API rejected the request", "help --all embeddings create"}}, + []string{"embeddings", "create", "--model", "model-synthetic", "--input", "synthetic"}, []string{"The API rejected the request", "help embeddings create"}}, {"wrong command flag", "temperature", "invalid_value", 400, []string{"embeddings", "create", "--model", "model-synthetic", "--input", "synthetic"}, []string{"The API rejected the request"}}, } { diff --git a/cmd/openai/main_shell_test.go b/cmd/openai/main_shell_test.go index a1809da2..3e2e01d5 100644 --- a/cmd/openai/main_shell_test.go +++ b/cmd/openai/main_shell_test.go @@ -103,7 +103,7 @@ func TestMainNativeShell(t *testing.T) { t.Run("installed help uses command name", func(t *testing.T) { t.Setenv("PATH", work+string(os.PathListSeparator)+os.Getenv("PATH")) directory := t.TempDir() - for _, topic := range []string{"", "--help", "images --help", "images generate --help", "help --all images generate", "help setup"} { + for _, topic := range []string{"", "--help", "images --help", "images generate --help", "help images generate", "help setup"} { got := runNativeShell(t, shell, directory, home, "not-a-url", invocation+" "+topic) if got.code != 0 || got.stderr != "" || strings.Contains(got.stdout, work) || !strings.Contains(got.stdout, "openai ") { t.Fatalf("installed %s includes a path or fails: %+v", topic, got) @@ -111,18 +111,18 @@ func TestMainNativeShell(t *testing.T) { } got := runNativeShell(t, shell, directory, home, "not-a-url", invocation+" images generate --help") for line := range strings.SplitSeq(got.stdout, "\n") { - if command, found := strings.CutPrefix(strings.TrimSpace(line), "Full help: "); found { - if command != "openai help --all images generate" { - t.Fatalf("installed full-help link = %q", command) + if command, found := strings.CutPrefix(strings.TrimSpace(line), "Key setup: "); found { + if command != "openai help setup" { + t.Fatalf("installed setup-help link = %q", command) } copied := runNativeShell(t, shell, directory, home, "not-a-url", command) - if copied.code != 0 || copied.stderr != "" || !strings.Contains(copied.stdout, "--output-compression") { + if copied.code != 0 || copied.stderr != "" || !strings.Contains(copied.stdout, "OPENAI_API_KEY") { t.Fatalf("copied installed help failed: %+v", copied) } return } } - t.Fatalf("no full-help link: %+v", got) + t.Fatalf("no setup-help link: %+v", got) }) t.Run("copy local help with implicit lookup enabled", func(t *testing.T) { t.Setenv("GODEBUG", os.Getenv("GODEBUG")+",execerrdot=0") @@ -136,16 +136,16 @@ func TestMainNativeShell(t *testing.T) { short := runNativeShell(t, shell, work, home, "not-a-url", invocation+" images generate --help") var command string for line := range strings.SplitSeq(short.stdout, "\n") { - if after, found := strings.CutPrefix(strings.TrimSpace(line), "Full help: "); found { + if after, found := strings.CutPrefix(strings.TrimSpace(line), "Key setup: "); found { command = after break } } - if short.code != 0 || short.stderr != "" || command != shell.binary+" help --all images generate" { + if short.code != 0 || short.stderr != "" || command != shell.binary+" help setup" { t.Fatalf("local help with PATH=%q must retain its relative path: %+v", searchPath, short) } copied := runNativeShell(t, shell, work, home, "not-a-url", command) - if copied.code != 0 || copied.stderr != "" || !strings.Contains(copied.stdout, "--output-compression") { + if copied.code != 0 || copied.stderr != "" || !strings.Contains(copied.stdout, "OPENAI_API_KEY") { t.Fatalf("copied local help with PATH=%q failed: %+v", searchPath, copied) } } @@ -154,16 +154,16 @@ func TestMainNativeShell(t *testing.T) { for _, command := range []string{"", "images generate "} { t.Run(command+flag, func(t *testing.T) { got := runNativeShell(t, shell, work, home, "not-a-url", shell.binary+" "+command+flag) - if got.code != 0 || got.stderr != "" || !strings.Contains(got.stdout, "help --all") { + if got.code != 0 || got.stderr != "" || !strings.Contains(got.stdout, "GLOBAL OPTIONS:") { t.Fatalf("help failed: %+v", got) } }) } } for _, tc := range []struct{ args, want string }{ - {"help --all images generate", "--output-compression"}, - {"help --all admin organization audit-logs list", "--event-type"}, - {"help --all admin:organization:audit-logs list", "--event-type"}, + {"help images generate", "--output-compression"}, + {"help admin organization audit-logs list", "--event-type"}, + {"help admin:organization:audit-logs list", "--event-type"}, {"help setup", "OPENAI_API_KEY"}, } { t.Run(tc.args, func(t *testing.T) { @@ -192,23 +192,23 @@ func TestMainNativeShell(t *testing.T) { short := runNativeShell(t, shell, directory, home, "not-a-url", invocation+" images generate --help") var command string for line := range strings.SplitSeq(short.stdout, "\n") { - if after, ok := strings.CutPrefix(strings.TrimSpace(line), "Full help: "); ok { + if after, ok := strings.CutPrefix(strings.TrimSpace(line), "Key setup: "); ok { command = after break } } if short.code != 0 || command == "" { - t.Fatalf("short help lacks a copyable full-help command: %+v", short) + t.Fatalf("help lacks a copyable setup command: %+v", short) } got := runNativeShell(t, shell, directory, home, "not-a-url", command) - if got.code != 0 || got.stderr != "" || !strings.Contains(got.stdout, "--output-compression") { + if got.code != 0 || got.stderr != "" || !strings.Contains(got.stdout, "OPENAI_API_KEY") { t.Fatalf("displayed command %q failed: %+v", command, got) } }) for _, full := range []bool{false, true} { - exampleName, helpArgs, prompt := "copy brief image example", " images generate --help", "A tiny orange robot" + exampleName, helpArgs, prompt := "copy flag-help image example", " images generate --help", "A tiny cat" if full { - exampleName, helpArgs, prompt = "copy full image example", " help --all images generate", "A tiny cat" + exampleName, helpArgs, prompt = "copy command-help image example", " help images generate", "A tiny cat" } if elsewhere { exampleName += " from another directory" @@ -320,7 +320,7 @@ func TestMainNativeShell(t *testing.T) { } output, err := os.ReadFile(filepath.Join(work, "help-output.txt")) // Windows PowerShell 5.1 writes redirected text as UTF-16LE. - if err != nil || (!bytes.Contains(output, []byte("OpenAI CLI")) && !bytes.Contains(output, []byte("O\x00p\x00e\x00n\x00A\x00I\x00 \x00C\x00L\x00I\x00"))) { + if err != nil || (!bytes.Contains(output, []byte("GLOBAL OPTIONS:")) && !bytes.Contains(output, []byte("G\x00L\x00O\x00B\x00A\x00L\x00 \x00O\x00P\x00T\x00I\x00O\x00N\x00S\x00:\x00"))) { t.Fatalf("redirected help is missing: %v", err) } }) diff --git a/cmd/openai/main_suggest_test.go b/cmd/openai/main_suggest_test.go index b7168d60..0e84dc15 100644 --- a/cmd/openai/main_suggest_test.go +++ b/cmd/openai/main_suggest_test.go @@ -16,8 +16,8 @@ func TestMainUnknownCommandSuggestions(t *testing.T) { {[]string{"fine-tuning:alpha:graders", "rnu"}, "Unknown help topic. Did you mean 'openai fine-tuning:alpha:graders run'?\n"}, {[]string{"responses", "creat"}, "Unknown help topic. Did you mean 'openai responses create'?\n"}, {[]string{"RESPONSES"}, "Unknown help topic. Did you mean 'openai responses'?\n"}, - {[]string{"totallybogus"}, "Unknown help topic. Run openai help --all to see commands.\n"}, - {[]string{"responses", "zzzzz"}, "Unknown help topic. Run openai help --all responses to see commands.\n"}, + {[]string{"totallybogus"}, "Unknown help topic. Run openai help to see commands.\n"}, + {[]string{"responses", "zzzzz"}, "Unknown help topic. Run openai help responses to see commands.\n"}, } { for _, flags := range [][]string{nil, {"--format-error", "json"}, {"--format", "json"}} { t.Run(strings.Join(append(append([]string{}, tc.args...), flags...), "/"), func(t *testing.T) { diff --git a/cmd/openai/normal_help_examples_test.go b/cmd/openai/normal_help_examples_test.go index 5e818ae7..d5723957 100644 --- a/cmd/openai/normal_help_examples_test.go +++ b/cmd/openai/normal_help_examples_test.go @@ -25,7 +25,7 @@ func TestMainNormalHelpOutputOptions(t *testing.T) { if got.code != 0 || got.stderr != "" { t.Fatalf("normal help failed: %+v", got) } - for _, want := range []string{"\nOUTPUT\n", "--format ", "--transform PATH", "--raw-output"} { + for _, want := range []string{"\n Output\n", "--format ", "--transform PATH", "--raw-output"} { if !strings.Contains(got.stdout, want) { t.Errorf("normal help lacks %q:\n%s", want, got.stdout) } @@ -34,22 +34,28 @@ func TestMainNormalHelpOutputOptions(t *testing.T) { } for _, args := range [][]string{{"images", "inline"}, {"images", "inline", "on"}, {"images", "inline", "off"}} { got := runMainDispatch(t, "bash", append(append([]string{"openai"}, args...), "--help")...) - if got.code != 0 || got.stderr != "" || strings.Contains(got.stdout, "\nOUTPUT\n") { - t.Fatalf("local image help advertises API output options: %+v", got) + if got.code != 0 || got.stderr != "" { + t.Fatalf("local image help failed: %+v", got) + } + text := strings.Join(strings.Fields(got.stdout), " ") + for _, want := range []string{"This local command supports --format auto or text only.", "It cannot use --transform or --raw-output."} { + if !strings.Contains(text, want) { + t.Errorf("local help omitted output restriction %q", want) + } } } } func TestMainNormalHelpModelsLimit(t *testing.T) { server, requests := normalHelpModelsServer(t) - for _, args := range [][]string{{"models", "list", "--help"}, {"help", "--all", "models", "list"}} { + for _, args := range [][]string{{"models", "list", "--help"}, {"help", "models", "list"}} { got := runMainDispatch(t, "bash", append([]string{"openai"}, args...)...) text := strings.Join(strings.Fields(got.stdout), " ") if got.code != 0 || got.stderr != "" || !strings.Contains(text, "--max-items INTEGER") || !strings.Contains(text, "omit or use -1 for unlimited, or use 0 for no items") { t.Fatalf("help does not explain the limit contract: %+v", got) } - if args[0] == "help" && !strings.Contains(text, "(default: unlimited)") { + if args[0] == "help" && !strings.Contains(text, "Default: unlimited") { t.Fatalf("full help displays the wrong omitted default: %s", text) } } @@ -123,8 +129,13 @@ func TestMainNormalHelpNativeModelExamples(t *testing.T) { t.Fatalf("normal help failed or made a request: %+v", help) } var examples []string - for line := range strings.SplitSeq(help.stdout, "\n") { - if strings.HasPrefix(line, " "+printedInvocation+" models list") { + _, exampleSection, found := strings.Cut(help.stdout, "EXAMPLES:\n") + if !found { + t.Fatal("model help omitted examples") + } + exampleSection, _, _ = strings.Cut(exampleSection, "\nOPTIONS:") + for line := range strings.SplitSeq(exampleSection, "\n") { + if strings.HasPrefix(strings.TrimSpace(line), printedInvocation+" models list") { examples = append(examples, strings.TrimSpace(line)) } } diff --git a/cmd/openai/run_help_test.go b/cmd/openai/run_help_test.go index cf85e55e..d791f7c9 100644 --- a/cmd/openai/run_help_test.go +++ b/cmd/openai/run_help_test.go @@ -29,10 +29,10 @@ func TestMainHelpScriptsRunUsesCopyableInvocation(t *testing.T) { args []string want []string }{ - {[]string{"images", "preview", "--help"}, []string{` go run ./cmd/openai images preview "photo.png"`, "Full help: go run ./cmd/openai help --all images preview"}}, - {[]string{"help", "--all", "images", "generate"}, []string{` go run ./cmd/openai images generate --prompt "A tiny cat" --name cat`}}, - {[]string{"help", "--all", "images", "edit"}, []string{` go run ./cmd/openai images edit --image "photo.png" --prompt "Make the sky purple" --name purple-sky`}}, - {[]string{"help", "--all", "images", "create-variation"}, []string{"retired and no longer available", ` go run ./cmd/openai images edit --image "photo.png" --prompt "Create a variation of this image" --name variation`}}, + {[]string{"images", "preview", "--help"}, []string{`go run ./cmd/openai images preview "photo.png"`, "Key setup: go run ./cmd/openai help setup"}}, + {[]string{"help", "images", "generate"}, []string{` go run ./cmd/openai images generate --prompt "A tiny cat" --name cat`}}, + {[]string{"help", "images", "edit"}, []string{` go run ./cmd/openai images edit --image "photo.png" --prompt "Make the sky purple" --name purple-sky`}}, + {[]string{"help", "images", "create-variation"}, []string{"retired and no longer available", ` go run ./cmd/openai images edit --image "photo.png" --prompt "Create a variation of this image" --name variation`}}, } { child := exec.CommandContext(ctx, "./scripts/run", tc.args...) child.Dir = root diff --git a/docs/image-generation-saving.md b/docs/image-generation-saving.md index 669088ae..22c1238b 100644 --- a/docs/image-generation-saving.md +++ b/docs/image-generation-saving.md @@ -97,7 +97,7 @@ Streamed edits save only the final image. `--partial-images 1`, `2` or `3` enables streaming and displays progress previews where supported. Explicit `--format json` preserves the original responses or edit events and makes no saved files. Model-discovery default markers remain separate work. -Use `openai help --all images edit` for every request setting. +Use `openai images edit --help` for every request setting. ## Inline previews diff --git a/internal/autocomplete/autocomplete.go b/internal/autocomplete/autocomplete.go index ccc8c6fc..8944f77b 100644 --- a/internal/autocomplete/autocomplete.go +++ b/internal/autocomplete/autocomplete.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "slices" + "strconv" "strings" "github.com/openai/openai-cli/internal/clihelp" @@ -281,8 +282,24 @@ func getAllPossibleCompletions(completionStyle CompletionStyle, root *cli.Comman continue } if isFlag(arg) && !literal { - name, _, assigned := strings.Cut(arg, "=") + name, value, assigned := strings.Cut(arg, "=") flag := findFlag(flags, name) + // Accept the typed legacy help flag without offering it in discovery. + if flag == nil && helpTopics && (name == "--all" || name == "-all") { + for _, declared := range help.Flags { + legacy, ok := declared.(*cli.BoolFlag) + if !ok || legacy.Name != "all" || !slices.Contains(flags, declared) { + continue + } + if assigned { + if _, err := strconv.ParseBool(value); err != nil { + break + } + } + flag = &declared + break + } + } if flag == nil { return CompletionResult{Behavior: ShellCompletionBehaviorNoComplete} } else if docFlag, ok := (*flag).(cli.DocGenerationFlag); ok && docFlag.TakesValue() && !assigned { diff --git a/internal/autocomplete/completion_experience_test.go b/internal/autocomplete/completion_experience_test.go index a2f17290..42071745 100644 --- a/internal/autocomplete/completion_experience_test.go +++ b/internal/autocomplete/completion_experience_test.go @@ -30,7 +30,7 @@ func TestCompletionHelpTopicsFollowCommandTree(t *testing.T) { {[]string{"help", "--all", "responses", "input-items", ""}, []string{"list"}}, {[]string{"responses", "help", "input-items", ""}, []string{"list"}}, {[]string{"help", "legacy:responses", ""}, []string{"create"}}, - {[]string{"help", "responses", "--a"}, []string{"--all"}}, + {[]string{"help", "responses", "--a"}, nil}, } { t.Run(strings.Join(tc.args, "/"), func(t *testing.T) { got := GetCompletions(CompletionStyleZsh, root, tc.args) diff --git a/internal/autocomplete/legacy_help_test.go b/internal/autocomplete/legacy_help_test.go new file mode 100644 index 00000000..411d70a1 --- /dev/null +++ b/internal/autocomplete/legacy_help_test.go @@ -0,0 +1,51 @@ +package autocomplete + +import ( + "testing" + + "github.com/openai/openai-cli/internal/clihelp" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" +) + +func TestTypedLegacyHelpFlagCompletion(t *testing.T) { + root := &cli.Command{Name: "openai", Commands: []*cli.Command{ + {Name: "responses", Commands: []*cli.Command{{Name: "create"}}}, + {Name: "models", Flags: []cli.Flag{&cli.BoolFlag{Name: "all"}}}, + }} + _, _, err := clihelp.Configure(root, []string{"openai"}) + require.NoError(t, err) + help := root.Command("help") + help.Flags = append(help.Flags, &cli.StringFlag{Name: "topic"}, &cli.BoolFlag{Name: "private", Hidden: true}) + for _, flag := range []string{"--all", "-all"} { + for _, value := range []string{"", "=true", "=false", "=1", "=0", "=TRUE", "=False"} { + got := GetCompletions(CompletionStyleBash, root, []string{"help", flag + value, "responses", "cr"}) + require.Equal(t, ShellCompletionBehaviorDefault, got.Behavior, flag+value) + require.Equal(t, []string{"create"}, completionNames(got.Completions), flag+value) + } + } + for _, args := range [][]string{ + {"help", "--all=invalid", "responses", "cr"}, + {"help", "--all=", "responses", "cr"}, + {"help", "-all=invalid", "responses", "cr"}, + {"help", "---all", "responses", "cr"}, + {"help", "--private", "responses", "cr"}, + {"help", "--", "--all", "responses", "cr"}, + {"--all", "responses", "cr"}, + } { + got := GetCompletions(CompletionStyleBash, root, args) + require.EqualValues(t, ShellCompletionBehaviorNoComplete, got.Behavior, "%q", args) + require.Empty(t, got.Completions, "%q", args) + } + got := GetCompletions(CompletionStyleBash, root, []string{"help", "--topic", "--all", "responses", "cr"}) + require.Equal(t, []string{"create"}, completionNames(got.Completions)) + for _, args := range [][]string{{"help", "--a"}, {"help", "--all="}} { + require.Empty(t, GetCompletions(CompletionStyleBash, root, args).Completions) + } + require.Contains(t, completionNames(GetCompletions(CompletionStyleBash, root, []string{"models", "--a"}).Completions), "--all") + + // A visible declaration with the same name retains normal value consumption. + help.Flags = append(help.Flags, &cli.StringFlag{Name: "all"}) + got = GetCompletions(CompletionStyleBash, root, []string{"help", "--all", "ignored", "responses", "cr"}) + require.Equal(t, []string{"create"}, completionNames(got.Completions)) +} diff --git a/internal/clihelp/brief.go b/internal/clihelp/brief.go deleted file mode 100644 index cdcd6a99..00000000 --- a/internal/clihelp/brief.go +++ /dev/null @@ -1,216 +0,0 @@ -package clihelp - -import ( - "fmt" - "strings" - - "github.com/urfave/cli/v3" -) - -// Help is derived from the command definitions so regenerated flags remain -// available. Only the presentation changes; flag objects are never rewritten. -func configureCommandHelp(command *cli.Command, invocation, path string) { - configureCommandHelpAtWidth(command, invocation, path, helpWidth(command)) -} - -func configureCommandHelpAtWidth(command *cli.Command, invocation, path string, width int) { - if path != "" && command.CustomHelpTemplate == "" && allowsHelpTopic(command) { - if command.Metadata == nil { - command.Metadata = map[string]any{} - } - command.Metadata["brief-help"] = briefHelpAtWidth(command, invocation, path, width) - command.CustomHelpTemplate = `{{index .Metadata "brief-help"}}` - } - for _, child := range command.Commands { - configureCommandHelpAtWidth(child, invocation, strings.TrimSpace(path+" "+child.Name), width) - } -} - -func briefHelp(command *cli.Command, invocation, path string) string { - return briefHelpAtWidth(command, invocation, path, helpWidth(command)) -} - -func briefHelpAtWidth(command *cli.Command, invocation, path string, width int) string { - var out strings.Builder - fmt.Fprintf(&out, "%s %s\n", invocation, path) - if command.Usage != "" { - out.WriteString(wrapDescription(shortDescription(command.Usage), "", width)) - } - if commands := examples[path]; len(commands) > 0 { - heading := "EXAMPLE" - if len(commands) > 1 { - heading = "EXAMPLES" - } - fmt.Fprintf(&out, "\n%s\n", heading) - for _, example := range commands { - fmt.Fprintf(&out, " %s %s\n", invocation, example) - } - if path == "files create" || path == "files upload" { - out.WriteString(wrapDescription("example.txt is the path to your existing file. Replace it with your file's path.", "", width)) - } - } - children := VisibleCommands(command) - if len(children) > 0 { - out.WriteByte('\n') - out.WriteString(commandList(command, width)) - fmt.Fprintf(&out, "\nCommand help: %s %s COMMAND --help\n", invocation, path) - } else { - var required, optional []cli.Flag - for _, flag := range command.Flags { - if visible, ok := flag.(cli.VisibleFlag); ok && !visible.IsVisible() { - continue - } - if isRequired(flag) { - required = append(required, flag) - } else { - optional = append(optional, flag) - } - } - if len(required) > 0 { - out.WriteString("\nREQUIRED INPUTS\n") - for _, flag := range required { - writeBriefFlag(&out, flag, width) - } - } - if len(optional) > 0 { - out.WriteString("\nOPTIONAL INPUTS") - if len(optional) > 3 { - out.WriteString(" (more in full help)") - } - out.WriteByte('\n') - // Put common choices first without changing the parser's flag order. - shown := make(map[int]bool) - priority := []string{"model", "input", "size", "quality", "limit"} - if path == "responses create" || path == "responses retrieve" { - priority = []string{"model", "input", "stream"} - } - for _, name := range priority { - for i, flag := range optional { - if flag.Names()[0] == name && len(shown) < 3 { - writeBriefFlag(&out, flag, width) - shown[i] = true - } - } - } - for i, flag := range optional { - if len(shown) == 3 { - break - } - if !shown[i] { - writeBriefFlag(&out, flag, width) - shown[i] = true - } - } - } - writeBriefOutputFlags(&out, command, width) - } - fmt.Fprintf(&out, "\nFull help: %s help --all %s\n", invocation, path) - fmt.Fprintf(&out, "Key setup: %s help setup\n", invocation) - return out.String() -} - -var examples = map[string][]string{ - "images": {`images generate --prompt "A tiny orange robot"`}, - "models list": { - "models list", - "models list --format json", - "models list --transform id --raw-output", - }, - "models retrieve": {"models retrieve --model gpt-5.5"}, - "responses create": {`responses create --model gpt-5.5 --input "Say hello"`}, - "files create": {`files create --file ./example.txt --purpose assistants`}, - "files upload": {`files upload --file ./example.txt --purpose assistants`}, - "images generate": {`images generate --prompt "A tiny orange robot"`}, - "images inline": {"images inline on"}, - "images inline on": {"images inline on"}, - "images inline off": {"images inline off"}, -} - -func isRequired(flag cli.Flag) bool { - if required, ok := flag.(interface{ IsRequiredAsFlagOrStdin() bool }); ok { - return required.IsRequiredAsFlagOrStdin() - } - required, ok := flag.(cli.RequiredFlag) - return ok && required.IsRequired() -} - -func writeBriefFlag(out *strings.Builder, flag cli.Flag, width int) { - name := flag.Names()[0] - if len(name) == 1 { - name = "-" + name - } else { - name = "--" + name - } - var usage string - if doc, ok := flag.(cli.DocGenerationFlag); ok { - usage = shortDescription(fileInputUsage(flag, doc.GetUsage())) - if label := flagValueLabel(flag); label != "" { - name += " " + label - } - } - // These generated descriptions are absent or describe an SDK object. - // Explain the CLI input here without changing its API or flag definition. - switch flag.Names()[0] { - case "model": - usage = "Model ID to use for this request." - case "input": - if usage == "" { - usage = "Text or other input to send to the model." - } - case "stream": - if flagValueLabel(flag) == "BOOLEAN" { - usage = "Stream results as they arrive when true." - } - } - writeHelpEntry(out, name, usage, width) -} - -func writeBriefOutputFlags(out *strings.Builder, command *cli.Command, width int) { - flags, _ := command.Metadata["brief-output-flags"].([]cli.Flag) - shown := make(map[string]bool) - for _, flag := range flags { - if visible, ok := flag.(cli.VisibleFlag); ok && !visible.IsVisible() { - continue - } - persistent, ok := flag.(cli.LocalFlag) - if !ok || persistent.IsLocal() || len(flag.Names()) == 0 { - continue - } - name := flag.Names()[0] - if shown[name] { - continue - } - var usage string - switch name { - case "format": - usage = "auto: readable text; json: complete JSON; jsonl: one JSON value per line." - case "transform": - usage = "Select a JSON field, such as id." - if command.Name == "list" { - usage += " With json, select from each item." - } - case "raw-output": - usage = "Print selected strings without JSON quotes." - default: - continue - } - if len(shown) == 0 { - out.WriteString("\nOUTPUT\n") - } - shown[name] = true - label := "--" + name - if value := flagValueLabel(flag); value != "" { - label += " " + value - } - writeHelpEntry(out, label, usage, width) - } -} - -func shortDescription(text string) string { - text = strings.Join(strings.Fields(strings.ReplaceAll(text, "\\n", " ")), " ") - text = strings.ReplaceAll(text, "`", "") - if end := strings.Index(text, ". "); end >= 0 { - text = text[:end+1] - } - return text -} diff --git a/internal/clihelp/brief_test.go b/internal/clihelp/brief_test.go deleted file mode 100644 index 47d5109b..00000000 --- a/internal/clihelp/brief_test.go +++ /dev/null @@ -1,306 +0,0 @@ -package clihelp - -import ( - "bytes" - "context" - "fmt" - "os" - "path/filepath" - "reflect" - "slices" - "strings" - "testing" - - "github.com/openai/openai-cli/internal/requestflag" - "github.com/urfave/cli/v3" -) - -func TestBriefHelpPreservesCommandAndFlagDefinitions(t *testing.T) { - model := &requestflag.Flag[string]{ - Name: "model", Aliases: []string{"m"}, Required: true, BodyPath: "model", - Usage: "Choose the model. This additional documentation must survive full help.", - } - quality := &requestflag.Flag[string]{Name: "quality", Default: "auto", BodyPath: "quality", Usage: "Choose quality."} - modelBefore, qualityBefore := *model, *quality - command := &cli.Command{Name: "generate", Usage: "Generate an image. Full description.", Flags: []cli.Flag{model, quality}} - flagsBefore, usageBefore := slices.Clone(command.Flags), command.Usage - root := &cli.Command{Name: "openai", Commands: []*cli.Command{{Name: "images", Commands: []*cli.Command{command}}}} - args := []string{"./openai", "images", "generate", "--model", "fake-model", "--quality", "high"} - got, help, err := Configure(root, args) - if err != nil || help || !slices.Equal(got, args) { - t.Fatalf("normal command changed: %q, help=%v, err=%v", got, help, err) - } - if !reflect.DeepEqual(*model, modelBefore) || !reflect.DeepEqual(*quality, qualityBefore) { - t.Error("help changed a flag's aliases, requirement, default, documentation, or request mapping") - } - if !slices.Equal(command.Flags, flagsBefore) || command.Usage != usageBefore { - t.Error("help changed the command's flag order or full description") - } - if command.CustomHelpTemplate == "" { - t.Error("command did not receive compact help") - } -} - -func TestBriefHelpIncludesEveryRequiredInput(t *testing.T) { - command := &cli.Command{Name: "create", Flags: []cli.Flag{ - &requestflag.Flag[string]{Name: "body-input", Required: true, BodyPath: "input", Usage: "Required JSON input."}, - &requestflag.Flag[string]{Name: "path-input", Required: true, PathParam: "id", Usage: "Required path input."}, - &cli.StringFlag{Name: "ordinary-input", Required: true, Usage: "Required CLI input."}, - &requestflag.Flag[string]{Name: "hidden-input", Hidden: true, Required: true}, - &requestflag.Flag[string]{Name: "constant", Const: true, Required: true, Default: "fixed"}, - &cli.StringFlag{Name: "optional-one"}, - &cli.StringFlag{Name: "optional-two"}, - &cli.StringFlag{Name: "optional-three"}, - }} - body := command.Flags[0].(*requestflag.Flag[string]) - if body.IsRequired() || !body.IsRequiredAsFlagOrStdin() { - t.Fatal("fixture must exercise input that may be supplied through JSON stdin") - } - got := briefHelp(command, "openai", "example create") - _, inputs, found := strings.Cut(got, "REQUIRED INPUTS") - if !found { - t.Fatalf("required inputs are missing: %s", got) - } - required, _, _ := strings.Cut(inputs, "OPTIONAL INPUTS") - for _, name := range []string{"body-input", "path-input", "ordinary-input"} { - if !strings.Contains(required, "--"+name+" TEXT") { - t.Errorf("required help lacks --%s: %s", name, got) - } - } - if strings.Contains(got, "hidden-input") || strings.Contains(required, "--constant") { - t.Errorf("hidden or constant flag was incorrectly presented as required: %s", got) - } - if strings.Contains(got, "--optional-three") { - t.Error("compact help should leave excess optional inputs in full help") - } - if !strings.Contains(got, "openai help --all example create") { - t.Error("compact help must link to the remaining options") - } -} - -func TestBriefHelpGroupsAreCompleteAndHideInternalCommands(t *testing.T) { - command := &cli.Command{Name: "group", Commands: []*cli.Command{{Name: "internal-only", Hidden: true}}} - for i := range 12 { - command.Commands = append(command.Commands, &cli.Command{Name: fmt.Sprintf("command-%02d", i), Usage: "A short description. Extra details."}) - } - got := briefHelp(command, "./openai", "group") - for _, name := range []string{"command-00", "command-09", "command-10", "command-11", "./openai group COMMAND --help", "./openai help --all group"} { - if !strings.Contains(got, name) { - t.Errorf("group help lacks %q: %s", name, got) - } - } - for _, name := range []string{"internal-only", "+ 2 more", "Extra details"} { - if strings.Contains(got, name) { - t.Errorf("group help should not include %q", name) - } - } -} - -func TestBriefHelpKeepsExplicitFeatureTemplates(t *testing.T) { - feature := &cli.Command{Name: "special", CustomHelpTemplate: "Existing feature help\n"} - hidden := &cli.Command{Name: "internal-only", Hidden: true} - root := &cli.Command{Name: "openai", Commands: []*cli.Command{feature, hidden}} - configureCommandHelp(root, "openai", "") - if feature.CustomHelpTemplate != "Existing feature help\n" || hidden.CustomHelpTemplate != "" { - t.Error("generic help replaced an explicit template or exposed a hidden command") - } -} - -func TestBriefImageExampleUsesMinimalInput(t *testing.T) { - got := briefHelp(&cli.Command{Name: "generate"}, "./openai", "images generate") - for _, text := range []string{`./openai images generate --prompt "A tiny orange robot"`, "Full help:"} { - if !strings.Contains(got, text) { - t.Errorf("image help lacks %q: %s", text, got) - } - } - for _, text := range []string{"JSON containing image data", "--model gpt-image-1.5"} { - if strings.Contains(got, text) { - t.Errorf("image help advertises an unshipped behavior: %q", text) - } - } -} - -func TestBriefModelsHelpShowsOutputExamplesAndTypedLimit(t *testing.T) { - command := &cli.Command{Name: "list", Flags: []cli.Flag{ - &requestflag.Flag[int64]{Name: "max-items", Usage: "Maximum items; use -1 for unlimited."}, - }, Metadata: map[string]any{"brief-output-flags": []cli.Flag{ - &cli.StringFlag{Name: "format", Usage: "The full reference contains many format details.", Value: "auto"}, - &cli.StringFlag{Name: "transform", Usage: "The full reference contains path exceptions."}, - &cli.BoolFlag{Name: "raw-output"}, - }}} - for _, width := range []int{40, 80} { - got := briefHelpAtWidth(command, "'/tmp/CLI build/openai'", "models list", width) - for _, want := range []string{ - "EXAMPLES\n", - " '/tmp/CLI build/openai' models list\n", - " '/tmp/CLI build/openai' models list --format json\n", - " '/tmp/CLI build/openai' models list --transform id --raw-output\n", - "--max-items INTEGER", "\nOUTPUT\n", "--format FORMAT", "--transform PATH", "--raw-output", - } { - if !strings.Contains(got, want) { - t.Errorf("width %d lacks %q:\n%s", width, want, got) - } - } - if strings.Contains(got, "more in full help") || strings.Contains(got, "The full reference") { - t.Errorf("width %d repeats reference details or advertises nonexistent optional inputs:\n%s", width, got) - } - } -} - -func TestBriefResponsesHelpPrioritizesInputModelAndStream(t *testing.T) { - command := &cli.Command{Name: "create", Flags: []cli.Flag{ - &requestflag.Flag[any]{Name: "access-programs", Usage: "Other configuration."}, - &requestflag.Flag[*bool]{Name: "background", Usage: "Run in the background."}, - &requestflag.Flag[any]{Name: "input"}, - &requestflag.Flag[string]{Name: "model"}, - &requestflag.Flag[*bool]{Name: "stream", Usage: "Stream the response."}, - }} - got := briefHelp(command, "openai", "responses create") - for _, want := range []string{"--model MODEL", "--input VALUE", "--stream BOOLEAN", "more in full help"} { - if !strings.Contains(got, want) { - t.Errorf("responses help lacks %q:\n%s", want, got) - } - } - for _, excluded := range []string{"--access-programs", "--background"} { - if strings.Contains(got, excluded) { - t.Errorf("responses help prioritized %q:\n%s", excluded, got) - } - } -} - -func TestBriefOutputFlagsRequireVisiblePersistentMetadata(t *testing.T) { - format := &cli.StringFlag{Name: "format", Value: "auto", Usage: "Original format documentation."} - before := *format - for _, tc := range []struct { - name string - flags []cli.Flag - want bool - }{ - {"absent", nil, false}, - {"visible", []cli.Flag{format, format}, true}, - {"hidden", []cli.Flag{&cli.StringFlag{Name: "format", Hidden: true}}, false}, - {"local", []cli.Flag{&cli.StringFlag{Name: "format", Local: true}}, false}, - {"unrecognized", []cli.Flag{&cli.StringFlag{Name: "unrelated"}}, false}, - } { - t.Run(tc.name, func(t *testing.T) { - command := &cli.Command{Name: "operation", Metadata: map[string]any{"brief-output-flags": tc.flags}} - got := briefHelp(command, "openai", "example operation") - if strings.Contains(got, "\nOUTPUT\n") != tc.want { - t.Fatalf("output section visibility is incorrect:\n%s", got) - } - if tc.want && strings.Count(got, "--format FORMAT") != 1 { - t.Fatalf("output flag is missing or duplicated:\n%s", got) - } - }) - } - if !reflect.DeepEqual(before, *format) { - t.Fatal("brief output help changed the root flag definition") - } -} - -func TestGoRunInvocationIsCopyableFromSourceCheckout(t *testing.T) { - project := t.TempDir() - t.Chdir(project) - cacheKey := strings.Repeat("a1", 32) - var paths []string - for _, executable := range []string{"openai", "openai.exe"} { - paths = append(paths, - filepath.Join(os.TempDir(), "go-build12345", "b001", "exe", executable), - filepath.Join(os.TempDir(), "custom cache", "a1", cacheKey+"-d", executable), - ) - } - for _, path := range paths { - if got := Invocation("openai", []string{path}); got != "openai" { - t.Errorf("go-run path outside a checkout %q became %q", path, got) - } - } - if err := os.MkdirAll(filepath.Join("cmd", "openai"), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join("cmd", "openai", "main.go"), []byte("package main\n"), 0o600); err != nil { - t.Fatal(err) - } - for _, path := range paths { - if got := Invocation("openai", []string{path}); got != "go run ./cmd/openai" { - t.Errorf("go-run path inside a checkout %q became %q", path, got) - } - } - for _, path := range []string{ - "./openai", - filepath.Join("go-build-tools", "openai"), - filepath.Join("cache", "a1", "not-a-digest-d", "openai"), - filepath.Join("cache", "a2", cacheKey+"-d", "openai"), - filepath.Join("cache", "z1", strings.Repeat("z1", 32)+"-d", "openai"), - } { - if got := Invocation("openai", []string{path}); got != path { - t.Errorf("installed/local executable %q changed to %q", path, got) - } - } -} - -func TestGroupHelpRendererIsScopedToConfiguredRoot(t *testing.T) { - for _, configured := range []bool{true, false} { - t.Run(fmt.Sprint(configured), func(t *testing.T) { - var output bytes.Buffer - group := &cli.Command{ - Name: "group", CustomHelpTemplate: "SCOPED GROUP TEMPLATE\n", - Commands: []*cli.Command{{Name: "child", Usage: "Existing child command"}}, - } - root := &cli.Command{Name: "openai", Writer: &output, Commands: []*cli.Command{group}} - args := []string{"openai", "group", "--help"} - if configured { - var err error - args, _, err = Configure(root, args) - if err != nil { - t.Fatal(err) - } - } - if err := root.Run(t.Context(), args); err != nil { - t.Fatal(err) - } - if got := output.String(); configured && got != "SCOPED GROUP TEMPLATE\n" { - t.Errorf("configured root lost its group template: %q", got) - } else if !configured && (strings.Contains(got, "SCOPED GROUP TEMPLATE") || !strings.Contains(got, "child")) { - t.Errorf("unconfigured root's native group renderer changed: %q", got) - } - }) - } -} - -func TestRepeatedConfigureDoesNotDuplicateCommandsOrRequestSetup(t *testing.T) { - var setups, actions int - root := &cli.Command{ - Name: "openai", - Before: func(ctx context.Context, _ *cli.Command) (context.Context, error) { - setups++ - return ctx, nil - }, - Commands: []*cli.Command{{Name: "run", Action: func(context.Context, *cli.Command) error { - actions++ - return nil - }}}, - } - args := []string{"openai", "run"} - for range 3 { - var err error - args, _, err = Configure(root, args) - if err != nil { - t.Fatal(err) - } - } - var helpCommands int - for _, command := range root.Commands { - if command.Name == "help" { - helpCommands++ - } - } - if helpCommands != 1 { - t.Fatalf("Configure added %d help commands", helpCommands) - } - if err := root.Run(t.Context(), args); err != nil { - t.Fatal(err) - } - if setups != 1 || actions != 1 { - t.Errorf("normal execution ran request setup %d times and action %d times", setups, actions) - } -} diff --git a/internal/clihelp/command_help.go b/internal/clihelp/command_help.go new file mode 100644 index 00000000..46c62a68 --- /dev/null +++ b/internal/clihelp/command_help.go @@ -0,0 +1,153 @@ +package clihelp + +import ( + "fmt" + "strings" + + "github.com/urfave/cli/v3" +) + +// Content supplies complete handwritten guidance without changing command behavior. +// Command examples omit the executable; the renderer supplies the safe invocation. +type Content struct { + Description string + Examples []Example +} + +type Example struct { + Description string + Command string +} + +const commandHelpTemplate = `{{call (index .Metadata "complete-help")}}` + +// Configure callbacks now, but enumerate flags only when the framework renders help. +// Parent links, inherited flags, and automatic help flags do not exist here yet. +func configureCommandHelp(command *cli.Command, invocation, path string) { + if command.Metadata == nil { + command.Metadata = map[string]any{} + } + command.Metadata["help-path"] = path + command.Metadata["complete-help"] = func() string { + return commandHelpAtWidth(command, invocation, path, helpWidth(command)) + } + if full, _ := command.Metadata["local-help-full"].(string); full != "" { + command.CustomHelpTemplate = full + } else if command.CustomHelpTemplate == "" && allowsHelpTopic(command) { + command.CustomHelpTemplate = commandHelpTemplate + } + for _, child := range command.Commands { + configureCommandHelp(child, invocation, strings.TrimSpace(path+" "+child.Name)) + } +} + +func commandHelpAtWidth(command *cli.Command, invocation, path string, width int) string { + var out strings.Builder + name := strings.TrimSpace(invocation + " " + path) + fmt.Fprintf(&out, "NAME:\n %s\n", name) + usage := command.Usage + if path == "" { + usage = "Use the OpenAI API from your terminal." + } + out.WriteString(wrapDescription(usage, " ", width)) + fmt.Fprintf(&out, "\nSYNOPSIS:\n %s\n", commandSynopsis(command, name, invocation)) + + content, supplied := command.Metadata["help-content"].(Content) + if !supplied { + content = Content{Description: command.Description, Examples: examples[path]} + } + if content.Description != "" { + out.WriteString("\nDESCRIPTION:\n") + out.WriteString(wrapDescription(content.Description, " ", width)) + } + if path == "" { + fmt.Fprintf(&out, "\nSTART HERE\n %s help setup\n %s models list\n", invocation, invocation) + } + if len(content.Examples) > 0 { + out.WriteString("\nEXAMPLES:\n") + for i, example := range content.Examples { + if i > 0 { + out.WriteByte('\n') + } + out.WriteString(wrapDescription(example.Description, " ", width)) + fmt.Fprintf(&out, " %s %s\n", invocation, example.Command) + } + if path == "files create" || path == "files upload" { + out.WriteString(wrapDescription("example.txt is the path to your existing file. Replace it with your file's path.", " ", width)) + } + } + if len(VisibleCommands(command)) > 0 { + out.WriteByte('\n') + out.WriteString(commandList(command, width)) + fmt.Fprintf(&out, "\nCommand help: %s COMMAND --help\n", name) + } + if flags := command.VisibleFlags(); len(flags) > 0 { + heading := "OPTIONS:" + if path == "" { + heading = "GLOBAL OPTIONS:" + } + out.WriteString("\n" + heading) + out.WriteString(fullFlagGroups(command, flags, width)) + } + if flags := command.VisiblePersistentFlags(); len(flags) > 0 { + out.WriteString("\nGLOBAL OPTIONS:") + out.WriteString(fullFlagGroups(command, flags, width)) + } + if path == "" { + out.WriteString("\nAdd --help (or -h) to any command. Help needs no API key or internet.\n") + } + fmt.Fprintf(&out, "\nKey setup: %s help setup\n", invocation) + return out.String() +} + +func commandSynopsis(command *cli.Command, name, invocation string) string { + if command.UsageText != "" { + // Existing handwritten usage uses the public command name. Replace only + // that leading executable, never filenames or arbitrary description text. + return strings.Replace(command.UsageText, "openai ", invocation+" ", 1) + } + // Required request fields may also arrive through stdin. The required-input + // reference describes those fields without making flags the only syntax. + name += " [OPTIONS]" + if len(VisibleCommands(command)) > 0 { + name += " COMMAND" + } + if command.ArgsUsage != "" { + name += " " + command.ArgsUsage + } + return name +} + +var examples = map[string][]Example{ + "images": {{"Generate an image:", `images generate --prompt "A tiny orange robot"`}}, + "models list": { + {"List available models:", "models list"}, + {"Return complete JSON:", "models list --format json"}, + {"Print model IDs for a script:", "models list --transform id --raw-output"}, + }, + "models retrieve": {{"Inspect a model:", "models retrieve --model gpt-5.5"}}, + "responses create": {{"Create a text response:", `responses create --model gpt-5.5 --input "Say hello"`}}, + "files create": {{"Upload an existing file:", `files create --file ./example.txt --purpose assistants`}}, + "files upload": {{"Upload an existing file:", `files upload --file ./example.txt --purpose assistants`}}, + "images generate": {{"Generate an image:", `images generate --prompt "A tiny orange robot"`}}, + "images inline": {{"Turn automatic image previews on:", "images inline on"}}, + "images inline on": {{"Turn automatic image previews on:", "images inline on"}}, + "images inline off": {{"Turn automatic image previews off:", "images inline off"}}, +} + +func isRequired(flag cli.Flag) bool { + if required, ok := flag.(interface{ IsRequiredAsFlagOrStdin() bool }); ok { + return required.IsRequiredAsFlagOrStdin() + } + required, ok := flag.(cli.RequiredFlag) + return ok && required.IsRequired() +} + +func shortDescription(text string) string { + text = strings.Join(strings.Fields(strings.ReplaceAll(text, "\\n", " ")), " ") + text = strings.ReplaceAll(text, "`", "") + if end := strings.Index(text, ". "); end >= 0 { + text = text[:end+1] + } + return text +} diff --git a/internal/clihelp/command_help_test.go b/internal/clihelp/command_help_test.go new file mode 100644 index 00000000..068a8b85 --- /dev/null +++ b/internal/clihelp/command_help_test.go @@ -0,0 +1,220 @@ +package clihelp + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "reflect" + "slices" + "strings" + "testing" + + "github.com/openai/openai-cli/internal/requestflag" + "github.com/urfave/cli/v3" +) + +func TestCompleteHelpPreservesCommandAndFlagDefinitions(t *testing.T) { + model := &requestflag.Flag[string]{Name: "model", Aliases: []string{"m"}, Required: true, BodyPath: "model", Usage: "Choose the model. Keep this complete constraint."} + quality := &requestflag.Flag[string]{Name: "quality", Default: "auto", BodyPath: "quality", Usage: "Choose quality."} + modelBefore, qualityBefore := *model, *quality + command := &cli.Command{Name: "generate", Usage: "Generate an image.", Flags: []cli.Flag{model, quality}} + flagsBefore := slices.Clone(command.Flags) + root := &cli.Command{Name: "openai", Commands: []*cli.Command{{Name: "images", Commands: []*cli.Command{command}}}} + args := []string{"./openai", "images", "generate", "--model", "fake-model", "--quality", "high"} + got, help, err := Configure(root, args) + if err != nil || help || !slices.Equal(got, args) { + t.Fatalf("normal command changed: %q, %v, %v", got, help, err) + } + if !reflect.DeepEqual(*model, modelBefore) || !reflect.DeepEqual(*quality, qualityBefore) || !slices.Equal(command.Flags, flagsBefore) { + t.Fatal("help changed command definitions") + } + if command.CustomHelpTemplate == "" { + t.Fatal("missing complete help") + } +} + +func TestCompleteHelpIncludesEveryInput(t *testing.T) { + command := &cli.Command{Name: "create", Flags: []cli.Flag{ + &requestflag.Flag[string]{Name: "body-input", Required: true, BodyPath: "input", Usage: "Required JSON input. Keep the full constraint."}, + &cli.StringFlag{Name: "ordinary-input", Required: true, Usage: "Required CLI input."}, + &cli.StringFlag{Name: "hidden-input", Hidden: true}, + }} + for i := range 5 { + command.Flags = append(command.Flags, &cli.StringFlag{Name: fmt.Sprintf("optional-%d", i), Usage: "Future options remain visible."}) + } + got := commandHelpAtWidth(command, "openai", "example create", 100) + for _, want := range []string{"Required inputs", "--body-input TEXT", "--ordinary-input TEXT", "--optional-4 TEXT", "Keep the full constraint."} { + if !strings.Contains(got, want) { + t.Errorf("missing %q: %s", want, got) + } + } + for _, absent := range []string{"hidden-input", "Full help:", "more in full help"} { + if strings.Contains(got, absent) { + t.Errorf("unexpected %q", absent) + } + } +} + +func TestCompleteHelpGroupsAreCompleteAndHideInternalCommands(t *testing.T) { + command := &cli.Command{Name: "group", Commands: []*cli.Command{{Name: "internal-only", Hidden: true}}} + for i := range 12 { + command.Commands = append(command.Commands, &cli.Command{Name: fmt.Sprintf("command-%02d", i), Usage: "A short description. Extra details."}) + } + got := commandHelpAtWidth(command, "./openai", "group", 80) + for _, want := range []string{"command-00", "command-09", "command-10", "command-11", "./openai group COMMAND --help"} { + if !strings.Contains(got, want) { + t.Errorf("missing %q", want) + } + } + for _, absent := range []string{"internal-only", "+ 2 more", "Extra details"} { + if strings.Contains(got, absent) { + t.Errorf("unexpected %q", absent) + } + } +} + +func TestCompleteHelpKeepsExplicitFeatureTemplates(t *testing.T) { + feature := &cli.Command{Name: "special", CustomHelpTemplate: "Existing feature help\n"} + hidden := &cli.Command{Name: "internal-only", Hidden: true} + root := &cli.Command{Name: "openai", Commands: []*cli.Command{feature, hidden}} + configureCommandHelp(root, "openai", "") + if feature.CustomHelpTemplate != "Existing feature help\n" || hidden.CustomHelpTemplate != "" { + t.Fatal("generic help replaced an explicit template or exposed a hidden command") + } +} + +func TestCompleteHelpExplainsOutputExamples(t *testing.T) { + command := &cli.Command{Name: "list", Flags: []cli.Flag{&requestflag.Flag[int64]{Name: "max-items", Usage: "Maximum items; use -1 for unlimited."}}} + for _, width := range []int{40, 80} { + got := commandHelpAtWidth(command, "'/tmp/CLI build/openai'", "models list", width) + for _, want := range []string{"EXAMPLES:", "Return complete JSON:", "Print model IDs for a script:", "'/tmp/CLI build/openai' models list --format json", "'/tmp/CLI build/openai' models list --transform id --raw-output", "--max-items INTEGER"} { + if !strings.Contains(got, want) { + t.Errorf("width %d missing %q: %s", width, want, got) + } + } + } +} + +func TestCompleteHelpContentOverridesExamplesAndDescription(t *testing.T) { + command := &cli.Command{Name: "generate", Description: "Superseded description.", Metadata: map[string]any{"help-content": Content{ + Description: "Keep the feature's complete caveat.", Examples: []Example{{"Create a synthetic example:", `images generate --prompt "two words"`}}, + }}} + got := commandHelpAtWidth(command, "'/tmp/my cli/openai'", "images generate", 40) + for _, want := range []string{"Keep the feature's complete caveat.", `'/tmp/my cli/openai' images generate --prompt "two words"`} { + if !strings.Contains(got, want) { + t.Errorf("missing %q", want) + } + } + if strings.Contains(got, "Superseded") || strings.Contains(got, "tiny orange robot") { + t.Fatal("default content duplicated explicit feature guidance") + } +} + +func TestGoRunInvocationIsCopyableFromSourceCheckout(t *testing.T) { + project := t.TempDir() + t.Chdir(project) + cacheKey := strings.Repeat("a1", 32) + var paths []string + for _, executable := range []string{"openai", "openai.exe"} { + paths = append(paths, + filepath.Join(os.TempDir(), "go-build12345", "b001", "exe", executable), + filepath.Join(os.TempDir(), "custom cache", "a1", cacheKey+"-d", executable), + ) + } + for _, path := range paths { + if got := Invocation("openai", []string{path}); got != "openai" { + t.Errorf("go-run path outside a checkout %q became %q", path, got) + } + } + if err := os.MkdirAll(filepath.Join("cmd", "openai"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join("cmd", "openai", "main.go"), []byte("package main\n"), 0o600); err != nil { + t.Fatal(err) + } + for _, path := range paths { + if got := Invocation("openai", []string{path}); got != "go run ./cmd/openai" { + t.Errorf("go-run path inside a checkout %q became %q", path, got) + } + } + for _, path := range []string{ + "./openai", + filepath.Join("go-build-tools", "openai"), + filepath.Join("cache", "a1", "not-a-digest-d", "openai"), + filepath.Join("cache", "a2", cacheKey+"-d", "openai"), + filepath.Join("cache", "z1", strings.Repeat("z1", 32)+"-d", "openai"), + } { + if got := Invocation("openai", []string{path}); got != path { + t.Errorf("installed/local executable %q changed to %q", path, got) + } + } +} + +func TestGroupHelpRendererIsScopedToConfiguredRoot(t *testing.T) { + for _, configured := range []bool{true, false} { + t.Run(fmt.Sprint(configured), func(t *testing.T) { + var output bytes.Buffer + group := &cli.Command{ + Name: "group", CustomHelpTemplate: "SCOPED GROUP TEMPLATE\n", + Commands: []*cli.Command{{Name: "child", Usage: "Existing child command"}}, + } + root := &cli.Command{Name: "openai", Writer: &output, Commands: []*cli.Command{group}} + args := []string{"openai", "group", "--help"} + if configured { + var err error + args, _, err = Configure(root, args) + if err != nil { + t.Fatal(err) + } + } + if err := root.Run(t.Context(), args); err != nil { + t.Fatal(err) + } + if got := output.String(); configured && got != "SCOPED GROUP TEMPLATE\n" { + t.Errorf("configured root lost its group template: %q", got) + } else if !configured && (strings.Contains(got, "SCOPED GROUP TEMPLATE") || !strings.Contains(got, "child")) { + t.Errorf("unconfigured root's native group renderer changed: %q", got) + } + }) + } +} + +func TestRepeatedConfigureDoesNotDuplicateCommandsOrRequestSetup(t *testing.T) { + var setups, actions int + root := &cli.Command{ + Name: "openai", + Before: func(ctx context.Context, _ *cli.Command) (context.Context, error) { + setups++ + return ctx, nil + }, + Commands: []*cli.Command{{Name: "run", Action: func(context.Context, *cli.Command) error { + actions++ + return nil + }}}, + } + args := []string{"openai", "run"} + for range 3 { + var err error + args, _, err = Configure(root, args) + if err != nil { + t.Fatal(err) + } + } + var helpCommands int + for _, command := range root.Commands { + if command.Name == "help" { + helpCommands++ + } + } + if helpCommands != 1 { + t.Fatalf("Configure added %d help commands", helpCommands) + } + if err := root.Run(t.Context(), args); err != nil { + t.Fatal(err) + } + if setups != 1 || actions != 1 { + t.Errorf("normal execution ran request setup %d times and action %d times", setups, actions) + } +} diff --git a/internal/clihelp/command_navigation_test.go b/internal/clihelp/command_navigation_test.go index 7e10af1d..01bdaefc 100644 --- a/internal/clihelp/command_navigation_test.go +++ b/internal/clihelp/command_navigation_test.go @@ -131,7 +131,7 @@ func TestMixedHelpSeparatesActionsAndCommandGroups(t *testing.T) { {Name: "create", Action: func(context.Context, *cli.Command) error { return nil }}, {Name: "retrieve", Action: func(context.Context, *cli.Command) error { return nil }}, }} - got := briefHelp(group, "openai", "admin organization projects") + got := commandHelpAtWidth(group, "openai", "admin organization projects", 80) for _, want := range []string{"ACTIONS", "COMMAND GROUPS", "create", "retrieve", "users"} { if !strings.Contains(got, want) { t.Errorf("mixed help lacks %q: %s", want, got) @@ -145,7 +145,7 @@ func TestMixedHelpSeparatesActionsAndCommandGroups(t *testing.T) { func TestBriefHelpUsesFileInputMetadata(t *testing.T) { for _, name := range []string{"file", "image", "mask"} { flag := &requestflag.Flag[string]{Name: name, Usage: "The audio file object (not file name) to transcribe.", Required: true, FileInput: true} - got := briefHelp(&cli.Command{Name: "create", Flags: []cli.Flag{flag}}, "openai", "audio transcriptions create") + got := commandHelpAtWidth(&cli.Command{Name: "create", Flags: []cli.Flag{flag}}, "openai", "audio transcriptions create", 80) if !strings.Contains(got, "Path to the audio file to transcribe.") || strings.Contains(got, "file object") { t.Errorf("file flag %s still describes an SDK input: %s", name, got) } diff --git a/internal/clihelp/complete_routing_test.go b/internal/clihelp/complete_routing_test.go new file mode 100644 index 00000000..07d3167b --- /dev/null +++ b/internal/clihelp/complete_routing_test.go @@ -0,0 +1,253 @@ +package clihelp + +import ( + "bytes" + "context" + "slices" + "strings" + "testing" + + "github.com/urfave/cli/v3" +) + +func completeHelpFixture(out *bytes.Buffer) *cli.Command { + return &cli.Command{Name: "openai", Writer: out, HideHelpCommand: true, + Before: func(ctx context.Context, _ *cli.Command) (context.Context, error) { + return ctx, cli.Exit("request setup must not run", 9) + }, + Flags: []cli.Flag{&cli.StringFlag{Name: "format", Value: "auto"}, &cli.StringFlag{Name: "root-only", Local: true}}, + Commands: []*cli.Command{{Name: "images", Commands: []*cli.Command{{Name: "generate", Usage: "Generate a synthetic image.", + Flags: []cli.Flag{&cli.StringFlag{Name: "size"}, &cli.StringFlag{Name: "prompt", Required: true}}, + Action: func(context.Context, *cli.Command) error { return cli.Exit("action must not run", 9) }, + }, {Name: "models", Flags: []cli.Flag{&cli.BoolFlag{Name: "all"}}, Action: func(context.Context, *cli.Command) error { return nil }}}}}, + } +} + +func TestCompleteHelpEntrypointsAndLegacyMigration(t *testing.T) { + var reference string + for _, args := range [][]string{ + {"openai", "images", "generate", "--help"}, + {"openai", "images", "generate", "-h"}, + {"openai", "images", "generate", "--h"}, + {"openai", "help", "images", "generate"}, + {"openai", "images", "help", "generate"}, + {"openai", "--help", "images", "generate"}, + {"openai", "images", "--help", "generate"}, + {"openai", "--help", "images", "generate", "--size", "1024x1024"}, + {"openai", "images", "generate", "--size", "1024x1024", "--help"}, + {"openai", "images", "generate", "--prompt", "--help", "--help"}, + {"openai", "--format", "help", "images", "generate", "--help"}, + {"openai", "help", "--all", "images", "generate"}, + {"openai", "help", "images", "generate", "--all=false"}, + {"openai", "images", "help", "--all", "generate"}, + {"openai", "--help", "--all", "images", "generate"}, + {"openai", "-all", "--help", "images", "generate"}, + {"openai", "-all=false", "--help", "images", "generate"}, + } { + t.Run(strings.Join(args, "/"), func(t *testing.T) { + var out bytes.Buffer + root := completeHelpFixture(&out) + normalized, _, err := Configure(root, args) + if err != nil { + t.Fatal(err) + } + if err := root.Run(t.Context(), normalized); err != nil { + t.Fatal(err) + } + got := out.String() + legacy := slices.Contains(args, "--all") || slices.Contains(args, "--all=false") || + slices.Contains(args, "-all") || slices.Contains(args, "-all=false") + notice := "Use openai help images generate; --all is no longer needed.\n" + if legacy { + if !strings.HasPrefix(got, notice) { + t.Fatalf("missing migration pointer: %s", got) + } + got = strings.TrimPrefix(got, notice) + } + if reference == "" { + reference = got + } + if got != reference { + t.Fatalf("help differs for %q:\n%s\nwant:\n%s", normalized, got, reference) + } + if strings.Contains(got, "--root-only") { + t.Fatal("root-only flag advertised on leaf") + } + }) + } +} + +func TestMalformedLegacyHelpFlagsRemainParserErrors(t *testing.T) { + for _, args := range [][]string{ + {"openai", "---all", "--help"}, + {"openai", "---all=false", "--help"}, + {"openai", "----all", "--help", "images", "generate"}, + } { + t.Run(strings.Join(args, "/"), func(t *testing.T) { + var out bytes.Buffer + root := completeHelpFixture(&out) + root.ExitErrHandler = func(context.Context, *cli.Command, error) {} + normalized, _, err := Configure(root, args) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(normalized, args) { + t.Fatalf("malformed legacy flag changed: %q => %q", args, normalized) + } + if err := root.Run(t.Context(), normalized); err == nil { + t.Fatal("malformed legacy flag bypassed the parser") + } + if strings.Contains(out.String(), "no longer needed") { + t.Fatal("malformed legacy flag emitted a migration notice") + } + }) + } +} + +func TestHelpScanLeavesLiteralValuesAndUnknownFlagsToParser(t *testing.T) { + for _, args := range [][]string{ + {"openai", "images", "generate", "--prompt", "--help"}, + {"openai", "images", "generate", "--prompt=--help"}, + {"openai", "images", "generate", "--", "--help"}, + {"openai", "images", "generate", "help"}, + {"openai", "--root-only", "help", "images", "generate"}, + {"openai", "images", "generate", "--unknown", "--help"}, + {"openai", "images", "generate", "--size"}, + {"openai", "--help", "---all", "images", "generate"}, + } { + var out bytes.Buffer + root := completeHelpFixture(&out) + got, help := normalizeHelpFlag(root, args) + if help || !slices.Equal(got, args) { + t.Fatalf("literal or invalid request changed: %q => %q, %v", args, got, help) + } + } +} + +func TestCompleteHelpDoesNotRetireCommandOwnedAll(t *testing.T) { + var out bytes.Buffer + root := completeHelpFixture(&out) + args, _, err := Configure(root, []string{"openai", "images", "models", "--all", "--help"}) + if err != nil { + t.Fatal(err) + } + if err := root.Run(t.Context(), args); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "--all") || strings.Contains(out.String(), "no longer needed") { + t.Fatalf("command-owned --all changed: %s", out.String()) + } +} + +func TestLegacyRootHelpAndSetupKeepCanonicalBody(t *testing.T) { + for _, path := range [][]string{nil, {"setup"}} { + var want string + for _, all := range []bool{false, true} { + var out bytes.Buffer + root := completeHelpFixture(&out) + args := []string{"openai", "help"} + if all { + args = append(args, "--all=false") + } + args = append(args, path...) + args, _, err := Configure(root, args) + if err != nil { + t.Fatal(err) + } + if err := root.Run(t.Context(), args); err != nil { + t.Fatal(err) + } + got := out.String() + if !all { + want = got + continue + } + line, body, found := strings.Cut(got, "\n") + if !found || !strings.Contains(line, "no longer needed") || body != want { + t.Fatalf("legacy guide differs: %s", got) + } + } + } +} + +func TestHelpFlagKeepsLeafOperandsAndBooleanAssignments(t *testing.T) { + var reference string + for _, args := range [][]string{ + {"openai", "images", "generate", "--help"}, + {"openai", "--help=true", "images", "generate"}, + {"openai", "-h=true", "images", "generate"}, + {"openai", "--h=1", "images", "generate"}, + {"openai", "--help", "images", "generate", "photo.png"}, + {"openai", "images", "generate", "photo.png", "--help"}, + {"openai", "images", "generate", "photo.png", "--size", "1024x1024", "--help"}, + {"openai", "images", "generate", "--help", "--", "photo.png"}, + {"openai", "images", "generate", "--help", "--", "--help=false"}, + } { + t.Run(strings.Join(args, "/"), func(t *testing.T) { + var out bytes.Buffer + root := completeHelpFixture(&out) + normalized, _, err := Configure(root, args) + if err != nil { + t.Fatal(err) + } + if slices.Contains(args, "photo.png") && !slices.Contains(normalized, "photo.png") { + t.Fatal("help removed the positional operand") + } + if err := root.Run(t.Context(), normalized); err != nil { + t.Fatal(err) + } + if reference == "" { + reference = out.String() + } + if out.String() != reference { + t.Fatalf("help differs for %q: %s", args, out.String()) + } + }) + } +} + +func TestHelpFlagFalseAndLiteralAssignmentsRemainData(t *testing.T) { + for _, args := range [][]string{ + {"openai", "images", "generate", "--help=false"}, + {"openai", "--help", "--help=false", "images", "generate"}, + {"openai", "images", "generate", "-h=0"}, + {"openai", "images", "generate", "--help=invalid"}, + {"openai", "images", "generate", "--prompt", "--help=true"}, + {"openai", "images", "generate", "--prompt=--help=true"}, + {"openai", "images", "generate", "photo.png", "--", "--help=true"}, + } { + var out bytes.Buffer + root := completeHelpFixture(&out) + got, help := normalizeHelpFlag(root, args) + if help || !slices.Equal(got, args) { + t.Fatalf("false or literal help changed: %q => %q", args, got) + } + } +} + +func TestRepeatedHelpConfigurationClearsLegacyNotice(t *testing.T) { + var out bytes.Buffer + root := completeHelpFixture(&out) + for _, args := range [][]string{ + {"openai", "help", "--all", "images", "generate"}, + {"openai", "help", "images", "generate"}, + {"openai", "images", "generate", "--help"}, + {"openai", "help", "-all", "images", "generate"}, + {"openai", "help", "images", "generate"}, + {"openai", "help", "--all=false", "images", "generate"}, + {"openai", "images", "generate", "--help"}, + } { + out.Reset() + normalized, _, err := Configure(root, args) + if err != nil { + t.Fatal(err) + } + if err := root.Run(t.Context(), normalized); err != nil { + t.Fatal(err) + } + legacy := slices.Contains(args, "--all") || slices.Contains(args, "-all") || slices.Contains(args, "--all=false") + if got := strings.Contains(out.String(), "no longer needed"); got != legacy { + t.Fatalf("migration intent leaked between invocations %q: %s", args, out.String()) + } + } +} diff --git a/internal/clihelp/file_input_help_test.go b/internal/clihelp/file_input_help_test.go index a7b515ed..77c7bae1 100644 --- a/internal/clihelp/file_input_help_test.go +++ b/internal/clihelp/file_input_help_test.go @@ -30,7 +30,7 @@ func TestFileInputHelpPreservesPurpose(t *testing.T) { t.Fatal(err) } before := flag.String() - brief := briefHelpAtWidth(&cli.Command{Name: "create", Flags: []cli.Flag{flag}}, "openai", "test create", 200) + brief := commandHelpAtWidth(&cli.Command{Name: "create", Flags: []cli.Flag{flag}}, "openai", "test create", 200) full := fullFlag(flag) wantBrief, wantFull := tc.brief, tc.full if !fileInput { diff --git a/internal/clihelp/flag_labels.go b/internal/clihelp/flag_labels.go index c66a1281..bd67d98b 100644 --- a/internal/clihelp/flag_labels.go +++ b/internal/clihelp/flag_labels.go @@ -1,11 +1,34 @@ package clihelp import ( + "slices" "strings" "github.com/urfave/cli/v3" ) +// FlagLabel supplies a semantic value label only for flags declared by Owner. +type FlagLabel struct { + Owner *cli.Command + Names []string + Label string +} + +func commandFlagValueLabel(command *cli.Command, flag cli.Flag) string { + labels, _ := command.Metadata["help-flag-labels"].([]FlagLabel) + for _, label := range labels { + if label.Owner == nil || !slices.Contains(label.Owner.Flags, flag) { + continue + } + for _, name := range label.Names { + if slices.Contains(flag.Names(), name) { + return label.Label + } + } + } + return flagValueLabel(flag) +} + // Labels describe accepted input without reading configured values or defaults. func flagValueLabel(flag cli.Flag) string { doc, ok := flag.(cli.DocGenerationFlag) diff --git a/internal/clihelp/flag_metadata_test.go b/internal/clihelp/flag_metadata_test.go new file mode 100644 index 00000000..17463592 --- /dev/null +++ b/internal/clihelp/flag_metadata_test.go @@ -0,0 +1,73 @@ +package clihelp + +import ( + "strings" + "testing" + + "github.com/openai/openai-cli/internal/requestflag" + "github.com/urfave/cli/v3" +) + +func TestFlagReferencePreservesDeclaredDefaultsAfterParsing(t *testing.T) { + for _, tc := range []struct { + name, supplied, expected string + flag cli.Flag + }{ + {"native string", "changed", `Default: "auto"`, &cli.StringFlag{Name: "mode", Value: "auto", Usage: "Choose a mode."}}, + {"native boolean", "false", "Default: true", &cli.BoolFlag{Name: "enabled", Value: true, Usage: "Enable the feature."}}, + {"request field", "changed", "Default: original", &requestflag.Flag[string]{Name: "field", Default: "original", Usage: "Request field."}}, + {"required as stdin", "changed", "Default: original", &requestflag.Flag[string]{Name: "field", Default: "original", Required: true, BodyPath: "field", Usage: "Required field or stdin."}}, + {"required native", "changed", "", &cli.StringFlag{Name: "field", Required: true, Value: "original", Usage: "Required flag."}}, + {"unset nullable", "true", "", &requestflag.Flag[*bool]{Name: "enabled", Usage: "Nullable Boolean field."}}, + {"hidden default", "changed", "", &cli.StringFlag{Name: "secret", Value: "private-default", HideDefault: true, Usage: "Secret setting."}}, + } { + t.Run(tc.name, func(t *testing.T) { + if err := tc.flag.PreParse(); err != nil { + t.Fatal(err) + } + if err := tc.flag.Set(tc.flag.Names()[0], tc.supplied); err != nil { + t.Fatal(err) + } + got := fullFlagGroups(&cli.Command{}, []cli.Flag{tc.flag}, 80) + if strings.Contains(got, "changed") || strings.Contains(got, "private-default") { + t.Fatalf("configured value appeared in help: %s", got) + } + if tc.expected == "" { + if strings.Contains(got, "Default:") { + t.Fatalf("unexpected default: %s", got) + } + } else if !strings.Contains(got, "\n "+tc.expected+"\n") { + t.Fatalf("missing declared default %q: %s", tc.expected, got) + } + }) + } +} + +func TestFlagReferenceKeepsMetadataLinesSeparate(t *testing.T) { + flag := &cli.StringFlag{Name: "mode", Value: "auto", Sources: cli.EnvVars("OPENAI_TEST_MODE"), Usage: "First paragraph.\nEnv: OPENAI_TEST_EXTRA\nThe following prose is independent."} + got := fullFlagGroups(&cli.Command{}, []cli.Flag{flag}, 80) + for _, want := range []string{ + "\n Env: OPENAI_TEST_EXTRA\n The following prose is independent.\n", + "\n Default: \"auto\"\n Env: OPENAI_TEST_MODE\n", + } { + if !strings.Contains(got, want) { + t.Errorf("metadata boundary missing %q: %s", want, got) + } + } +} + +func TestFlagReferenceLabelsRequireDeclaredOwnership(t *testing.T) { + rootKey := &cli.StringFlag{Name: "api-key", HideDefault: true} + localKey := &cli.StringFlag{Name: "api-key", HideDefault: true} + root := &cli.Command{Flags: []cli.Flag{rootKey}} + command := &cli.Command{Metadata: map[string]any{"help-flag-labels": []FlagLabel{{Owner: root, Names: []string{"api-key"}, Label: "KEY"}}}} + for _, tc := range []struct { + flag cli.Flag + heading string + }{{rootKey, "--api-key KEY"}, {localKey, "--api-key TEXT"}} { + got := fullFlagGroups(command, []cli.Flag{tc.flag}, 80) + if !strings.Contains(got, tc.heading) { + t.Fatalf("owner-aware label missing %q: %s", tc.heading, got) + } + } +} diff --git a/internal/clihelp/full.go b/internal/clihelp/full.go index c58a113e..a960064a 100644 --- a/internal/clihelp/full.go +++ b/internal/clihelp/full.go @@ -17,26 +17,6 @@ type FlagGroup struct { Owner *cli.Command } -// Keep the framework's command reference and its complete flag descriptions. -// Only the option layout changes, using headings rather than a wide flag table. -func fullHelpTemplate(command *cli.Command, source string) string { - configureCommandList(command) - command.Metadata["full-help-flags"] = func(flags []cli.Flag) string { - return fullFlagGroups(command, flags, helpWidth(command)) - } - command.Metadata["full-help-description"] = func(text string) string { - return strings.TrimSuffix(wrapDescription(text, " ", helpWidth(command)), "\n") - } - return strings.NewReplacer( - `COMMANDS:{{template "visibleCommandCategoryTemplate" .}}`, `{{call (index .Metadata "help-command-list")}}`, - `COMMANDS:{{template "visibleCommandTemplate" .}}`, `{{call (index .Metadata "help-command-list")}}`, - `{{template "visibleFlagCategoryTemplate" .}}`, `{{call (index .Metadata "full-help-flags") .VisibleFlags}}`, - `{{template "visibleFlagTemplate" .}}`, `{{call (index .Metadata "full-help-flags") .VisibleFlags}}`, - `{{template "visiblePersistentFlagTemplate" .}}`, `{{call (index .Metadata "full-help-flags") .VisiblePersistentFlags}}`, - ` {{template "descriptionTemplate" .}}`, `{{call (index .Metadata "full-help-description") .Description}}`, - ).Replace(source) -} - func fullFlagGroups(command *cli.Command, flags []cli.Flag, width int) string { groups := []FlagGroup{{Title: "Required inputs"}} if configured, ok := command.Metadata["help-flag-groups"].([]FlagGroup); ok { @@ -77,15 +57,85 @@ func fullFlagGroups(command *cli.Command, flags []cli.Flag, width int) string { } fmt.Fprintf(&out, "\n\n %s\n", group.Title) for _, flag := range flags { - heading, description, _ := strings.Cut(fullFlag(flag), "\t") + heading, description := flagReference(command, flag) fmt.Fprintf(&out, "\n %s\n", heading) - out.WriteString(wrapDescription(description, " ", width)) + writeFlagDescription(&out, description, width) } delete(grouped, group.Title) } return out.String() } +// Use documentation interfaces for defaults and environment names. Preserve a +// custom flag's own presentation when its signature differs from standard flags. +func flagReference(command *cli.Command, flag cli.Flag) (string, string) { + heading, fallback, _ := strings.Cut(fullFlag(flag), "\t") + doc, ok := flag.(cli.DocGenerationFlag) + if !ok || heading != fullFlagNames(flag, flagValueLabel(flag)) { + return heading, fallback + } + var names []string + for _, name := range flag.Names() { + prefix := "--" + if len(name) == 1 { + prefix = "-" + } + names = append(names, prefix+name) + } + heading = strings.Join(names, ", ") + if label := commandFlagValueLabel(command, flag); label != "" { + heading += " " + label + } + usage := fileInputUsage(flag, doc.GetUsage()) + if start := strings.IndexByte(usage, '`'); start >= 0 { + if end := strings.IndexByte(usage[start+1:], '`'); end >= 0 { + usage = usage[:start] + usage[start+1:start+1+end] + usage[start+end+2:] + } + } + if multi, ok := flag.(cli.DocGenerationMultiValueFlag); ok && multi.IsMultiValueFlag() && + !strings.Contains(strings.ToLower(usage), "repeat") { + usage += "\nCan be used more than once." + } + required, _ := flag.(cli.RequiredFlag) + if doc.IsDefaultVisible() && (required == nil || !required.IsRequired()) { + value := doc.GetDefaultText() + // Request flags expose parsed values through GetValue. Their declared + // defaults come only from GetDefaultText, including unset nullable values. + _, request := flag.(interface{ IsRequiredAsFlagOrStdin() bool }) + if value == "" && !request { + value = doc.GetValue() + } + if value != "" { + usage += "\nDefault: " + value + } + } + if env := doc.GetEnvVars(); len(env) > 0 { + usage += "\nEnv: " + strings.Join(env, ", ") + } + return heading, strings.TrimSpace(usage) +} + +func writeFlagDescription(out *strings.Builder, description string, width int) { + var prose []string + flush := func() { + if len(prose) > 0 { + out.WriteString(wrapDescription(strings.Join(prose, "\n"), " ", width)) + prose = nil + } + } + for _, line := range strings.Split(description, "\n") { + // These are authored or interface-derived presentation lines. Preserve + // their boundaries without inferring values from arbitrary flag prose. + if strings.HasPrefix(line, "Default:") || strings.HasPrefix(line, "Env:") { + flush() + out.WriteString(wrapDescription(line, " ", width)) + } else { + prose = append(prose, line) + } + } + flush() +} + func fullFlag(flag cli.Flag) string { rendered := fileInputUsage(flag, flag.String()) doc, ok := flag.(cli.DocGenerationFlag) diff --git a/internal/clihelp/full_test.go b/internal/clihelp/full_test.go index a6eeb5d3..9b7fd3c7 100644 --- a/internal/clihelp/full_test.go +++ b/internal/clihelp/full_test.go @@ -146,8 +146,13 @@ func TestFullHelpGroupingKeepsEachFlagAndItsDetails(t *testing.T) { t.Errorf("section %q duplicated or missing: %s", section, got) } } - for _, flag := range flags { - heading, details, _ := strings.Cut(fullFlag(flag), "\t") + for _, expected := range []struct{ heading, details string }{ + {"--optional TEXT", "A future setting must remain visible."}, + {"--prompt TEXT", "Required text. Keep the important limit."}, + {"--image, -i TEXT", "Path to your file."}, + {"--categorized INTEGER", "Original category. Default: 12"}, + } { + heading, details := expected.heading, expected.details if strings.Count(got, heading) != 1 || !strings.Contains(strings.Join(strings.Fields(got), " "), strings.Join(strings.Fields(details), " ")) { t.Errorf("grouping lost flag %q or its details: %s", heading, got) } diff --git a/internal/clihelp/help.go b/internal/clihelp/help.go index 6c221fac..548cb19e 100644 --- a/internal/clihelp/help.go +++ b/internal/clihelp/help.go @@ -1,14 +1,16 @@ // Package clihelp owns local onboarding and help-topic routing. Feature commands -// supply their own brief and full templates through command metadata. +// supply complete guidance through command metadata. package clihelp import ( "context" "fmt" + "io" "os" "os/exec" "path/filepath" "runtime" + "strconv" "strings" "sync" "unicode" @@ -16,27 +18,6 @@ import ( "github.com/urfave/cli/v3" ) -const welcomeHelp = `{{$run := index .Root.Metadata "help-invocation"}}OpenAI CLI -Use the OpenAI API from your terminal. - -START HERE - {{$run}} help setup Set up your API key - {{$run}} models list List models available to your key - -{{call (index .Metadata "help-command-list")}} -OUTPUT - --format json Return complete JSON - --transform PATH Select a JSON field, such as id - --raw-output Print selected strings without quotes - -GET HELP - {{$run}} images --help Browse image commands - {{$run}} images generate --help Example and common inputs - {{$run}} help --all Command groups and global options - -Add --help (or -h) to any command. Help needs no API key or internet. -` - const setupHelp = `{{$run := index .Root.Metadata "help-invocation"}}Set up your API key 1. CREATE A KEY @@ -77,23 +58,23 @@ This is a guide only. No key has been entered or checked by showing this page. // Configure keeps onboarding in CLI help, never in a shell startup file or // an API command's output. Normalize help before Run so the framework still // owns parsing, parent links and rendering, while help skips request setup. -// Commands marked "local-help" only display guidance; "local-help-full" supplies -// an optional full reference template used by help --all. +// Commands marked "local-help" only display guidance. Existing authored guides +// remain intact, with "local-help-full" preferred when supplied. func Configure(root *cli.Command, args []string) ([]string, bool, error) { installSubcommandHelp() - root.CustomRootCommandHelpTemplate = welcomeHelp + root.CustomRootCommandHelpTemplate = commandHelpTemplate if root.Metadata == nil { root.Metadata = map[string]any{} } root.Metadata["help-invocation"] = Invocation(root.Name, args) - configureCommandList(root) - configureCommandHelp(root, root.Metadata["help-invocation"].(string), "") + root.Metadata["help-legacy-all"] = false + delete(root.Metadata, "help-selected-command") if root.Command("help") == nil { root.Commands = append(root.Commands, &cli.Command{ - Name: "help", Usage: "Get help: help [--all] [command...]", HideHelpCommand: true, + Name: "help", Usage: "Get help: help [command...]", HideHelpCommand: true, Metadata: map[string]any{"help-command-section": "Help", "help-command-rank": 100000, "help-topic-command": true}, - CustomHelpTemplate: welcomeHelp, - Flags: []cli.Flag{&cli.BoolFlag{Name: "all", Usage: "Show every command option", HideDefault: true}}, + CustomHelpTemplate: `{{call (index .Root.Metadata "complete-help")}}`, + Flags: []cli.Flag{&cli.BoolFlag{Name: "all", Usage: "Use help without --all", HideDefault: true, Hidden: true}}, Action: showHelpTopics, Commands: []*cli.Command{{ Name: "setup", Usage: "Learn how to enter your API key", HideHelpCommand: true, @@ -116,12 +97,25 @@ func Configure(root *cli.Command, args []string) ([]string, bool, error) { return requestSetup(ctx, command) } } + if help := root.Command("help"); help != nil && help.Metadata["help-topic-command"] == true { + for _, declared := range help.Flags { + if flag, ok := declared.(*cli.BoolFlag); ok && flag.Name == "all" { + // urfave retains IsSet between runs. Reset only our migration + // flag so another Configure call cannot retain an old notice. + *flag = cli.BoolFlag{Name: "all", Usage: "Use help without --all", HideDefault: true, Hidden: true} + } + } + } + configureCommandHelp(root, root.Metadata["help-invocation"].(string), "") if len(args) <= 1 { return []string{root.Name, "--help"}, true, nil } if args[1] == "__complete" { return args, false, nil } + if normalized, help := normalizeHelpFlag(root, args); help { + return normalized, true, nil + } current := root commandStart := -1 for i := 1; i < len(args); i++ { @@ -212,12 +206,131 @@ func rootFlag(root *cli.Command, name string) cli.Flag { return nil } +// Move authentic help switches behind the declared command path. The framework +// still parses every option and value; this scan never rewrites request values. +func normalizeHelpFlag(root *cli.Command, args []string) ([]string, bool) { + chain := []*cli.Command{root} + remove := map[int]bool{} + help, legacy := false, false + end := len(args) + for i := 1; i < len(args); i++ { + arg := args[i] + if arg == "--" { + end = i + break + } + current := chain[len(chain)-1] + spelling, value, assigned := strings.Cut(arg, "=") + if spelling == "--help" || spelling == "-h" || spelling == "--h" { + if assigned { + enabled, err := strconv.ParseBool(value) + if err != nil || !enabled { + // Preserve false values, invalid values, and mixed true/false + // flag precedence exactly as the framework handles them. + return args, false + } + } + remove[i], help = true, true + continue + } + if strings.HasPrefix(arg, "-") { + name, value, assigned := strings.Cut(strings.TrimLeft(arg, "-"), "=") + var found cli.Flag + for j := len(chain) - 1; j >= 0 && found == nil; j-- { + for _, flag := range chain[j].Flags { + if local, ok := flag.(cli.LocalFlag); j != len(chain)-1 && ok && local.IsLocal() { + continue + } + for _, alias := range flag.Names() { + if alias == name { + found = flag + } + } + } + } + if found == nil && current == root && (spelling == "--all" || spelling == "-all") { + if assigned { + if _, err := strconv.ParseBool(value); err != nil { + return args, false + } + } + remove[i], legacy = true, true + continue + } + doc, ok := found.(cli.DocGenerationFlag) + if !ok { + return args, false + } + if doc.TakesValue() && !assigned { + i++ + if i >= len(args) { + return args, false + } + } + continue + } + if arg == "help" && len(current.Commands) > 0 { + // The dedicated help action retains its established topic parsing. + return args, false + } + if arg == "" { + continue + } + next := current.Command(arg) + if next == nil { + if len(current.Commands) == 0 && current != root { + // Leaf operands are filenames or other request data. Keep them + // intact while looking for a genuine help switch later on. + continue + } + return args, false + } + chain = append(chain, next) + } + if !help { + return args, false + } + out := make([]string, 0, len(args)+1) + for i, arg := range args[:end] { + if !remove[i] { + out = append(out, arg) + } + } + out = append(out, "--help") + out = append(out, args[end:]...) + root.Metadata["help-legacy-all"] = legacy + root.Metadata["help-selected-command"] = chain[len(chain)-1] + return out, true +} + var subcommandHelpOnce sync.Once // urfave's group-help path does not consult CustomHelpTemplate. Honor it here // as its command-help path does, retaining the existing renderer otherwise. func installSubcommandHelp() { subcommandHelpOnce.Do(func() { + printer := cli.HelpPrinter + cli.HelpPrinter = func(writer io.Writer, template string, data any) { + if command, ok := data.(*cli.Command); ok { + if notice := legacyHelpNotice(command); notice != "" { + _, _ = fmt.Fprintln(writer, notice) + } + } + printer(writer, template, data) + } + commandHelp := cli.ShowCommandHelp + cli.ShowCommandHelp = func(ctx context.Context, command *cli.Command, name string) error { + target, selected := command.Root().Metadata["help-selected-command"].(*cli.Command) + if !selected || target != command { + return commandHelp(ctx, command, name) + } + // urfave interprets the first positional operand as another help + // topic. A real help flag already selected this declared command. + if command == command.Root() { + return cli.ShowRootCommandHelp(command) + } + return commandHelp(ctx, command.Lineage()[1], command.Name) + } fallback := cli.ShowSubcommandHelp cli.ShowSubcommandHelp = func(command *cli.Command) error { if _, configured := command.Root().Metadata["help-invocation"]; !configured || command.CustomHelpTemplate == "" { @@ -229,6 +342,28 @@ func installSubcommandHelp() { }) } +func legacyHelpNotice(command *cli.Command) string { + root := command.Root() + invocation, configured := root.Metadata["help-invocation"].(string) + if !configured { + return "" + } + legacy, _ := root.Metadata["help-legacy-all"].(bool) + if help := root.Command("help"); help != nil { + legacy = legacy || help.IsSet("all") + } + if !legacy { + return "" + } + var path []string + for _, item := range command.Lineage() { + if item != root && item.Name != "help" { + path = append([]string{item.Name}, path...) + } + } + return "Use " + strings.TrimSpace(invocation+" help "+strings.Join(path, " ")) + "; --all is no longer needed." +} + func showHelpTopics(ctx context.Context, command *cli.Command) error { root := command.Root() parent, target := root, root @@ -239,9 +374,6 @@ func showHelpTopics(ctx context.Context, command *cli.Command) error { } parent, target = target, next } - if command.Bool("all") { - useFullHelp(root, target) - } if target == root { return cli.ShowRootCommandHelp(root) } @@ -268,18 +400,6 @@ func allowsHelpTopic(command *cli.Command) bool { return !command.Hidden || compatibility } -func useFullHelp(root, target *cli.Command) { - if full, _ := target.Metadata["local-help-full"].(string); full != "" { - target.CustomHelpTemplate = full - } else if target == root { - root.CustomRootCommandHelpTemplate = fullHelpTemplate(root, cli.RootCommandHelpTemplate) - } else if len(target.Commands) > 0 { - target.CustomHelpTemplate = fullHelpTemplate(target, cli.SubcommandHelpTemplate) - } else { - target.CustomHelpTemplate = fullHelpTemplate(target, cli.CommandHelpTemplate) - } -} - // Invocation returns a copyable executable name for help examples without // interpreting argv as shell syntax or displaying terminal controls. func Invocation(fallback string, args []string) string { diff --git a/internal/clihelp/help_test.go b/internal/clihelp/help_test.go index 9f20df10..b04aadf1 100644 --- a/internal/clihelp/help_test.go +++ b/internal/clihelp/help_test.go @@ -19,6 +19,8 @@ func TestHelpUsesFeatureTemplatesWithoutRequestSetup(t *testing.T) { {"openai", "help", "--all", "guide", "size"}, {"openai", "guide", "help", "size", "--all"}, {"openai", "guide", "size"}, + {"openai", "guide", "size", "--help"}, + {"openai", "guide", "size", "-h"}, } { t.Run(strings.Join(args, "/"), func(t *testing.T) { var out bytes.Buffer @@ -41,9 +43,9 @@ func TestHelpUsesFeatureTemplatesWithoutRequestSetup(t *testing.T) { if err := root.Run(t.Context(), normalized); err != nil { t.Fatal(err) } - want := "Short size guide\n" + want := "Full size guide\n" if strings.Contains(strings.Join(args, " "), "--all") { - want = "Full size guide\n" + want = "Use openai help guide size; --all is no longer needed.\n" + want } if got := out.String(); got != want { t.Fatalf("help = %q; want %q", got, want) diff --git a/internal/clihelp/wrap_descriptions_test.go b/internal/clihelp/wrap_descriptions_test.go index 00a28e1a..ffa52986 100644 --- a/internal/clihelp/wrap_descriptions_test.go +++ b/internal/clihelp/wrap_descriptions_test.go @@ -13,7 +13,7 @@ func TestBriefDescriptionsWrapWithoutLosingWords(t *testing.T) { description := "Creates an edited or extended image given one or more source images and a prompt." command := &cli.Command{Name: "images", Commands: []*cli.Command{{Name: "edit", Usage: description}}} for _, width := range []int{32, 40, 80, 120} { - got := briefHelpAtWidth(command, "openai", "images", width) + got := commandHelpAtWidth(command, "openai", "images", width) _, listing, _ := strings.Cut(got, "ACTIONS\n") listing, _, _ = strings.Cut(listing, "\nCommand help:") if !strings.Contains(strings.Join(strings.Fields(listing), " "), description) || strings.Contains(listing, "...") { @@ -65,7 +65,7 @@ func TestConfigureBriefUsesTheRootWriterBeforeParentLinksExist(t *testing.T) { if _, _, err := Configure(root, []string{"openai", "images", "--help"}); err != nil { t.Fatal(err) } - if got, want := group.Metadata["brief-help"], briefHelpAtWidth(group, "openai", "images", 80); got != want { + if got, want := group.Metadata["complete-help"].(func() string)(), commandHelpAtWidth(group, "openai", "images", 80); got != want { t.Fatalf("redirected group help measured another output: %v", got) } } diff --git a/pkg/custom/help.go b/pkg/custom/help.go index 079900e0..b027fe50 100644 --- a/pkg/custom/help.go +++ b/pkg/custom/help.go @@ -1,9 +1,6 @@ package custom import ( - "slices" - "strings" - "github.com/openai/openai-cli/internal/clihelp" "github.com/openai/openai-cli/internal/requestflag" "github.com/urfave/cli/v3" @@ -14,7 +11,7 @@ func ConfigureHelp(root *cli.Command, args []string) ([]string, bool, error) { orderImageCommands(root) configureGlobalFlagDescriptions(root) configureHelpGroups(root) - configureImageHelpInvocation(root, clihelp.Invocation(root.Name, args)) + configureImageHelpContent(root) return clihelp.Configure(root, args) } @@ -52,7 +49,7 @@ func configureGlobalFlagDescriptions(root *cli.Command) { case *cli.StringFlag: switch flag.Name { case "base-url": - flag.Usage = "Send API requests to this endpoint. Env: OPENAI_BASE_URL." + flag.Usage = "Send API requests to this endpoint.\nEnv: OPENAI_BASE_URL" flag.DefaultText = "https://api.openai.com/v1" case "format": flag.Usage = "Choose how results are displayed. Format names are case-insensitive.\n" + @@ -98,29 +95,93 @@ func configureGlobalFlagDescriptions(root *cli.Command) { flag.Usage = "Add an HTTP request header as 'Name: Value'.\n" + "Repeat for multiple headers; the last value for each header name wins.\n" + "Example: -H 'X-Request-Tag: cli-test'.\n" + - "Env: OPENAI_CUSTOM_HEADERS accepts one 'Name: Value' header per line. Flags override matching environment headers." + "Env: OPENAI_CUSTOM_HEADERS\n" + + "Environment headers use one 'Name: Value' header per line. Flags override matching environment headers." } } } -func configureImageHelpInvocation(root *cli.Command, invocation string) { +func configureImageHelpContent(root *cli.Command) { images := root.Command("images") if images == nil { return } - for name, description := range map[string]string{ - "generate": imageGenerationSavingHelp, - "edit": imageEditSavingHelp, - "create-variation": imageVariationSavingHelp, + const localOutput = "This local command supports --format auto or text only.\n" + + "It cannot use --transform or --raw-output." + for name, content := range map[string]clihelp.Content{ + "generate": { + Description: "Run without flags to choose settings in an interactive terminal.\n" + + "After saving, the picker reopens with your settings. Ctrl+C exits.\n\n" + + "Saves to ~/Downloads/gpt-images/. Use --output-dir to choose an existing folder.\n" + + "For API data without saving, use --format json without --name or --output-dir.\n" + + "Size and quality choices depend on the model.", + Examples: []clihelp.Example{{Description: "Generate and save an image:", Command: `images generate --prompt "A tiny cat" --name cat`}}, + }, + "edit": { + Description: "Replace photo.png with your image's path and the prompt with your changes.\n" + + "Keeps your original. Saves to ~/Downloads/gpt-images/.\n" + + "Use --output-dir to choose an existing folder.\n" + + "For API data without saving, use --format json without --name or --output-dir.\n" + + "Size and quality choices depend on the model.", + Examples: []clihelp.Example{{Description: "Edit and save an image:", Command: `images edit --image "photo.png" --prompt "Make the sky purple" --name purple-sky`}}, + }, + "create-variation": { + Description: "This endpoint is retired and no longer available. Use images edit with a GPT Image model and a prompt.\n" + + "Replace photo.png with your image's path. Edits save to ~/Downloads/gpt-images/.\n" + + "The options below describe the legacy variations contract.", + Examples: []clihelp.Example{{Description: "Create a variation with images edit:", Command: `images edit --image "photo.png" --prompt "Create a variation of this image" --name variation`}}, + }, + "preview": { + Description: imagePreviewDetails + "\nReplace photo.png with your saved image's path.\n" + + "Pipes, CI and terminals without supported graphics or color cannot display previews.\n" + + "Preview limits: 64 MiB and 16 megapixels. Larger originals are still kept.\n\n" + localOutput, + Examples: []clihelp.Example{{Description: "View a saved image:", Command: `images preview "photo.png"`}}, + }, + "models": { + Description: "Shows exact model names and checks metadata visibility with your key.\n" + + "Checks known image models individually, without loading the full API model list.\n" + + "No images are generated. Checks stop after 15 seconds, with no retries.\n" + + "Use --offline to show known names without an API key or access check.\n\n" + + "The known list comes from this CLI's SDK; it may not include newly released models.\n" + + "Visibility checks do not guarantee image-generation permissions or quota.\n" + + "Readable output also works in scripts. Failed checks keep partial results and exit nonzero.", + Examples: []clihelp.Example{ + {Description: "Check image model visibility:", Command: "images models"}, + {Description: "Use an exact model name:", Command: `images generate --prompt "A tiny orange robot" --model gpt-image-2.5-flare`}, + {Description: "Return JSON for scripts:", Command: "--format json images models"}, + }, + }, } { if command := images.Command(name); command != nil { - // Descriptions are plain text, not templates. Start from the original - // each time so repeated help configuration cannot retain an old path. - command.Description = strings.Replace(description, "\n openai ", "\n "+invocation+" ", 1) + setCompleteHelpContent(command, content) + } + } + if inline := images.Command("inline"); inline != nil { + setCompleteHelpContent(inline, clihelp.Content{ + Description: inline.Description + "\n" + localOutput, + Examples: []clihelp.Example{{Description: "Turn on automatic image previews:", Command: "images inline on"}}, + }) + for _, name := range []string{"on", "off"} { + if command := inline.Command(name); command != nil { + setCompleteHelpContent(command, clihelp.Content{ + Description: command.Description + "\n\n" + localOutput, + Examples: []clihelp.Example{{Description: "Remember automatic image previews " + name + ":", Command: "images inline " + name}}, + }) + } } } } +// Adapt only known feature pages. The shared renderer supplies flags and the +// current executable invocation without changing runtime command descriptions. +func setCompleteHelpContent(command *cli.Command, content clihelp.Content) { + if command.Metadata == nil { + command.Metadata = map[string]any{} + } + command.Metadata["help-content"] = content + command.CustomHelpTemplate = "" +} + func configureHelpGroups(root *cli.Command) { groups := []clihelp.FlagGroup{ {Title: "Authentication", Names: []string{"api-key", "admin-api-key", "webhook-secret", "mtls-client-cert-file", "mtls-client-key-file"}, Owner: root}, @@ -128,6 +189,12 @@ func configureHelpGroups(root *cli.Command) { {Title: "Request options", Names: []string{"organization", "project", "base-url", "header"}, Owner: root}, {Title: "Troubleshooting", Names: []string{"debug"}, Owner: root}, } + labels := []clihelp.FlagLabel{ + {Owner: root, Names: []string{"api-key", "admin-api-key"}, Label: "KEY"}, + {Owner: root, Names: []string{"organization", "project"}, Label: "ID"}, + {Owner: root, Names: []string{"webhook-secret"}, Label: "SECRET"}, + {Owner: root, Names: []string{"header"}, Label: "'NAME: VALUE'"}, + } var visit func(*cli.Command, bool) visit = func(command *cli.Command, image bool) { if command.Metadata == nil { @@ -145,6 +212,7 @@ func configureHelpGroups(root *cli.Command) { }, sections...) } command.Metadata["help-flag-groups"] = sections + command.Metadata["help-flag-labels"] = labels for _, flag := range command.Flags { if limit, ok := flag.(*requestflag.Flag[int64]); ok && limit.Name == "max-items" && limit.Usage == "The maximum number of items to return (use -1 for unlimited)." { @@ -158,32 +226,4 @@ func configureHelpGroups(root *cli.Command) { } } visit(root, false) - // These API commands support every output option shown in their brief help. - for _, path := range [][]string{{"models", "list"}, {"models", "retrieve"}, {"responses", "create"}, {"responses", "retrieve"}} { - command := root - var local []cli.Flag - for _, name := range path { - command = command.Command(name) - if command == nil { - break - } - local = append(local, command.Flags...) - } - if command != nil { - var output []cli.Flag - for _, name := range []string{"format", "transform", "raw-output"} { - for _, flag := range root.Flags { - shadowed := slices.ContainsFunc(local, func(other cli.Flag) bool { - return slices.ContainsFunc(other.Names(), func(alias string) bool { - return slices.Contains(flag.Names(), alias) - }) - }) - if len(flag.Names()) > 0 && flag.Names()[0] == name && !shadowed { - output = append(output, flag) - } - } - } - command.Metadata["brief-output-flags"] = output - } - } } diff --git a/pkg/custom/help_test.go b/pkg/custom/help_test.go index 4c440e3b..576486e0 100644 --- a/pkg/custom/help_test.go +++ b/pkg/custom/help_test.go @@ -1,7 +1,9 @@ package custom import ( + "bytes" "context" + "reflect" "strings" "testing" @@ -39,25 +41,6 @@ func TestConfigureHelpPaginationMetadataPreservesValues(t *testing.T) { } } -func TestConfigureHelpOutputMetadataRespectsShadowing(t *testing.T) { - format := &cli.StringFlag{Name: "format", Aliases: []string{"f"}} - transform := &cli.StringFlag{Name: "transform"} - local := &cli.StringFlag{Name: "field", Aliases: []string{"f"}} - leaf := &cli.Command{Name: "list", Flags: []cli.Flag{local}} - root := &cli.Command{Name: "openai", Flags: []cli.Flag{format, transform}, Commands: []*cli.Command{ - {Name: "models", Commands: []*cli.Command{leaf}}, - {Name: "local"}, - }} - configureHelpGroups(root) - flags := leaf.Metadata["brief-output-flags"].([]cli.Flag) - if len(flags) != 1 || flags[0] != transform { - t.Fatalf("output help included a shadowed root flag: %v", flags) - } - if _, exists := root.Command("local").Metadata["brief-output-flags"]; exists { - t.Fatal("local command received API output help") - } -} - func TestConfigureHelpHidesConfiguredGlobalValues(t *testing.T) { for _, name := range []string{"api-key", "admin-api-key", "webhook-secret", "organization", "project"} { for _, order := range []string{"plain", "wrapped-first", "wrapped-after"} { @@ -111,6 +94,12 @@ func TestConfigureHelpPreservesRootAndRequestFlagOwnership(t *testing.T) { t.Fatalf("incorrect root-owned group: %+v", groups[i]) } } + labels := child.Metadata["help-flag-labels"].([]clihelp.FlagLabel) + for _, label := range labels { + if label.Owner != root { + t.Fatalf("semantic label could apply to a local request field: %+v", label) + } + } } func TestConfigureHelpBaseURLUsesLiteralDefault(t *testing.T) { @@ -127,30 +116,85 @@ func TestConfigureHelpBaseURLUsesLiteralDefault(t *testing.T) { } func TestConfigureHelpRefreshesImageExampleInvocation(t *testing.T) { + for _, executable := range []string{"./openai", "./a user's directory/openai", "openai"} { + for name, example := range map[string]string{ + "generate": `images generate --prompt "A tiny cat" --name cat`, + "edit": `images edit --image "photo.png" --prompt "Make the sky purple" --name purple-sky`, + "create-variation": `images edit --image "photo.png" --prompt "Create a variation of this image" --name variation`, + } { + t.Run(executable+"/"+name, func(t *testing.T) { + var out bytes.Buffer + root := &cli.Command{Name: "openai", Writer: &out, HideHelpCommand: true, + Commands: []*cli.Command{{Name: "images", Commands: []*cli.Command{{Name: name}}}}, + } + // Reconfiguration must not retain the first executable in examples. + if _, _, err := ConfigureHelp(root, []string{"./old-directory/openai", "help", "images", name}); err != nil { + t.Fatal(err) + } + args := []string{executable, "help", "images", name} + normalized, _, err := ConfigureHelp(root, args) + if err != nil { + t.Fatal(err) + } + if err := root.Run(t.Context(), normalized); err != nil { + t.Fatal(err) + } + want := clihelp.Invocation(root.Name, args) + " " + example + if got := out.String(); strings.Count(got, want) != 1 || strings.Contains(got, "old-directory") { + t.Fatalf("example is duplicated or retains an old invocation: want %q in %q", want, got) + } + }) + } + } +} + +func TestConfigureHelpPreservesImageGuidance(t *testing.T) { images := &cli.Command{Name: "images", Commands: []*cli.Command{ {Name: "generate"}, {Name: "edit"}, {Name: "create-variation"}, - {Name: "unrelated", Description: " openai unrelated"}, + {Name: "unrelated", Description: "Keep this description", CustomHelpTemplate: "Keep this template"}, }} root := &cli.Command{Name: "openai", Commands: []*cli.Command{images}} - originals := map[string]string{ - "generate": imageGenerationSavingHelp, "edit": imageEditSavingHelp, - "create-variation": imageVariationSavingHelp, + configureImageSaving(root) + registerImagePreviewCommands(root) + registerImageModels(root) + wantGuidance := map[string][]string{ + "generate": {"interactive terminal", "picker reopens", "Ctrl+C exits", "~/Downloads/gpt-images/", "--format json", "without --name or --output-dir", "depend on the model"}, + "edit": {"Replace photo.png", "Keeps your original", "~/Downloads/gpt-images/", "existing folder", "--format json", "depend on the model"}, + "create-variation": {"retired and no longer available", "GPT Image model", "Replace photo.png", "legacy variations contract"}, + "preview": {"PNG, JPEG and WebP", "No API call or key", "original is unchanged", "Resize, then run", "preferences do not affect", "Replace photo.png", "Pipes, CI", "64 MiB", "16 megapixels", "--format auto or text only", "cannot use --transform or --raw-output"}, + "models": {"exact model names", "individually", "No images are generated", "15 seconds", "no retries", "--offline", "without an API key", "CLI's SDK", "newly released", "permissions or quota", "partial results", "exit nonzero"}, } - // The same tree can be configured again by an embedding caller or a test. - for _, executable := range []string{"./openai", "./a user's directory/openai", "openai"} { - args := []string{executable, "help", "--all", "images", "generate"} - if _, _, err := ConfigureHelp(root, args); err != nil { - t.Fatal(err) + for _, command := range images.Commands { + beforeDescription, beforeUsage := command.Description, command.UsageText + beforeFlags := append([]cli.Flag(nil), command.Flags...) + configureImageHelpContent(root) + configureImageHelpContent(root) + if command.Description != beforeDescription || command.UsageText != beforeUsage || !reflect.DeepEqual(command.Flags, beforeFlags) { + t.Fatalf("help adaptation changed the runtime definition of %s", command.Name) + } + if command.Name == "unrelated" { + if command.CustomHelpTemplate != "Keep this template" { + t.Fatal("help adaptation replaced an unrelated feature template") + } + continue } - invocation := clihelp.Invocation(root.Name, args) - for name, original := range originals { - want := strings.Replace(original, "\n openai ", "\n "+invocation+" ", 1) - if got := images.Command(name).Description; got != want { - t.Errorf("%s example after %q: got %q, want %q", name, executable, got, want) + content := command.Metadata["help-content"].(clihelp.Content) + for _, want := range wantGuidance[command.Name] { + if !strings.Contains(content.Description, want) { + t.Errorf("%s help lost %q: %s", command.Name, want, content.Description) } } - if got := images.Command("unrelated").Description; got != " openai unrelated" { - t.Fatalf("unrelated description changed: %q", got) + if command.Name != "models" && len(content.Examples) != 1 { + t.Errorf("%s help needs one example, got %d", command.Name, len(content.Examples)) + } + } + for _, name := range []string{"on", "off"} { + command := images.Command("inline").Command(name) + content := command.Metadata["help-content"].(clihelp.Content) + for _, want := range []string{"future image generation", "No API request or key", "overrides it", "preview ignores", "local Apple Terminal", "--format auto or text only", "cannot use --transform or --raw-output"} { + if !strings.Contains(content.Description, want) { + t.Errorf("inline %s help lost %q: %s", name, want, content.Description) + } } } } diff --git a/pkg/custom/image_upload_help.go b/pkg/custom/image_upload_help.go index fb165b69..179585b6 100644 --- a/pkg/custom/image_upload_help.go +++ b/pkg/custom/image_upload_help.go @@ -19,7 +19,7 @@ Use images edit with a GPT Image model and a prompt: Replace photo.png with your image's path. Edits save to ~/Downloads/gpt-images/. {{end}} -Full help: {{$bin}} help --all images {{.Name}} +Help: {{$bin}} help images {{.Name}} ` const imageEditSavingHelp = `EXAMPLE diff --git a/pkg/custom/local_errors.go b/pkg/custom/local_errors.go index 8c3bfece..d7799e60 100644 --- a/pkg/custom/local_errors.go +++ b/pkg/custom/local_errors.go @@ -191,7 +191,7 @@ func knownLocalError(command *cli.Command, message string) string { case strings.HasPrefix(message, "No help topic for '"): return unknownCommandErrorMessage(command) case strings.HasPrefix(message, "Unknown help topic "): - return "Unknown help topic. Run openai help --all to see commands." + return "Unknown help topic. Run openai help to see commands." case strings.HasPrefix(message, "Failed to parse piped data as YAML/JSON:\n"): return "Could not parse piped input as YAML or JSON. Check the input's syntax." case strings.HasPrefix(message, "Cannot merge flags with a body that is not a map:"), @@ -291,12 +291,12 @@ func unknownCommandErrorMessage(command *cli.Command) string { } return unknownCommandAt(command, name) } - return "Unknown help topic. Run openai help --all to see commands." + return "Unknown help topic. Run openai help to see commands." } func unknownCommandAt(command *cli.Command, name string) string { if command == nil { - return "Unknown help topic. Run openai help --all to see commands." + return "Unknown help topic. Run openai help to see commands." } if command.Suggest { candidates := clihelp.VisibleCommands(command) @@ -321,7 +321,7 @@ func unknownCommandAt(command *cli.Command, name string) string { } } slices.Reverse(path) - invocation := errorHelpInvocation(command.Root()) + " help --all" + invocation := errorHelpInvocation(command.Root()) + " help" if len(path) > 0 { invocation += " " + strings.Join(path, " ") } diff --git a/pkg/custom/local_errors_test.go b/pkg/custom/local_errors_test.go index fbc1c303..b1ca0aec 100644 --- a/pkg/custom/local_errors_test.go +++ b/pkg/custom/local_errors_test.go @@ -55,7 +55,7 @@ func TestLocalErrorMessageDoesNotExposeDiagnostics(t *testing.T) { {"invalid output format", errors.New("Invalid format: " + secret + ", valid formats are: " + strings.Join(OutputFormats, ", ")), "Invalid output format."}, {"format choices", errors.New("format must be one of: " + strings.Join(OutputFormats, ", ")), "Invalid output format."}, {"untrusted format choices", errors.New("format must be one of: " + secret), "The command could not be completed."}, - {"unknown help topic", fmt.Errorf("Unknown help topic %q. Run %s help --all to see commands.", secret, secret), "Unknown help topic. Run openai help --all to see commands."}, + {"unknown help topic", fmt.Errorf("Unknown help topic %q. Run %s help --all to see commands.", secret, secret), "Unknown help topic. Run openai help to see commands."}, {"invalid YAML", errors.New("Failed to parse piped data as YAML/JSON:\n" + secret), "Could not parse piped input as YAML or JSON."}, {"scalar request body", errors.New("Cannot merge flags with a body that is not a map: " + secret), "The request body must be a JSON or YAML object."}, {"form body", errors.New("Cannot send a non-map value to a form-encoded endpoint: " + secret), "The request body must be a JSON or YAML object."}, diff --git a/pkg/custom/preview_saved_image.go b/pkg/custom/preview_saved_image.go index e75ddcb6..6d37b882 100644 --- a/pkg/custom/preview_saved_image.go +++ b/pkg/custom/preview_saved_image.go @@ -25,7 +25,7 @@ const imagePreviewHelp = `{{$bin := or (index .Root.Metadata "help-invocation") Replace photo.png with your saved image's path. No API call or key needed. Keeps your original. -Full help: {{$bin}} help --all images preview +Command help: {{$bin}} images preview --help ` func registerImagePreviewCommands(root *cli.Command) { diff --git a/pkg/custom/readable_errors.go b/pkg/custom/readable_errors.go index 8fe82eb2..113c7fed 100644 --- a/pkg/custom/readable_errors.go +++ b/pkg/custom/readable_errors.go @@ -214,7 +214,7 @@ func readableAPIArgumentMessage(apierr *openai.Error, command *cli.Command) stri } invocation := errorHelpInvocation(command.Root()) pathToCommand := strings.TrimPrefix(command.FullName(), command.Root().Name+" ") - return message + "\nOptions and examples: " + invocation + " help --all " + pathToCommand + return message + "\nOptions and examples: " + invocation + " help " + pathToCommand } func readableParameterFlag(command *cli.Command, parameter string) (name, path string) { diff --git a/pkg/custom/save_generated_images.go b/pkg/custom/save_generated_images.go index b7aa8b2e..09b63991 100644 --- a/pkg/custom/save_generated_images.go +++ b/pkg/custom/save_generated_images.go @@ -32,7 +32,7 @@ COMMON OPTIONS --output-dir DIRECTORY Save in an existing folder Size and quality choices depend on the model. -Full help: {{$bin}} help --all images generate +Help: {{$bin}} help images generate Key setup: {{$bin}} help setup ` From 859c216b580109a5900ccebc5395f87150a542ec Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Wed, 7 Oct 2026 16:55:48 -0700 Subject: [PATCH 07/44] Record complete help comparisons at bounded terminal sizes --- scripts/demos/record-flag-descriptions.sh | 128 +++++++++++++++++----- 1 file changed, 98 insertions(+), 30 deletions(-) diff --git a/scripts/demos/record-flag-descriptions.sh b/scripts/demos/record-flag-descriptions.sh index e3d6030d..719e1caa 100755 --- a/scripts/demos/record-flag-descriptions.sh +++ b/scripts/demos/record-flag-descriptions.sh @@ -1,6 +1,6 @@ #!/bin/bash -# Record help and error recovery at 90 and 56 columns. Keep all media outside Git. -# Set DEMO_INCLUDE_FULL=0 to omit the longer reference pages. +# Record complete help in 90x76 and 56x76 PTYs. Keep all media outside Git. +# Preview the first 70 terminal lines; preserve complete PTY output separately. # Build the existing fixture before running: # go build -o /tmp/image-model-demo-api ./scripts/demos/image-models # DEMO_API_BINARY=/tmp/image-model-demo-api scripts/demos/record-flag-descriptions.sh \ @@ -11,12 +11,9 @@ if [ "$#" -ne 5 ]; then echo 'usage: record-flag-descriptions.sh BEFORE_BINARY AFTER_BINARY BASE_SHA CANDIDATE_SHA OUTPUT_DIR' >&2 exit 2 fi -demo_full_rows="${DEMO_FULL_ROWS:-280}" -[[ "$demo_full_rows" =~ ^[1-9][0-9]*$ ]] || { echo 'DEMO_FULL_ROWS must be positive.' >&2; exit 2; } -demo_include_full="${DEMO_INCLUDE_FULL:-1}" -[[ "$demo_include_full" =~ ^[01]$ ]] || { echo 'DEMO_INCLUDE_FULL must be 0 or 1.' >&2; exit 2; } demo_source="$(cd "$(dirname "$0")" && pwd)" demo_root="$(cd "$demo_source/../.." && pwd)" +demo_python="$(command -v python3)" source "$demo_source/capture_and_render.sh" demo_prepare_capture "$demo_root" "$1" "$2" "$3" "$4" "$5" \ "${DEMO_API_BINARY:-$demo_root/dist/demos/bin/image-model-demo-api}" @@ -25,35 +22,99 @@ demo_render_options=(--font-family Menlo --font-size 18 --line-height 1.2 \ --theme dracula --fps-cap 20 --last-frame-duration 3) demo_start_api "$demo_runtime/requests.txt" { - echo 'feature: useful help, typed flags, and specific error recovery' + echo 'feature: one complete help page with readable flag descriptions' echo "base commit: $demo_before_sha" echo "candidate commit: $demo_after_sha" echo 'data: no API key; loopback fixture records unexpected requests' echo 'capture: real binaries in isolated bash PTYs; no personal environment or shell hooks' + echo 'geometry: actual stdout PTY checked with stty; 90x76 and 56x76' + echo 'preview: labeled first 70 terminal lines, derived from complete PTY output' + echo 'full evidence: *-full.cast, *-full.txt, *-stdout.bin, *-stdout.txt, *-stderr.txt' + echo 'stdout.bin retains UTF-8 PTY bytes; stdout.txt normalizes only CRLF endings' echo 'scope: terminal replay; no native graphical terminal claim' demo_capture_metadata + "$demo_python" --version shasum -a 256 "$demo_source/record-flag-descriptions.sh" "$demo_source/image-models/main.go" } > "$demo_output_root/metadata.txt" -demo_topics=(root-short models-short responses-short images-short unknown-option invalid-integer) -if [ "$demo_include_full" -eq 1 ]; then demo_topics+=(root-full images-full); fi -for demo_topic in "${demo_topics[@]}"; do +# Keep original recordings untouched. Excerpts contain only plain help text. +cat > "$demo_runtime/excerpt.py" <<'PYTHON' +import json +from pathlib import Path +import sys +import unicodedata + +directory, scene, columns, topic, *arguments = sys.argv[1:] +directory = Path(directory) +columns = int(columns) +records = [json.loads(line) for line in (directory / f"{scene}-full.cast").read_text().splitlines()] +header, events = records[0], records[1:] +if (header["width"], header["height"]) != (columns, 76): + raise SystemExit("The full capture has unexpected PTY dimensions.") +if (directory / f"{scene}-geometry.txt").read_text().split() != ["76", str(columns)]: + raise SystemExit("The command's actual stdout PTY dimensions did not match.") +if any(event[1] == "r" for event in events): + raise SystemExit("The terminal resized during capture.") +recorded = "".join(event[2] for event in events if event[1] == "o") +command = "$ openai " + " ".join(arguments) +_, found, output = recorded.partition(command + "\r\n") +suffix = "\r\n$ " +if not found or not output.endswith(suffix): + raise SystemExit("The capture lacks complete command-output boundaries.") +output = output[:-len(suffix)] +if not output or (directory / f"{scene}-stderr.txt").read_bytes(): + raise SystemExit("Help must produce stdout with empty stderr.") +if any(value in output for value in ("synthetic-demo-key", "/help-only/v1")): + raise SystemExit("Help disclosed a configured fixture value.") +(directory / f"{scene}-stdout.bin").write_bytes(output.encode("utf-8")) +plain = output.replace("\r\n", "\n") +(directory / f"{scene}-stdout.txt").write_bytes(plain.encode("utf-8")) + +# Count terminal rows, including soft wraps in long synopsis/example lines. +# Reject controls instead of silently changing the recorded command output. +rows = [] +lines = plain.split("\n")[:-1] if plain.endswith("\n") else plain.split("\n") +for line in lines: + current, cells = "", 0 + for character in line.expandtabs(8): + if unicodedata.category(character).startswith("C"): + raise SystemExit("Unsupported control character in plain help output.") + width = 0 if unicodedata.combining(character) else 2 if unicodedata.east_asian_width(character) in "WF" else 1 + if cells + width > columns: + rows.append(current) + current, cells = "", 0 + current += character + cells += width + rows.append(current) + +label = f"{scene.title()}: {topic} ({columns} columns)" +caption = "Excerpt: first 70 terminal lines" +excerpt = "\n".join(rows[:70]) +text = f"{label}\n{caption}\n\n{command}\n{excerpt}\n[Full stdout retained separately]\n" +header.update(title=f"{label}; {caption}", width=columns, height=76) +with (directory / f"{scene}.cast").open("w") as stream: + stream.write(json.dumps(header) + "\n") + stream.write(json.dumps([0.0, "o", "\u001b[2J\u001b[H"]) + "\n") + stream.write(json.dumps([0.3, "o", text.replace("\n", "\r\n")]) + "\n") +(directory / f"{scene}.txt").write_text(text) +with (directory / "metadata.txt").open("a") as stream: + stream.write(f"{scene}: {len(rows)} complete terminal lines; excerpt shows {min(len(rows), 70)}\n") +PYTHON + +for demo_topic in root images-generate; do demo_expected_status=0 case "$demo_topic" in - root-full) demo_args=(help --all); demo_rows="$demo_full_rows";; - images-full) demo_args=(help --all images generate); demo_rows="$demo_full_rows";; - root-short) demo_args=(--help); demo_rows=96;; - models-short) demo_args=(models list --help); demo_rows=48;; - responses-short) demo_args=(responses create --help); demo_rows=48;; - images-short) demo_args=(images generate --help); demo_rows=48;; - unknown-option) demo_args=(models list --max-itmes=1); demo_rows=20; demo_expected_status=1;; - invalid-integer) demo_args=(models list --max-items=not-an-integer); demo_rows=20; demo_expected_status=1;; + root) demo_args=(--help);; + images-generate) demo_args=(images generate --help);; esac { cat <<'SCENE' #!/bin/bash set -uo pipefail test -t 0 && test -t 1 && test -t 2 || exit 99 +demo_geometry="$(stty size)" || exit 98 +test "$demo_geometry" = "$LINES $COLUMNS" || exit 98 +printf '%s\n' "$demo_geometry" > "$DEMO_GEOMETRY_FILE" unset OPENAI_API_KEY printf '\033[2J\033[H%s\n\n' "$DEMO_SCENE_LABEL" sleep 0.3 @@ -67,7 +128,8 @@ printf '$ openai' printf ' %s' "${demo_args[@]}" printf '\n' sleep 0.3 -if openai "${demo_args[@]}"; then demo_status=0; else demo_status=$?; fi +# Keep stdout on the real PTY. Redirected stdout would force 80-column help. +if openai "${demo_args[@]}" 2>"$DEMO_STDERR_FILE"; then demo_status=0; else demo_status=$?; fi printf '\n$ ' sleep 3 exit "$demo_status" @@ -75,24 +137,30 @@ SCENE } > "$demo_runtime/scene.sh" for demo_columns in 90 56; do - demo_window_size="${demo_columns}x${demo_rows}" + demo_window_size="${demo_columns}x76" demo_output="$demo_output_root/$demo_topic-$demo_columns" mkdir "$demo_output" { printf 'command: openai'; printf ' %s' "${demo_args[@]}"; printf '\n' echo "render: asciinema + agg, Menlo 18px, Dracula, $demo_window_size, line height 1.2" + echo 'before/after media: first-70-terminal-line excerpts, not complete pages' + echo 'before-full/after-full: untouched complete PTY captures and transcripts' } > "$demo_output/metadata.txt" cp "$demo_runtime/scene.sh" "$demo_output/scene.sh" - demo_capture_scene before "$demo_expected_status" "$demo_runtime/before" "$demo_api_url/help-only/v1" \ - "Before: $demo_topic ($demo_columns columns)" "COLUMNS=$demo_columns" "LINES=$demo_rows" - demo_capture_scene after "$demo_expected_status" "$demo_runtime/after" "$demo_api_url/help-only/v1" \ - "After: $demo_topic ($demo_columns columns)" "COLUMNS=$demo_columns" "LINES=$demo_rows" for demo_scene in before after; do - /usr/bin/grep -Fq 'openai' "$demo_output/$demo_scene.txt" - if /usr/bin/grep -Eq 'synthetic-demo-key|/help-only/v1' "$demo_output/$demo_scene.txt"; then - echo 'The command disclosed a configured fixture value.' >&2 - exit 1 - fi + demo_capture_scene "$demo_scene-full" "$demo_expected_status" "$demo_runtime/$demo_scene" "$demo_api_url/help-only/v1" \ + "$demo_scene: $demo_topic ($demo_columns columns; complete capture)" \ + "COLUMNS=$demo_columns" "LINES=76" \ + "DEMO_GEOMETRY_FILE=$demo_output/$demo_scene-geometry.txt" \ + "DEMO_STDERR_FILE=$demo_output/$demo_scene-stderr.txt" + test ! -s "$demo_output/$demo_scene-stderr.txt" + test ! -s "$demo_runtime/requests.txt" + "$demo_python" "$demo_runtime/excerpt.py" "$demo_output" "$demo_scene" "$demo_columns" "$demo_topic" "${demo_args[@]}" + "$demo_agg" --quiet "${demo_render_options[@]}" "$demo_output/$demo_scene.cast" "$demo_output/$demo_scene.gif" + "$demo_agg" --quiet "${demo_render_options[@]}" --select 100% \ + "$demo_output/$demo_scene.cast" "$demo_output/$demo_scene-frame.gif" + "$demo_ffmpeg" -hide_banner -loglevel error -y \ + -i "$demo_output/$demo_scene-frame.gif" -frames:v 1 "$demo_output/$demo_scene.png" done test ! -s "$demo_runtime/requests.txt" demo_assemble_capture 300 before after @@ -102,4 +170,4 @@ demo_stop_api cp "$demo_runtime/requests.txt" "$demo_output_root/requests.txt" test ! -s "$demo_output_root/requests.txt" echo 'API requests: 0 (required)' >> "$demo_output_root/metadata.txt" -printf 'Recorded help and error recovery in %s\n' "$demo_output_root" +printf 'Recorded complete help and labeled excerpts in %s\n' "$demo_output_root" From 7705e330bc14bfce19dffb3c9dc5760162211a1c Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Thu, 8 Oct 2026 00:11:04 -0700 Subject: [PATCH 08/44] Clarify command syntax and match gcloud help structure --- README.md | 1 + cmd/openai/flag_descriptions_test.go | 22 ++- cmd/openai/gcloud_help_test.go | 145 ++++++++++++++++++++ cmd/openai/image_settings_help_test.go | 6 +- cmd/openai/main_help_test.go | 16 ++- cmd/openai/main_requestheaders_test.go | 6 +- cmd/openai/main_shell_test.go | 36 +++-- cmd/openai/normal_help_examples_test.go | 6 +- internal/clihelp/command_help.go | 98 +++++++++---- internal/clihelp/command_help_test.go | 115 ++++++++++++++++ internal/clihelp/full.go | 31 ++++- internal/clihelp/full_test.go | 61 ++++++++- internal/clihelp/synopsis.go | 151 +++++++++++++++++++++ internal/clihelp/synopsis_test.go | 96 +++++++++++++ internal/clihelp/terminal_prose.go | 148 ++++++++++++++++++++ internal/clihelp/terminal_prose_test.go | 75 ++++++++++ internal/clihelp/wrap_descriptions.go | 22 ++- pkg/custom/command.go | 1 + pkg/custom/describe_request_inputs.go | 60 ++++++++ pkg/custom/describe_request_inputs_test.go | 97 +++++++++++++ pkg/custom/help.go | 39 +++++- pkg/custom/help_test.go | 4 +- scripts/demos/record-flag-descriptions.sh | 4 +- 23 files changed, 1161 insertions(+), 79 deletions(-) create mode 100644 cmd/openai/gcloud_help_test.go create mode 100644 internal/clihelp/synopsis.go create mode 100644 internal/clihelp/synopsis_test.go create mode 100644 internal/clihelp/terminal_prose.go create mode 100644 internal/clihelp/terminal_prose_test.go create mode 100644 pkg/custom/describe_request_inputs.go create mode 100644 pkg/custom/describe_request_inputs_test.go diff --git a/README.md b/README.md index 55c49d30..d6e3a32f 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ openai --help # Command groups and global options Add `--help` (or `-h`) to any command for its complete help page. `openai help COMMAND` shows the same page. +Command pages list global options. Use `openai --help` for their full descriptions. You can place `--help` after valid command options. The old `help --all` spelling prints migration guidance and the complete page. Command behavior, defaults, and output formats remain unchanged. diff --git a/cmd/openai/flag_descriptions_test.go b/cmd/openai/flag_descriptions_test.go index 747b35cc..20f72113 100644 --- a/cmd/openai/flag_descriptions_test.go +++ b/cmd/openai/flag_descriptions_test.go @@ -42,6 +42,24 @@ func TestMainGlobalFlagDescriptions(t *testing.T) { got := runMainDispatchWithEnv(t, "bash", nil, args...) require.Zero(t, got.code, got.stderr) require.Empty(t, got.stderr) + if len(path) > 0 { + _, index, found := strings.Cut(got.stdout, "GLOBAL OPTIONS:\n") + require.True(t, found) + for _, group := range groups { + for _, flag := range group.flags { + // Root-local flags become inherited in the separate globals feature. + if flag == "--api-key" || flag == "--admin-api-key" || flag == "--webhook-secret" || flag == "--organization" || flag == "--project" { + continue + } + require.Contains(t, index, flag) + } + } + require.Contains(t, index, "Global option details: openai --help") + // Execute the reference before checking the full global descriptions. + got = runMainDispatch(t, "bash", "openai", "--help") + require.Zero(t, got.code, got.stderr) + require.Empty(t, got.stderr) + } remaining := got.stdout for i, group := range groups { _, after, found := strings.Cut(remaining, "\n "+group.title+"\n") @@ -146,9 +164,11 @@ func TestMainGlobalFlagHelpHidesConfiguredValues(t *testing.T) { for _, secret := range []string{"synthetic-env-", "synthetic-flag-", "example.invalid"} { require.NotContains(t, got.stdout+got.stderr, secret) } - if route[0] == "help" { + if len(route) == 1 { require.Contains(t, got.stdout, "https://api.openai.com/v1") require.Contains(t, got.stdout, "OPENAI_BASE_URL") + } else { + require.Contains(t, got.stdout, "Global option details: openai --help") } }) } diff --git a/cmd/openai/gcloud_help_test.go b/cmd/openai/gcloud_help_test.go new file mode 100644 index 00000000..f1c2e3ac --- /dev/null +++ b/cmd/openai/gcloud_help_test.go @@ -0,0 +1,145 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func helpSection(text, start, end string) string { + _, section, found := strings.Cut(text, start+":\n") + if !found { + return "" + } + section, _, _ = strings.Cut(section, "\n"+end+":") + return strings.TrimSpace(section) +} + +func TestMainGcloudHelpPreservesDetailsAfterShortName(t *testing.T) { + got := runMainDispatch(t, "bash", "openai", "responses", "create", "--help") + if got.code != 0 || got.stderr != "" { + t.Fatalf("help failed: %+v", got) + } + name := helpSection(got.stdout, "NAME", "SYNOPSIS") + if strings.Contains(name, "https://") || !strings.Contains(name, "Creates a model response.") { + t.Fatalf("NAME is not a short command summary: %s", name) + } + description := helpSection(got.stdout, "DESCRIPTION", "EXAMPLES") + for _, want := range []string{"guides/text", "guides/images-vision", "guides/structured-outputs", "guides/function-calling", "guides/tools", "guides/tools-web-search", "guides/tools-file-search"} { + if !strings.Contains(description, want) { + t.Errorf("DESCRIPTION lost reference %q", want) + } + } + if strings.Contains(description, "](") { + t.Errorf("DESCRIPTION retained unrendered Markdown: %s", description) + } +} + +func TestMainGcloudHelpConcreteSyntaxAndLabels(t *testing.T) { + for _, tc := range []struct { + path []string + want []string + }{ + {[]string{"models", "retrieve"}, []string{"[MODEL | --model MODEL]", "Model ID.", "JSON/YAML keys: model."}}, + {[]string{"files", "upload"}, []string{"[--file PATH]", "[--purpose TEXT]", "Required request inputs: --file, --purpose."}}, + {[]string{"images", "generate"}, []string{"--size SIZE", "--quality QUALITY", "--background BACKGROUND", "--output-format FORMAT", "--inline MODE"}}, + } { + got := runMainDispatch(t, "bash", append(append([]string{"openai"}, tc.path...), "--help")...) + if got.code != 0 || got.stderr != "" { + t.Fatalf("help failed: %+v", got) + } + plain := strings.Join(strings.Fields(got.stdout), " ") + for _, want := range tc.want { + if !strings.Contains(plain, want) { + t.Errorf("%q omitted %q: %s", tc.path, want, got.stdout) + } + } + if strings.Contains(helpSection(got.stdout, "SYNOPSIS", "DESCRIPTION"), "[OPTIONS]") { + t.Errorf("%q retained a generic synopsis", tc.path) + } + } +} + +func TestMainGcloudHelpCompactGlobalsAndLocalRestrictions(t *testing.T) { + root := runMainDispatch(t, "bash", "openai", "--help") + for _, path := range [][]string{{"models", "list"}, {"images", "preview"}, {"images", "inline", "on"}} { + got := runMainDispatch(t, "bash", append(append([]string{"openai"}, path...), "--help")...) + if got.code != 0 || got.stderr != "" { + t.Fatalf("help failed: %+v", got) + } + _, globals, found := strings.Cut(got.stdout, "GLOBAL OPTIONS:\n") + if !found || !strings.Contains(globals, "Global option details: openai --help") { + t.Fatalf("global option reference missing: %s", got.stdout) + } + for _, name := range []string{"--format", "--format-error", "--transform", "--raw-output", "-r", "--header", "-H", "--base-url"} { + if !strings.Contains(globals, name) { + t.Errorf("global option index lost %s", name) + } + } + if strings.Contains(globals, "Choose how results are displayed") || strings.Contains(globals, "- json: full JSON") { + t.Error("global index repeated full or inapplicable format descriptions") + } + if path[0] == "images" { + flat := strings.Join(strings.Fields(globals), " ") + for _, note := range []string{"--format auto or text only", "cannot use --transform or --raw-output"} { + if !strings.Contains(flat, note) { + t.Errorf("local restriction missing beside global index: %s", note) + } + } + } + } + if root.code != 0 || root.stderr != "" || !strings.Contains(root.stdout, "Choose how results are displayed") || !strings.Contains(root.stdout, "Env: OPENAI_API_KEY") { + t.Fatalf("root lost global option details: %+v", root) + } +} + +func TestMainGcloudHelpDocumentedInputSourcesExecute(t *testing.T) { + requests := make(chan string, 12) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests <- r.Method + " " + r.URL.Path + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"id": "synthetic", "object": "model", "created": 0, "owned_by": "synthetic"}) + })) + defer server.Close() + for _, tc := range []struct { + name, stdin, path string + args []string + }{ + {"model positional", "", "/models/model-synthetic", []string{"models", "retrieve", "model-synthetic"}}, + {"model flag", "", "/models/model-synthetic", []string{"models", "retrieve", "--model", "model-synthetic"}}, + {"model JSON", `{"model":"model-synthetic"}`, "/models/model-synthetic", []string{"models", "retrieve"}}, + {"model YAML", "model: model-synthetic\n", "/models/model-synthetic", []string{"models", "retrieve"}}, + {"multiple positionals", "", "/vector_stores/vs-synthetic/files/file-synthetic", []string{"vector-stores", "files", "retrieve", "vs-synthetic", "file-synthetic"}}, + {"mixed sources", `{"file_id":"file-synthetic"}`, "/vector_stores/vs-synthetic/files/file-synthetic", []string{"vector-stores", "files", "retrieve", "--vector-store-id", "vs-synthetic"}}, + {"multiple YAML keys", "vector_store_id: vs-synthetic\nfile_id: file-synthetic\n", "/vector_stores/vs-synthetic/files/file-synthetic", []string{"vector-stores", "files", "retrieve"}}, + } { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "input.txt") + if err := os.WriteFile(path, []byte(tc.stdin), 0600); err != nil { + t.Fatal(err) + } + stdin, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer stdin.Close() + args := append([]string{"openai", "--format", "json"}, tc.args...) + got := runMainDispatchWithStdin(t, "bash", []string{"OPENAI_API_KEY=fake-help-key", "OPENAI_BASE_URL=" + server.URL}, stdin, args...) + if got.code != 0 || got.stderr != "" || !strings.Contains(got.stdout, `"id": "synthetic"`) { + t.Fatalf("documented syntax failed: %+v", got) + } + select { + case request := <-requests: + if request != "GET "+tc.path { + t.Errorf("input sources changed request target: %s", request) + } + default: + t.Fatal("documented syntax did not reach the synthetic request") + } + }) + } +} diff --git a/cmd/openai/image_settings_help_test.go b/cmd/openai/image_settings_help_test.go index ee08bf1d..b91843c4 100644 --- a/cmd/openai/image_settings_help_test.go +++ b/cmd/openai/image_settings_help_test.go @@ -31,7 +31,7 @@ func TestMainImageSettingsHelpScope(t *testing.T) { require.Contains(t, short.stdout, "~/Downloads/gpt-images/") } if operation != "create-variation" { - require.Contains(t, short.stdout, "--name TEXT") + require.Contains(t, short.stdout, "--name NAME") require.Contains(t, short.stdout, "--output-dir DIRECTORY") if operation == "generate" { require.Contains(t, short.stdout, "choose settings in an interactive terminal") @@ -46,7 +46,9 @@ func TestMainImageSettingsHelpScope(t *testing.T) { require.True(t, found, "full help must include the command description") description, _, found = strings.Cut(description, "\nEXAMPLES:") require.True(t, found, "full help must retain the separate option reference") - require.LessOrEqual(t, len(strings.Fields(description)), 65, "keep the introduction concise; details belong in the option reference") + require.NotEmpty(t, description, "preserve the detailed command guidance") + name := helpSection(full.stdout, "NAME", "SYNOPSIS") + require.LessOrEqual(t, len(strings.Fields(name)), 30, "keep NAME concise; preserve detail in DESCRIPTION") require.Equal(t, 1, strings.Count(full.stdout, "--prompt \""), "lead with one useful example") text := strings.Join(strings.Fields(full.stdout), " ") for _, want := range []string{"--name", "-2, -3", "Uses your saved preference unless set", "Pipes and CI never show previews"} { diff --git a/cmd/openai/main_help_test.go b/cmd/openai/main_help_test.go index fc64ff21..6cbb5576 100644 --- a/cmd/openai/main_help_test.go +++ b/cmd/openai/main_help_test.go @@ -115,7 +115,11 @@ func TestMainHelpFullReferencePreservesEveryFlag(t *testing.T) { t.Errorf("full help lost command %q", child.Name) } } - for _, text := range []string{"--header", "OPENAI_CUSTOM_HEADERS"} { + headerReference := []string{"--header", "Global option details: openai --help"} + if len(path) == 0 { + headerReference = []string{"--header", "OPENAI_CUSTOM_HEADERS"} + } + for _, text := range headerReference { if !strings.Contains(got.stdout, text) { t.Errorf("full help lost request-header documentation %q", text) } @@ -210,11 +214,11 @@ func TestMainHelpKeepsAPIProjectInCommandOptions(t *testing.T) { t.Fatalf("API project input is missing: %s", got.stdout) } for _, heading := range []string{"Authentication", "Output", "Request options", "Troubleshooting"} { - if strings.Contains(local, heading) || !strings.Contains(global, heading) { - t.Errorf("%q should group only global flags: %s", heading, got.stdout) + if strings.Contains(local, "\n "+heading+"\n") { + t.Errorf("global group %q appeared among local API fields: %s", heading, got.stdout) } } - if !strings.Contains(global, "--base-url") || !strings.Contains(global, "--format FORMAT") { + if !strings.Contains(global, "--base-url") || !strings.Contains(global, "--format") || !strings.Contains(global, "Global option details: openai --help") { t.Errorf("global configuration is missing: %s", global) } } @@ -225,14 +229,14 @@ func TestMainHelpFullReferenceNullableDefaults(t *testing.T) { flag, wantDefault string }{ {[]string{"beta:assistants", "create"}, "--description TEXT", ""}, - {[]string{"images", "generate"}, "--output-format TEXT", "Default: png"}, + {[]string{"images", "generate"}, "--output-format FORMAT", "Default: png"}, } { t.Run(strings.Join(tc.path, "/"), func(t *testing.T) { got := runMainDispatch(t, "bash", append([]string{"openai", "help"}, tc.path...)...) if got.code != 0 || got.stderr != "" { t.Fatalf("full reference failed: %+v", got) } - _, details, found := strings.Cut(got.stdout, tc.flag) + _, details, found := strings.Cut(got.stdout, "\n "+tc.flag+"\n") if !found { t.Fatalf("full reference lost %s", tc.flag) } diff --git a/cmd/openai/main_requestheaders_test.go b/cmd/openai/main_requestheaders_test.go index 8ab4b1d4..5c684563 100644 --- a/cmd/openai/main_requestheaders_test.go +++ b/cmd/openai/main_requestheaders_test.go @@ -327,7 +327,11 @@ func TestMainRequestHeadersHelpAndCompletion(t *testing.T) { for _, path := range [][]string{nil, {"models", "retrieve"}} { args := append([]string{"openai", "--header", "X-Test: fake-help-secret", "help", "--all"}, path...) got := runMainDispatchWithEnv(t, "bash", []string{"OPENAI_CUSTOM_HEADERS=X-Test: fake-env-help-secret"}, args...) - if got.code != 0 || !strings.Contains(got.stdout, "OPENAI_CUSTOM_HEADERS") || strings.Contains(got.stdout+got.stderr, "fake-help-secret") || strings.Contains(got.stdout+got.stderr, "fake-env-help-secret") { + documentation := "OPENAI_CUSTOM_HEADERS" + if len(path) > 0 { + documentation = "Global option details: openai --help" + } + if got.code != 0 || !strings.Contains(got.stdout, documentation) || !strings.Contains(got.stdout, "--header") || strings.Contains(got.stdout+got.stderr, "fake-help-secret") || strings.Contains(got.stdout+got.stderr, "fake-env-help-secret") { t.Errorf("full header help = %+v; want documentation without header values", got) } } diff --git a/cmd/openai/main_shell_test.go b/cmd/openai/main_shell_test.go index 3e2e01d5..4d490d00 100644 --- a/cmd/openai/main_shell_test.go +++ b/cmd/openai/main_shell_test.go @@ -190,19 +190,24 @@ func TestMainNativeShell(t *testing.T) { } t.Run(name, func(t *testing.T) { short := runNativeShell(t, shell, directory, home, "not-a-url", invocation+" images generate --help") - var command string - for line := range strings.SplitSeq(short.stdout, "\n") { - if after, ok := strings.CutPrefix(strings.TrimSpace(line), "Key setup: "); ok { - command = after - break + for _, link := range []struct{ label, content string }{ + {"Key setup: ", "OPENAI_API_KEY"}, + {"Global option details: ", "--api-key KEY"}, + } { + var command string + for line := range strings.SplitSeq(short.stdout, "\n") { + if after, ok := strings.CutPrefix(strings.TrimSpace(line), link.label); ok { + command = after + break + } + } + if short.code != 0 || command == "" { + t.Fatalf("help lacks a copyable %s command: %+v", link.label, short) + } + got := runNativeShell(t, shell, directory, home, "not-a-url", command) + if got.code != 0 || got.stderr != "" || !strings.Contains(got.stdout, link.content) { + t.Fatalf("displayed command %q failed: %+v", command, got) } - } - if short.code != 0 || command == "" { - t.Fatalf("help lacks a copyable setup command: %+v", short) - } - got := runNativeShell(t, shell, directory, home, "not-a-url", command) - if got.code != 0 || got.stderr != "" || !strings.Contains(got.stdout, "OPENAI_API_KEY") { - t.Fatalf("displayed command %q failed: %+v", command, got) } }) for _, full := range []bool{false, true} { @@ -217,7 +222,12 @@ func TestMainNativeShell(t *testing.T) { home := t.TempDir() short := runNativeShell(t, shell, directory, home, "not-a-url", invocation+helpArgs) var command string - for line := range strings.SplitSeq(short.stdout, "\n") { + _, examples, found := strings.Cut(short.stdout, "EXAMPLES:\n") + if !found { + t.Fatalf("help omitted its executable examples: %+v", short) + } + examples, _, _ = strings.Cut(examples, "\nOPTIONS:") + for line := range strings.SplitSeq(examples, "\n") { if strings.Contains(line, " images generate --prompt ") { command = strings.TrimSpace(line) break diff --git a/cmd/openai/normal_help_examples_test.go b/cmd/openai/normal_help_examples_test.go index d5723957..962b8a6c 100644 --- a/cmd/openai/normal_help_examples_test.go +++ b/cmd/openai/normal_help_examples_test.go @@ -25,7 +25,11 @@ func TestMainNormalHelpOutputOptions(t *testing.T) { if got.code != 0 || got.stderr != "" { t.Fatalf("normal help failed: %+v", got) } - for _, want := range []string{"\n Output\n", "--format ", "--transform PATH", "--raw-output"} { + wantOptions := []string{"GLOBAL OPTIONS:", "--format", "--transform", "--raw-output", "Global option details: openai --help"} + if len(args) == 0 { + wantOptions = []string{"\n Output\n", "--format FORMAT", "--transform PATH", "--raw-output"} + } + for _, want := range wantOptions { if !strings.Contains(got.stdout, want) { t.Errorf("normal help lacks %q:\n%s", want, got.stdout) } diff --git a/internal/clihelp/command_help.go b/internal/clihelp/command_help.go index 46c62a68..14fc4226 100644 --- a/internal/clihelp/command_help.go +++ b/internal/clihelp/command_help.go @@ -10,8 +10,11 @@ import ( // Content supplies complete handwritten guidance without changing command behavior. // Command examples omit the executable; the renderer supplies the safe invocation. type Content struct { - Description string - Examples []Example + Summary string + InputNote string + Description string + Examples []Example + GlobalOptionsNote string } type Example struct { @@ -49,16 +52,33 @@ func commandHelpAtWidth(command *cli.Command, invocation, path string, width int if path == "" { usage = "Use the OpenAI API from your terminal." } - out.WriteString(wrapDescription(usage, " ", width)) - fmt.Fprintf(&out, "\nSYNOPSIS:\n %s\n", commandSynopsis(command, name, invocation)) - content, supplied := command.Metadata["help-content"].(Content) if !supplied { content = Content{Description: command.Description, Examples: examples[path]} } - if content.Description != "" { + plainUsage := terminalProse(usage) + summary := terminalProse(content.Summary) + if summary == "" { + summary = commandSummary(plainUsage) + } + out.WriteString(wrapPlainDescription(summary, " ", width)) + out.WriteString("\nSYNOPSIS:\n") + out.WriteString(commandSynopsis(command, name, invocation, width)) + note := content.InputNote + if note == "" { + note = synopsisInputNote(command) + } + if note != "" { + out.WriteByte('\n') + out.WriteString(wrapDescription(note, " ", width)) + } + description := content.Description + if strings.Join(strings.Fields(plainUsage), " ") != strings.Join(strings.Fields(summary), " ") { + description = joinHelpDescriptions(usage, description) + } + if description != "" { out.WriteString("\nDESCRIPTION:\n") - out.WriteString(wrapDescription(content.Description, " ", width)) + out.WriteString(wrapDescription(description, " ", width)) } if path == "" { fmt.Fprintf(&out, "\nSTART HERE\n %s help setup\n %s models list\n", invocation, invocation) @@ -90,8 +110,13 @@ func commandHelpAtWidth(command *cli.Command, invocation, path string, width int out.WriteString(fullFlagGroups(command, flags, width)) } if flags := command.VisiblePersistentFlags(); len(flags) > 0 { - out.WriteString("\nGLOBAL OPTIONS:") - out.WriteString(fullFlagGroups(command, flags, width)) + out.WriteString("\nGLOBAL OPTIONS:\n") + out.WriteString(globalFlagIndex(flags, width)) + if content.GlobalOptionsNote != "" { + out.WriteByte('\n') + out.WriteString(wrapDescription(content.GlobalOptionsNote, " ", width)) + } + fmt.Fprintf(&out, "\n Global option details: %s --help\n", invocation) } if path == "" { out.WriteString("\nAdd --help (or -h) to any command. Help needs no API key or internet.\n") @@ -100,22 +125,49 @@ func commandHelpAtWidth(command *cli.Command, invocation, path string, width int return out.String() } -func commandSynopsis(command *cli.Command, name, invocation string) string { - if command.UsageText != "" { - // Existing handwritten usage uses the public command name. Replace only - // that leading executable, never filenames or arbitrary description text. - return strings.Replace(command.UsageText, "openai ", invocation+" ", 1) - } - // Required request fields may also arrive through stdin. The required-input - // reference describes those fields without making flags the only syntax. - name += " [OPTIONS]" - if len(VisibleCommands(command)) > 0 { - name += " COMMAND" +func commandSummary(usage string) string { + paragraph, _, _ := strings.Cut(usage, "\n\n") + paragraph = strings.Join(strings.Fields(paragraph), " ") + for i := range len(paragraph) - 1 { + if strings.ContainsRune(".!?", rune(paragraph[i])) && paragraph[i+1] == ' ' { + return paragraph[:i+1] + } } - if command.ArgsUsage != "" { - name += " " + command.ArgsUsage + return paragraph +} + +func joinHelpDescriptions(descriptions ...string) string { + var paragraphs []string + seen := map[string]bool{} + for _, description := range descriptions { + for _, paragraph := range strings.Split(description, "\n\n") { + key := strings.Join(strings.Fields(paragraph), " ") + if key != "" && !seen[key] { + paragraphs = append(paragraphs, paragraph) + seen[key] = true + } + } + } + return strings.Join(paragraphs, "\n\n") +} + +func globalFlagIndex(flags []cli.Flag, width int) string { + var names []string + seen := map[string]bool{} + for _, flag := range flags { + for _, name := range flag.Names() { + if name == "" || seen[name] { + continue + } + seen[name] = true + prefix := "--" + if len(name) == 1 { + prefix = "-" + } + names = append(names, prefix+name) + } } - return name + return wrapPlainDescription(strings.Join(names, ", "), " ", width) } var examples = map[string][]Example{ diff --git a/internal/clihelp/command_help_test.go b/internal/clihelp/command_help_test.go index 068a8b85..b7bcf27c 100644 --- a/internal/clihelp/command_help_test.go +++ b/internal/clihelp/command_help_test.go @@ -112,6 +112,121 @@ func TestCompleteHelpContentOverridesExamplesAndDescription(t *testing.T) { } } +func TestCommandNameKeepsLongUsageInDescription(t *testing.T) { + command := &cli.Command{Name: "create", + Usage: "Create a response. Preserve the complete API restriction.\n\nRead [the guide](https://example.invalid/guide).", + Description: "Keep the authored caveat.", + } + got := commandHelpAtWidth(command, "openai", "responses create", 80) + name, rest, found := strings.Cut(got, "\nSYNOPSIS:") + if !found || !strings.Contains(name, "Create a response.") || strings.Contains(name, "restriction") { + t.Fatalf("NAME is not a short command identity: %s", name) + } + _, description, found := strings.Cut(rest, "\nDESCRIPTION:\n") + if !found { + t.Fatal("long usage has no DESCRIPTION") + } + for _, want := range []string{"Create a response. Preserve the complete API restriction.", "the guide (https://example.invalid/guide)", "Keep the authored caveat."} { + if !strings.Contains(description, want) { + t.Errorf("DESCRIPTION lost %q: %s", want, description) + } + } +} + +func TestAuthoredSummaryPreservesUsageAndContentContract(t *testing.T) { + command := &cli.Command{Name: "create", Usage: "A long existing usage sentence with important input details.", + Description: "Superseded description.", + Metadata: map[string]any{"help-content": Content{ + Summary: "Create an item.", Description: "Keep the complete feature caveat.", + }}, + } + got := commandHelpAtWidth(command, "openai", "items create", 100) + name, _, _ := strings.Cut(got, "\nSYNOPSIS:") + if !strings.Contains(name, "Create an item.") || strings.Contains(name, "existing usage") { + t.Fatalf("authored summary was not used: %s", name) + } + if !strings.Contains(got, command.Usage) || !strings.Contains(got, "Keep the complete feature caveat.") || strings.Contains(got, "Superseded") { + t.Fatalf("description content changed: %s", got) + } +} + +func TestInputNoteOverridesOnlySynopsisGuidance(t *testing.T) { + prompt := &requestflag.Flag[string]{Name: "prompt", Required: true, BodyPath: "prompt"} + before := *prompt + var parsed string + command := &cli.Command{Name: "generate", Flags: []cli.Flag{prompt}, + Metadata: map[string]any{"help-content": Content{InputNote: "Choose a prompt interactively or supply it through flags or piped JSON/YAML."}}, + Action: func(_ context.Context, command *cli.Command) error { + parsed = command.String("prompt") + return nil + }, + } + root := &cli.Command{Name: "openai", Commands: []*cli.Command{command}} + args := []string{"openai", "generate", "--prompt", "synthetic prompt"} + normalized, help, err := Configure(root, args) + if err != nil || help || !slices.Equal(normalized, args) { + t.Fatalf("authored note changed normal routing: %q, %v, %v", normalized, help, err) + } + got := commandHelpAtWidth(command, "openai", "generate", 100) + if !strings.Contains(got, "Choose a prompt interactively") || strings.Contains(got, "Required request inputs:") { + t.Fatalf("authored input note did not replace automatic guidance: %s", got) + } + if !reflect.DeepEqual(*prompt, before) || command.Flags[0] != prompt { + t.Fatal("authored note changed a flag declaration") + } + if err := root.Run(t.Context(), normalized); err != nil { + t.Fatal(err) + } + if parsed != "synthetic prompt" { + t.Fatalf("normal parser received %q", parsed) + } +} + +func TestInheritedGlobalIndexKeepsEveryNameAndRootReference(t *testing.T) { + var out bytes.Buffer + leaf := &cli.Command{Name: "run", Flags: []cli.Flag{&cli.StringFlag{Name: "project", Usage: "Local project field."}}, + Metadata: map[string]any{"help-content": Content{GlobalOptionsNote: "This command supports text output only."}}, + } + root := &cli.Command{Name: "openai", Writer: &out, Commands: []*cli.Command{leaf}, Flags: []cli.Flag{ + &cli.StringFlag{Name: "root-option", Aliases: []string{"r"}, Usage: "Complete inherited explanation."}, + &cli.StringFlag{Name: "project", Usage: "Root project explanation."}, + &cli.BoolFlag{Name: "hidden-global", Hidden: true}, + &cli.BoolFlag{Name: "root-only", Local: true}, + }} + for i := range 12 { + root.Flags = append(root.Flags, &cli.BoolFlag{Name: fmt.Sprintf("future-%02d", i), Usage: "Keep future flags visible."}) + } + args, _, err := Configure(root, []string{"/tmp/CLI build/openai", "run", "--help"}) + if err != nil { + t.Fatal(err) + } + if err := root.Run(t.Context(), args); err != nil { + t.Fatal(err) + } + got := out.String() + _, index, found := strings.Cut(got, "\nGLOBAL OPTIONS:\n") + if !found { + t.Fatal("missing inherited flag index") + } + for _, want := range []string{"--root-option, -r", "--future-00", "--future-11", "Global option details: '/tmp/CLI build/openai' --help", "This command supports text output only."} { + if !strings.Contains(index, want) { + t.Errorf("global index lost %q: %s", want, index) + } + } + for _, absent := range []string{"Complete inherited explanation", "Root project explanation", "--project", "--hidden-global", "--root-only"} { + if strings.Contains(index, absent) { + t.Errorf("global index contains %q: %s", absent, index) + } + } + if !strings.Contains(got, "Local project field.") { + t.Fatal("local flag description was removed") + } + rootHelp := commandHelpAtWidth(root, "openai", "", 80) + if !strings.Contains(rootHelp, "Complete inherited explanation.") || !strings.Contains(rootHelp, "Root project explanation.") { + t.Fatal("root help lost full global descriptions") + } +} + func TestGoRunInvocationIsCopyableFromSourceCheckout(t *testing.T) { project := t.TempDir() t.Chdir(project) diff --git a/internal/clihelp/full.go b/internal/clihelp/full.go index a960064a..305197d0 100644 --- a/internal/clihelp/full.go +++ b/internal/clihelp/full.go @@ -17,6 +17,28 @@ type FlagGroup struct { Owner *cli.Command } +// FlagUsage supplies display-only guidance for flags declared by Owner. +type FlagUsage struct { + Owner *cli.Command + Names []string + Usage string +} + +func commandFlagUsage(command *cli.Command, flag cli.Flag, usage string) string { + configured, _ := command.Metadata["help-flag-usages"].([]FlagUsage) + for _, entry := range configured { + if entry.Owner == nil || !slices.Contains(entry.Owner.Flags, flag) { + continue + } + for _, name := range entry.Names { + if slices.Contains(flag.Names(), name) { + return entry.Usage + } + } + } + return usage +} + func fullFlagGroups(command *cli.Command, flags []cli.Flag, width int) string { groups := []FlagGroup{{Title: "Required inputs"}} if configured, ok := command.Metadata["help-flag-groups"].([]FlagGroup); ok { @@ -86,12 +108,7 @@ func flagReference(command *cli.Command, flag cli.Flag) (string, string) { if label := commandFlagValueLabel(command, flag); label != "" { heading += " " + label } - usage := fileInputUsage(flag, doc.GetUsage()) - if start := strings.IndexByte(usage, '`'); start >= 0 { - if end := strings.IndexByte(usage[start+1:], '`'); end >= 0 { - usage = usage[:start] + usage[start+1:start+1+end] + usage[start+end+2:] - } - } + usage := fileInputUsage(flag, commandFlagUsage(command, flag, doc.GetUsage())) if multi, ok := flag.(cli.DocGenerationMultiValueFlag); ok && multi.IsMultiValueFlag() && !strings.Contains(strings.ToLower(usage), "repeat") { usage += "\nCan be used more than once." @@ -128,7 +145,7 @@ func writeFlagDescription(out *strings.Builder, description string, width int) { // their boundaries without inferring values from arbitrary flag prose. if strings.HasPrefix(line, "Default:") || strings.HasPrefix(line, "Env:") { flush() - out.WriteString(wrapDescription(line, " ", width)) + out.WriteString(wrapPlainDescription(line, " ", width)) } else { prose = append(prose, line) } diff --git a/internal/clihelp/full_test.go b/internal/clihelp/full_test.go index 9b7fd3c7..5817f165 100644 --- a/internal/clihelp/full_test.go +++ b/internal/clihelp/full_test.go @@ -102,7 +102,11 @@ func TestFullHelpPreservesCategoriesGlobalOptionsAndDescriptions(t *testing.T) { t.Fatal(err) } got := out.String() - for _, want := range []string{"GLOBAL OPTIONS:", "--api-key TEXT", "OPENAI_TEST_HELP_KEY"} { + globalDetails := []string{"GLOBAL OPTIONS:", "--api-key", "Global option details: openai --help"} + if args[len(args)-1] == "--all" { + globalDetails = []string{"GLOBAL OPTIONS:", "--api-key TEXT", "OPENAI_TEST_HELP_KEY"} + } + for _, want := range globalDetails { if !strings.Contains(got, want) { t.Errorf("full help missing %q:\n%s", want, got) } @@ -110,8 +114,14 @@ func TestFullHelpPreservesCategoriesGlobalOptionsAndDescriptions(t *testing.T) { if strings.Contains(got, "fake-help-test-key") || strings.Contains(got, "Hidden configuration") { t.Fatal("hidden information appeared in full help") } + if args[len(args)-1] != "--all" && strings.Contains(got, "OPENAI_TEST_HELP_KEY") { + t.Fatal("child help repeated the global reference instead of its index") + } + if args[len(args)-1] == "images" && (!strings.Contains(got, "--limit INTEGER") || !strings.Contains(got, "Up to ten images.")) { + t.Fatal("group help lost its complete local flag description") + } if args[len(args)-1] == "generate" { - for _, want := range []string{"Image inputs", "--prompt TEXT", "Complete image reference.", "Use a description with blue and `cat`.", "All limits stay here."} { + for _, want := range []string{"Image inputs", "--prompt TEXT", "Complete image reference.", "Use a description with blue and cat.", "All limits stay here."} { if !strings.Contains(got, want) { t.Errorf("leaf help missing %q:\n%s", want, got) } @@ -170,3 +180,50 @@ func TestFullHelpSeparatesFlagEntries(t *testing.T) { t.Fatalf("flag entries lack a blank line: %q", got) } } + +func TestFlagReferenceNormalizesProseWithoutChangingValues(t *testing.T) { + flag := &cli.StringFlag{ + Name: "path", Value: "`literal` [label](url)", + Usage: "Select `fav\\.movie`, `data[0]`, or `auto`. Read [the guide](https://example.invalid).", + } + if err := flag.PreParse(); err != nil { + t.Fatal(err) + } + before := flag.String() + got := fullFlagGroups(&cli.Command{}, []cli.Flag{flag}, 100) + for _, want := range []string{`Select fav\.movie, data[0], or auto.`, "Read the guide (https://example.invalid).", "Default: \"`literal` [label](url)\""} { + if !strings.Contains(got, want) { + t.Errorf("flag reference lost %q: %s", want, got) + } + } + if flag.String() != before || flag.Value != "`literal` [label](url)" { + t.Fatal("presentation changed flag behavior or source metadata") + } +} + +func TestFlagUsageOverrideRequiresDeclaringOwner(t *testing.T) { + rootFlag := &cli.StringFlag{Name: "model", Aliases: []string{"m"}, Usage: "Root model."} + localFlag := &cli.StringFlag{Name: "model", Usage: "", Value: "declared-model"} + root := &cli.Command{Flags: []cli.Flag{rootFlag}} + leaf := &cli.Command{Flags: []cli.Flag{localFlag}} + leaf.Metadata = map[string]any{"help-flag-usages": []FlagUsage{ + {Owner: root, Names: []string{"m"}, Usage: "Root `model` guidance."}, + {Owner: leaf, Names: []string{"model"}, Usage: "ID of the `model` to retrieve."}, + }} + for _, flag := range []cli.Flag{rootFlag, localFlag} { + if err := flag.PreParse(); err != nil { + t.Fatal(err) + } + } + got := fullFlagGroups(leaf, []cli.Flag{localFlag}, 80) + if !strings.Contains(got, "ID of the model to retrieve.") || strings.Contains(got, "Root model guidance") { + t.Fatalf("local usage inherited another owner's guidance: %s", got) + } + if localFlag.Usage != "" || localFlag.Value != "declared-model" || rootFlag.Usage != "Root model." { + t.Fatal("usage metadata changed a flag declaration") + } + delete(leaf.Metadata, "help-flag-usages") + if got := fullFlagGroups(leaf, []cli.Flag{localFlag}, 80); strings.Contains(got, "ID of the model") { + t.Fatalf("usage override persisted after its metadata was removed: %s", got) + } +} diff --git a/internal/clihelp/synopsis.go b/internal/clihelp/synopsis.go new file mode 100644 index 00000000..a3b60cf7 --- /dev/null +++ b/internal/clihelp/synopsis.go @@ -0,0 +1,151 @@ +package clihelp + +import ( + "slices" + "strings" + + "github.com/charmbracelet/x/ansi" + "github.com/urfave/cli/v3" +) + +// Synopses use declarations, never parsed values. Handwritten commands can keep +// an explicit UsageText. Verified positional aliases use help-positional-flags. +func commandSynopsis(command *cli.Command, name, invocation string, width int) string { + if command.UsageText != "" { + var lines []string + for _, line := range strings.Split(command.UsageText, "\n") { + if suffix, found := strings.CutPrefix(line, "openai "); found { + line = invocation + " " + suffix + } + lines = append(lines, " "+line) + } + return strings.Join(lines, "\n") + "\n" + } + var arguments []string + flags := command.VisibleFlags() + if name == invocation { + if len(flags) > 0 { + arguments = append(arguments, "[GLOBAL OPTIONS]") + } + } else { + positionals := positionalHelpFlags(command, flags) + for _, flag := range positionals { + label := commandFlagValueLabel(command, flag) + arguments = append(arguments, "["+label+" | "+synopsisFlag(command, flag)+"]") + } + if command.ArgsUsage != "" { + arguments = append(arguments, command.ArgsUsage) + } + for _, flag := range flags { + if slices.Contains(positionals, flag) || len(flag.Names()) == 0 { + continue + } + if inner, ok := flag.(interface{ GetOuterFlag() cli.Flag }); ok && slices.Contains(flags, inner.GetOuterFlag()) { + // The parent value covers these nested fields in the synopsis. + // OPTIONS still documents every visible nested field separately. + continue + } + argument := synopsisFlag(command, flag) + required, _ := flag.(cli.RequiredFlag) + if required == nil || !required.IsRequired() { + argument = "[" + argument + "]" + } + if multi, ok := flag.(cli.DocGenerationMultiValueFlag); ok && multi.IsMultiValueFlag() { + argument += "..." + } + arguments = append(arguments, argument) + } + if len(command.VisiblePersistentFlags()) > 0 { + arguments = append(arguments, "[GLOBAL OPTIONS]") + } + } + if len(VisibleCommands(command)) > 0 { + arguments = append(arguments, "COMMAND") + } + var out strings.Builder + out.WriteString(" " + name) + column := 3 + ansi.StringWidth(name) + for _, argument := range arguments { + if column+1+ansi.StringWidth(argument) > width { + out.WriteString("\n " + argument) + column = 7 + ansi.StringWidth(argument) + } else { + out.WriteString(" " + argument) + column += 1 + ansi.StringWidth(argument) + } + } + out.WriteByte('\n') + return out.String() +} + +func synopsisFlag(command *cli.Command, flag cli.Flag) string { + name := flag.Names()[0] + prefix := "--" + if len(name) == 1 { + prefix = "-" + } + if label := commandFlagValueLabel(command, flag); label != "" { + name += " " + label + } + return prefix + name +} + +func positionalHelpFlags(command *cli.Command, flags []cli.Flag) []cli.Flag { + names, _ := command.Metadata["help-positional-flags"].([]string) + var positionals []cli.Flag + for _, name := range names { + for _, flag := range flags { + path, ok := flag.(interface{ GetPathParam() string }) + if ok && path.GetPathParam() != "" && len(flag.Names()) > 0 && flag.Names()[0] == name && + slices.Contains(command.Flags, flag) && !slices.Contains(positionals, flag) { + positionals = append(positionals, flag) + } + } + } + return positionals +} + +func synopsisInputNote(command *cli.Command) string { + var names, keys []string + for _, flag := range command.VisibleFlags() { + required, ok := flag.(interface{ IsRequiredAsFlagOrStdin() bool }) + if !ok || !required.IsRequiredAsFlagOrStdin() || len(flag.Names()) == 0 { + continue + } + input, ok := flag.(interface { + GetBodyPath() string + GetQueryPath() string + GetHeaderPath() string + GetPathParam() string + IsBodyRoot() bool + }) + if !ok { + continue + } + var key string + for _, candidate := range []string{input.GetBodyPath(), input.GetPathParam(), input.GetQueryPath(), input.GetHeaderPath()} { + if candidate != "" { + key = candidate + break + } + } + if key == "" && !input.IsBodyRoot() { + continue + } + names = append(names, "--"+flag.Names()[0]) + if key != "" && !slices.Contains(keys, key) { + keys = append(keys, key) + } + } + if len(names) == 0 { + return "" + } + note := "Required request inputs: " + strings.Join(names, ", ") + ". Supply them through flags or piped JSON/YAML." + if len(positionalHelpFlags(command, command.VisibleFlags())) > 0 { + note += " The shown positional arguments can replace their matching flags." + } + if len(keys) > 0 { + note += " JSON/YAML keys: " + strings.Join(keys, ", ") + "." + } + return note +} diff --git a/internal/clihelp/synopsis_test.go b/internal/clihelp/synopsis_test.go new file mode 100644 index 00000000..08b5ca48 --- /dev/null +++ b/internal/clihelp/synopsis_test.go @@ -0,0 +1,96 @@ +package clihelp + +import ( + "strings" + "testing" + + "github.com/openai/openai-cli/internal/requestflag" + "github.com/urfave/cli/v3" +) + +func TestSynopsisShowsRequiredInputsAndStdinKeys(t *testing.T) { + command := &cli.Command{Name: "create", Flags: []cli.Flag{ + &requestflag.Flag[string]{Name: "file", Required: true, FileInput: true, BodyPath: "file"}, + &requestflag.Flag[string]{Name: "file-purpose", Required: true, BodyPath: "purpose"}, + &cli.StringFlag{Name: "required-cli", Required: true}, + &cli.IntFlag{Name: "limit"}, + &cli.BoolFlag{Name: "hidden", Hidden: true}, + }} + got := commandSynopsis(command, "openai files create", "openai", 64) + for _, want := range []string{"[--file PATH]", "[--file-purpose TEXT]", "--required-cli TEXT", "[--limit INTEGER]"} { + if !strings.Contains(got, want) { + t.Errorf("synopsis lost %q: %s", want, got) + } + } + if strings.Contains(got, "[--required-cli") || strings.Contains(got, "hidden") { + t.Fatalf("required or hidden CLI input changed: %s", got) + } + note := synopsisInputNote(command) + for _, want := range []string{"Required request inputs: --file, --file-purpose.", "flags or piped JSON/YAML", "JSON/YAML keys: file, purpose."} { + if !strings.Contains(note, want) { + t.Errorf("required input note lost %q: %s", want, note) + } + } +} + +func TestSynopsisUsesOnlyVerifiedPositionalAliases(t *testing.T) { + model := &requestflag.Flag[string]{Name: "model", PathParam: "model", Required: true} + other := &requestflag.Flag[string]{Name: "other", PathParam: "other_id", Required: true} + command := &cli.Command{Name: "retrieve", Flags: []cli.Flag{model, other, + &cli.StringFlag{Name: "format"}, + &requestflag.Flag[string]{Name: "hidden", PathParam: "hidden", Hidden: true}, + }, Metadata: map[string]any{"help-positional-flags": []string{"model", "format", "unknown", "hidden", "model"}}} + got := commandSynopsis(command, "openai models retrieve", "openai", 100) + if strings.Count(got, "[MODEL | --model MODEL]") != 1 || !strings.Contains(got, "[--other TEXT]") { + t.Fatalf("positional metadata did not match visible path flags: %s", got) + } + for _, absent := range []string{"FORMAT |", "unknown", "hidden", "TEXT | --other"} { + if strings.Contains(got, absent) { + t.Fatalf("invented positional argument %q: %s", absent, got) + } + } + if !strings.Contains(synopsisInputNote(command), "positional arguments can replace their matching flags") { + t.Fatal("positional source was not explained") + } +} + +func TestSynopsisOmitsNestedFieldsOnlyWithVisibleParent(t *testing.T) { + outer := &requestflag.Flag[map[string]any]{Name: "settings"} + inner := &visibleSynopsisInnerFlag{InnerFlag: &requestflag.InnerFlag[string]{Name: "settings.mode", OuterFlag: outer}} + command := &cli.Command{Name: "create", Flags: []cli.Flag{outer, inner}} + got := commandSynopsis(command, "openai items create", "openai", 80) + if !strings.Contains(got, "[--settings TEXT=VALUE]") || strings.Contains(got, "settings.mode") { + t.Fatalf("nested synopsis is redundant: %s", got) + } + help := commandHelpAtWidth(command, "openai", "items create", 80) + if !strings.Contains(help, "--settings.mode TEXT") { + t.Fatalf("nested option disappeared from the full reference: %s", help) + } + outer.Hidden = true + got = commandSynopsis(command, "openai items create", "openai", 80) + if !strings.Contains(got, "[--settings.mode TEXT]") { + t.Fatalf("nested flag lacks a visible parent and was omitted: %s", got) + } +} + +type visibleSynopsisInnerFlag struct { + *requestflag.InnerFlag[string] +} + +func (*visibleSynopsisInnerFlag) IsVisible() bool { return true } + +func TestSynopsisPreservesAuthoredUsageAndSafeInvocation(t *testing.T) { + invocation := "'/tmp/CLI build/openai'" + command := &cli.Command{UsageText: "openai images preview [--inline auto|on] FILE\nAn openai token inside prose stays unchanged."} + got := commandSynopsis(command, invocation+" images preview", invocation, 40) + want := " " + invocation + " images preview [--inline auto|on] FILE\n An openai token inside prose stays unchanged.\n" + if got != want { + t.Fatalf("authored usage changed: %q", got) + } + command.UsageText = "" + command.Flags = []cli.Flag{&cli.StringFlag{Name: "first"}, &cli.StringFlag{Name: "second"}} + got = commandSynopsis(command, invocation+" items create", invocation, 40) + if !strings.Contains(got, invocation) || !strings.Contains(got, "\n [--first TEXT]") || !strings.Contains(got, "[--second TEXT]") { + t.Fatalf("synopsis split the invocation or failed to wrap options: %s", got) + } +} diff --git a/internal/clihelp/terminal_prose.go b/internal/clihelp/terminal_prose.go new file mode 100644 index 00000000..00d6914a --- /dev/null +++ b/internal/clihelp/terminal_prose.go @@ -0,0 +1,148 @@ +package clihelp + +import "strings" + +// terminalProse normalizes authored help, never request values or runtime data. +// Keep code blocks and commands literal; preserve malformed or escaped markup. +func terminalProse(text string) string { + lines := reflowDescription(text) + var fence descriptionFence + for i, line := range lines { + if !fence.contains(line) && !isCodeLine(line) { + lines[i] = terminalInlineProse(line) + } + } + return strings.Join(lines, "\n") +} + +func terminalInlineProse(text string) string { + var out strings.Builder + for i := 0; i < len(text); { + switch text[i] { + case '\\': + end := min(i+2, len(text)) + out.WriteString(text[i:end]) + i = end + case '`': + start, end, next := inlineCode(text, i) + if next < 0 { + // An unfinished span remains literal, including the remaining text. + out.WriteString(text[i:]) + return out.String() + } + out.WriteString(text[start:end]) + i = next + case '[': + labelEnd := inlineClosing(text, i, '[', ']') + if labelEnd < 0 { + out.WriteString(text[i:]) + return out.String() + } + next := labelEnd + 1 + if next >= len(text) || text[next] != '(' || i > 0 && text[i-1] == '!' { + out.WriteString(text[i:next]) + i = next + continue + } + destinationEnd := inlineClosing(text, next, '(', ')') + if destinationEnd < 0 { + out.WriteString(text[i:]) + return out.String() + } + label, destination := text[i+1:labelEnd], text[next+1:destinationEnd] + if strings.HasPrefix(destination, "<") && strings.HasSuffix(destination, ">") { + destination = destination[1 : len(destination)-1] + } + out.WriteString(terminalInlineProse(label)) + out.WriteString(" (" + destination + ")") + i = destinationEnd + 1 + default: + out.WriteByte(text[i]) + i++ + } + } + return out.String() +} + +// Backtick runs must match exactly. Backslashes inside code remain literal. +func inlineCode(text string, start int) (int, int, int) { + length := 1 + for start+length < len(text) && text[start+length] == '`' { + length++ + } + for cursor := start + length; cursor < len(text); { + relative := strings.IndexByte(text[cursor:], '`') + if relative < 0 { + break + } + cursor += relative + end := cursor + 1 + for end < len(text) && text[end] == '`' { + end++ + } + if end-cursor == length { + return start + length, cursor, end + } + cursor = end + } + return 0, 0, -1 +} + +// Balanced delimiters retain nested URL parentheses and escaped field paths. +func inlineClosing(text string, start int, open, close byte) int { + depth, angle := 1, false + for i := start + 1; i < len(text); i++ { + if text[i] == '\\' { + i++ + continue + } + if open == '(' && text[i] == '<' { + angle = true + } else if angle && text[i] == '>' { + angle = false + } else if !angle { + if open == '[' && text[i] == '`' { + if _, _, next := inlineCode(text, i); next >= 0 { + i = next - 1 + continue + } + } + if text[i] == open { + depth++ + } else if text[i] == close { + depth-- + if depth == 0 { + return i + } + } + } + } + return -1 +} + +// Fence markers must match; nested backticks cannot close a tilde code block. +type descriptionFence struct { + marker byte + length int +} + +func (f *descriptionFence) contains(line string) bool { + inside := f.length != 0 + trimmed := strings.TrimLeft(line, " ") + if len(line)-len(trimmed) > 3 || len(trimmed) < 3 || trimmed[0] != '`' && trimmed[0] != '~' { + return inside + } + length := 1 + for length < len(trimmed) && trimmed[length] == trimmed[0] { + length++ + } + if inside { + if trimmed[0] == f.marker && length >= f.length && strings.TrimSpace(trimmed[length:]) == "" { + f.length = 0 + } + } else if length >= 3 && (trimmed[0] != '`' || !strings.Contains(trimmed[length:], "`")) { + f.marker, f.length = trimmed[0], length + return true + } + return inside +} diff --git a/internal/clihelp/terminal_prose_test.go b/internal/clihelp/terminal_prose_test.go new file mode 100644 index 00000000..f96cf3fc --- /dev/null +++ b/internal/clihelp/terminal_prose_test.go @@ -0,0 +1,75 @@ +package clihelp + +import ( + "strings" + "testing" +) + +func TestTerminalProseNormalizesCodeAndLinks(t *testing.T) { + for _, tc := range []struct{ name, source, want string }{ + {"every code span", "Choose `transparent`, `opaque`, or `auto`.", "Choose transparent, opaque, or auto."}, + {"reference", "Read [the guide](https://example.invalid/guide#inputs).", "Read the guide (https://example.invalid/guide#inputs)."}, + {"source wrapping", "Read [the\nguide](https://example.invalid/guide).", "Read the guide (https://example.invalid/guide)."}, + {"balanced URL", "See [a guide](https://example.invalid/a_(b)?q=x%20y#c).", "See a guide (https://example.invalid/a_(b)?q=x%20y#c)."}, + {"angle URL", "See [guide]().", "See guide (https://example.invalid/a(b)."}, + {"escaped URL", `See [guide](https://example.invalid/a\(b\)).`, `See guide (https://example.invalid/a\(b\)).`}, + {"link title", `See [guide](https://example.invalid "The guide").`, `See guide (https://example.invalid "The guide").`}, + {"code label", "Select [`data[0]`](https://example.invalid/fields).", "Select data[0] (https://example.invalid/fields)."}, + {"literal brackets", "Select data[0] and `data.#(id==\"x\")#`.", "Select data[0] and data.#(id==\"x\")#."}, + {"field escapes", "Select `fav\\.movie` or `path\\*`.", `Select fav\.movie or path\*.`}, + {"code spaces", "Keep `two spaces` and ``literal `tick` text``.", "Keep two spaces and literal `tick` text."}, + {"literal link in code", "Keep `[label](https://example.invalid)` literal.", "Keep [label](https://example.invalid) literal."}, + {"escaped markup", "Keep \\`literal\\` and \\[label](https://example.invalid).", "Keep \\`literal\\` and \\[label](https://example.invalid)."}, + {"mismatched ticks", "Keep ``unclosed `code` literal.", "Keep ``unclosed `code` literal."}, + {"unclosed code", "Keep `unclosed [guide](url).", "Keep `unclosed [guide](url)."}, + {"unclosed label", "Keep [unclosed `code` literal.", "Keep [unclosed `code` literal."}, + {"unclosed destination", "Keep [guide](https://example.invalid/(path).", "Keep [guide](https://example.invalid/(path)."}, + {"image syntax", "Keep ![literal](example.png).", "Keep ![literal](example.png)."}, + {"reference syntax", "Keep [label][reference].", "Keep [label][reference]."}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := terminalProse(tc.source); got != tc.want { + t.Fatalf("terminal prose = %q; want %q", got, tc.want) + } + }) + } +} + +func TestTerminalProsePreservesBlocksCommandsAndLists(t *testing.T) { + source := "Use `auto`.\n\n- Keep `first` and `second`.\n- Read [the guide](https://example.invalid).\n\n" + + "~~~sh\nopenai responses create --input '`raw` [literal](url)'\n```\n[still literal](url)\n~~~\n" + + "Afterwards, use `text`.\n\n" + + "````text\n```\n[literal](url)\n````\n" + + " literal `tick` [label](url)\n\tother `tick` [label](url)\n" + + "openai models list --transform 'data.#(name==\"`raw`\")'\n" + got := wrapDescription(source, " ", 40) + for _, want := range []string{ + "Use auto.", "- Keep first and second.", "- Read the guide", "https://example.invalid", + "~~~sh\n openai responses create --input '`raw` [literal](url)'\n ```\n [still literal](url)\n ~~~", + "Afterwards, use text.", "````text\n ```\n [literal](url)\n ````", + " literal `tick` [label](url)", "\tother `tick` [label](url)", + "openai models list --transform 'data.#(name==\"`raw`\")'", + } { + if !strings.Contains(got, want) { + t.Errorf("wrapped help lost %q:\n%s", want, got) + } + } +} + +func TestTerminalProseDoesNotNormalizeLiteralCodeTwice(t *testing.T) { + source := "Keep ``literal `ticks` and [label](url)`` unchanged." + want := "Keep literal `ticks` and [label](url) unchanged." + for _, got := range []string{wrapDescription(source, "", 100), wrapPlainDescription(terminalProse(source), "", 100)} { + if got != want+"\n" { + t.Fatalf("literal inline code changed during wrapping: %q", got) + } + } +} + +func TestTerminalProseKeepsMalformedRuns(t *testing.T) { + for _, source := range []string{strings.Repeat("[", 10000), "`" + strings.Repeat("[malformed](", 1000)} { + if got := terminalProse(source); got != source { + t.Fatal("malformed source changed") + } + } +} diff --git a/internal/clihelp/wrap_descriptions.go b/internal/clihelp/wrap_descriptions.go index 90b9ae92..62acb1fe 100644 --- a/internal/clihelp/wrap_descriptions.go +++ b/internal/clihelp/wrap_descriptions.go @@ -26,19 +26,19 @@ func helpWidth(command *cli.Command) int { // Wrap prose without splitting words, paths, flags or copyable commands. func wrapDescription(text, indent string, width int) string { + return wrapPlainDescription(terminalProse(text), indent, width) +} + +// Already normalized prose must not reinterpret literal markup from code spans. +func wrapPlainDescription(text, indent string, width int) string { if text == "" { return "" } var out strings.Builder - fenced := false + var fence descriptionFence for _, line := range reflowDescription(text) { - trimmed := strings.TrimSpace(line) - fence := strings.HasPrefix(trimmed, "```") - if fenced || fence || isCodeLine(line) { + if fence.contains(line) || isCodeLine(line) { out.WriteString(indent + line + "\n") - if fence { - fenced = !fenced - } continue } out.WriteString(indent) @@ -80,17 +80,13 @@ func reflowDescription(text string) []string { paragraph = "" } } - fenced := false + var fence descriptionFence for _, line := range strings.Split(text, "\n") { trimmed := strings.TrimSpace(line) - fence := strings.HasPrefix(trimmed, "```") - code := fenced || fence || isCodeLine(line) + code := fence.contains(line) || isCodeLine(line) if trimmed == "" || code || strings.HasSuffix(trimmed, ":") { flush() lines = append(lines, line) - if fence { - fenced = !fenced - } continue } if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") || strings.HasPrefix(trimmed, "• ") { diff --git a/pkg/custom/command.go b/pkg/custom/command.go index 8b049498..b7fee302 100644 --- a/pkg/custom/command.go +++ b/pkg/custom/command.go @@ -18,6 +18,7 @@ func ConfigureCommand(root *cli.Command) { } root.Metadata[configuredCommandMetadata] = true configureRootRequestFlags(root) + describeRequestInputs(root) registerImageModels(root) configureReadableOutput(root) configureImageSaving(root) diff --git a/pkg/custom/describe_request_inputs.go b/pkg/custom/describe_request_inputs.go new file mode 100644 index 00000000..ac73d76d --- /dev/null +++ b/pkg/custom/describe_request_inputs.go @@ -0,0 +1,60 @@ +package custom + +import ( + "strings" + + "github.com/openai/openai-cli/internal/clihelp" + "github.com/urfave/cli/v3" +) + +// Annotate the generated handlers before handwritten commands and aliases are +// installed. Generated handlers consume URL path inputs in declaration order; +// a handwritten flag's URL location alone does not establish positional syntax. +func describeRequestInputs(root *cli.Command) { + for _, resource := range root.Commands { + if resource.Category != "API RESOURCE" { + continue + } + for _, command := range resource.Commands { + if command.Action == nil { + continue + } + var positional []string + var descriptions []clihelp.FlagUsage + for _, flag := range command.Flags { + path, ok := flag.(interface{ GetPathParam() string }) + if !ok || path.GetPathParam() == "" || len(flag.Names()) == 0 { + continue + } + name := flag.Names()[0] + positional = append(positional, name) + if doc, ok := flag.(cli.DocGenerationFlag); ok && strings.TrimSpace(doc.GetUsage()) == "" { + descriptions = append(descriptions, clihelp.FlagUsage{ + Owner: command, Names: []string{name}, Usage: pathInputDescription(path.GetPathParam()), + }) + } + } + if len(positional) == 0 { + continue + } + if command.Metadata == nil { + command.Metadata = map[string]any{} + } + command.Metadata["help-positional-flags"] = positional + command.Metadata["help-flag-usages"] = descriptions + } + } +} + +func pathInputDescription(parameter string) string { + switch parameter { + case "id": + return "Resource ID." + case "model": + return "Model ID. Use models list to find available model IDs." + case "version": + return "Version identifier." + default: + return "ID of the " + strings.ReplaceAll(strings.TrimSuffix(parameter, "_id"), "_", " ") + "." + } +} diff --git a/pkg/custom/describe_request_inputs_test.go b/pkg/custom/describe_request_inputs_test.go new file mode 100644 index 00000000..5607b47d --- /dev/null +++ b/pkg/custom/describe_request_inputs_test.go @@ -0,0 +1,97 @@ +package custom + +import ( + "bytes" + "context" + "reflect" + "strings" + "testing" + + "github.com/openai/openai-cli/internal/clihelp" + "github.com/openai/openai-cli/internal/requestflag" + "github.com/urfave/cli/v3" +) + +func TestDescribeRequestInputsPreservesDeclarations(t *testing.T) { + model := &requestflag.Flag[string]{Name: "model", Required: true, PathParam: "model"} + project := &requestflag.Flag[string]{Name: "project-id", PathParam: "project_id", Usage: "Keep this authored constraint."} + beforeModel, beforeProject := *model, *project + action := func(context.Context, *cli.Command) error { return nil } + operation := &cli.Command{Name: "retrieve", Flags: []cli.Flag{project, model}, Action: action} + handwritten := &cli.Command{Name: "local", Flags: []cli.Flag{model}, Action: action} + root := &cli.Command{Name: "openai", Commands: []*cli.Command{ + {Name: "models", Category: "API RESOURCE", Commands: []*cli.Command{operation}}, handwritten, + }} + describeRequestInputs(root) + describeRequestInputs(root) + if !reflect.DeepEqual(*model, beforeModel) || !reflect.DeepEqual(*project, beforeProject) { + t.Fatal("presentation changed a request flag") + } + if got := operation.Metadata["help-positional-flags"]; !reflect.DeepEqual(got, []string{"project-id", "model"}) { + t.Fatalf("positional declaration order changed: %v", got) + } + usage := operation.Metadata["help-flag-usages"].([]clihelp.FlagUsage) + if len(usage) != 1 || usage[0].Owner != operation || usage[0].Usage != "Model ID. Use models list to find available model IDs." { + t.Fatalf("authored or missing descriptions changed incorrectly: %+v", usage) + } + if handwritten.Metadata["help-positional-flags"] != nil { + t.Fatal("URL metadata alone invented a handwritten positional argument") + } +} + +func TestDescribeRequestInputsSurvivesResourceCloning(t *testing.T) { + for _, path := range [][]string{{"admin:projects", "retrieve"}, {"admin", "projects", "retrieve"}} { + var out bytes.Buffer + flag := &requestflag.Flag[string]{Name: "project-id", Required: true, PathParam: "project_id"} + operation := &cli.Command{Name: "retrieve", Flags: []cli.Flag{flag}, Action: func(context.Context, *cli.Command) error { + t.Fatal("help ran the request action") + return nil + }} + root := &cli.Command{Name: "openai", Writer: &out, Commands: []*cli.Command{ + {Name: "admin:projects", Category: "API RESOURCE", Commands: []*cli.Command{operation}}, + }} + describeRequestInputs(root) + configureCommandSubgroups(root) + args := append(append([]string{"openai"}, path...), "--help") + args, _, err := ConfigureHelp(root, args) + if err != nil { + t.Fatal(err) + } + if err := root.Run(t.Context(), args); err != nil { + t.Fatal(err) + } + for _, want := range []string{"[PROJECT_ID | --project-id PROJECT_ID]", "ID of the project.", "JSON/YAML keys: project_id."} { + if !strings.Contains(strings.Join(strings.Fields(out.String()), " "), want) { + t.Errorf("route %q lost %q: %s", path, want, out.String()) + } + } + } +} + +func TestConfigureHelpImageLabelsAreOwnerScoped(t *testing.T) { + imageSize := &requestflag.Flag[string]{Name: "size"} + imageQuality := &requestflag.Flag[string]{Name: "quality"} + otherSize := &requestflag.Flag[string]{Name: "size"} + image := &cli.Command{Name: "generate", Flags: []cli.Flag{imageSize, imageQuality}} + other := &cli.Command{Name: "other", Flags: []cli.Flag{otherSize}} + root := &cli.Command{Name: "openai", Commands: []*cli.Command{{Name: "images", Commands: []*cli.Command{image}}, other}} + configureHelpGroups(root) + configureHelpGroups(root) + labels := image.Metadata["help-flag-labels"].([]clihelp.FlagLabel) + for name, want := range map[string]string{"size": "SIZE", "quality": "QUALITY"} { + count := 0 + for _, label := range labels { + if label.Owner == image && reflect.DeepEqual(label.Names, []string{name}) && label.Label == want { + count++ + } + } + if count != 1 { + t.Errorf("image label %s appears %d times", name, count) + } + } + for _, label := range other.Metadata["help-flag-labels"].([]clihelp.FlagLabel) { + if label.Owner == other { + t.Fatal("image label leaked to an unrelated command") + } + } +} diff --git a/pkg/custom/help.go b/pkg/custom/help.go index b027fe50..f67eef1e 100644 --- a/pkg/custom/help.go +++ b/pkg/custom/help.go @@ -1,6 +1,8 @@ package custom import ( + "strings" + "github.com/openai/openai-cli/internal/clihelp" "github.com/openai/openai-cli/internal/requestflag" "github.com/urfave/cli/v3" @@ -110,6 +112,8 @@ func configureImageHelpContent(root *cli.Command) { "It cannot use --transform or --raw-output." for name, content := range map[string]clihelp.Content{ "generate": { + InputNote: "Supply the prompt with --prompt or piped JSON/YAML (key: prompt).\n" + + "Run without flags to enter it in the interactive picker.", Description: "Run without flags to choose settings in an interactive terminal.\n" + "After saving, the picker reopens with your settings. Ctrl+C exits.\n\n" + "Saves to ~/Downloads/gpt-images/. Use --output-dir to choose an existing folder.\n" + @@ -134,8 +138,9 @@ func configureImageHelpContent(root *cli.Command) { "preview": { Description: imagePreviewDetails + "\nReplace photo.png with your saved image's path.\n" + "Pipes, CI and terminals without supported graphics or color cannot display previews.\n" + - "Preview limits: 64 MiB and 16 megapixels. Larger originals are still kept.\n\n" + localOutput, - Examples: []clihelp.Example{{Description: "View a saved image:", Command: `images preview "photo.png"`}}, + "Preview limits: 64 MiB and 16 megapixels. Larger originals are still kept.", + Examples: []clihelp.Example{{Description: "View a saved image:", Command: `images preview "photo.png"`}}, + GlobalOptionsNote: localOutput, }, "models": { Description: "Shows exact model names and checks metadata visibility with your key.\n" + @@ -158,14 +163,16 @@ func configureImageHelpContent(root *cli.Command) { } if inline := images.Command("inline"); inline != nil { setCompleteHelpContent(inline, clihelp.Content{ - Description: inline.Description + "\n" + localOutput, - Examples: []clihelp.Example{{Description: "Turn on automatic image previews:", Command: "images inline on"}}, + Description: inline.Description, + Examples: []clihelp.Example{{Description: "Turn on automatic image previews:", Command: "images inline on"}}, + GlobalOptionsNote: localOutput, }) for _, name := range []string{"on", "off"} { if command := inline.Command(name); command != nil { setCompleteHelpContent(command, clihelp.Content{ - Description: command.Description + "\n\n" + localOutput, - Examples: []clihelp.Example{{Description: "Remember automatic image previews " + name + ":", Command: "images inline " + name}}, + Description: command.Description, + Examples: []clihelp.Example{{Description: "Remember automatic image previews " + name + ":", Command: "images inline " + name}}, + GlobalOptionsNote: localOutput, }) } } @@ -212,7 +219,25 @@ func configureHelpGroups(root *cli.Command) { }, sections...) } command.Metadata["help-flag-groups"] = sections - command.Metadata["help-flag-labels"] = labels + commandLabels := append([]clihelp.FlagLabel(nil), labels...) + if positional, ok := command.Metadata["help-positional-flags"].([]string); ok { + for _, name := range positional { + commandLabels = append(commandLabels, clihelp.FlagLabel{ + Owner: command, Names: []string{name}, Label: strings.ToUpper(strings.ReplaceAll(name, "-", "_")), + }) + } + } + if image { + for _, label := range []struct{ name, value string }{ + {"size", "SIZE"}, {"quality", "QUALITY"}, {"background", "BACKGROUND"}, + {"moderation", "LEVEL"}, {"input-fidelity", "FIDELITY"}, + {"output-format", "FORMAT"}, {"response-format", "FORMAT"}, + {"name", "NAME"}, {"inline", "MODE"}, + } { + commandLabels = append(commandLabels, clihelp.FlagLabel{Owner: command, Names: []string{label.name}, Label: label.value}) + } + } + command.Metadata["help-flag-labels"] = commandLabels for _, flag := range command.Flags { if limit, ok := flag.(*requestflag.Flag[int64]); ok && limit.Name == "max-items" && limit.Usage == "The maximum number of items to return (use -1 for unlimited)." { diff --git a/pkg/custom/help_test.go b/pkg/custom/help_test.go index 576486e0..304182be 100644 --- a/pkg/custom/help_test.go +++ b/pkg/custom/help_test.go @@ -180,7 +180,7 @@ func TestConfigureHelpPreservesImageGuidance(t *testing.T) { } content := command.Metadata["help-content"].(clihelp.Content) for _, want := range wantGuidance[command.Name] { - if !strings.Contains(content.Description, want) { + if !strings.Contains(content.Description+"\n"+content.GlobalOptionsNote, want) { t.Errorf("%s help lost %q: %s", command.Name, want, content.Description) } } @@ -192,7 +192,7 @@ func TestConfigureHelpPreservesImageGuidance(t *testing.T) { command := images.Command("inline").Command(name) content := command.Metadata["help-content"].(clihelp.Content) for _, want := range []string{"future image generation", "No API request or key", "overrides it", "preview ignores", "local Apple Terminal", "--format auto or text only", "cannot use --transform or --raw-output"} { - if !strings.Contains(content.Description, want) { + if !strings.Contains(content.Description+"\n"+content.GlobalOptionsNote, want) { t.Errorf("inline %s help lost %q: %s", name, want, content.Description) } } diff --git a/scripts/demos/record-flag-descriptions.sh b/scripts/demos/record-flag-descriptions.sh index 719e1caa..b5e2128b 100755 --- a/scripts/demos/record-flag-descriptions.sh +++ b/scripts/demos/record-flag-descriptions.sh @@ -101,11 +101,13 @@ with (directory / "metadata.txt").open("a") as stream: stream.write(f"{scene}: {len(rows)} complete terminal lines; excerpt shows {min(len(rows), 70)}\n") PYTHON -for demo_topic in root images-generate; do +for demo_topic in root images-generate responses-create models-retrieve; do demo_expected_status=0 case "$demo_topic" in root) demo_args=(--help);; images-generate) demo_args=(images generate --help);; + responses-create) demo_args=(responses create --help);; + models-retrieve) demo_args=(models retrieve --help);; esac { cat <<'SCENE' From e27b66106d35a8829d2b8da04c74991eaca3a71a Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Thu, 8 Oct 2026 00:26:44 -0700 Subject: [PATCH 09/44] Render help emphasis without altering literal arguments --- cmd/openai/gcloud_help_test.go | 5 + internal/clihelp/terminal_prose.go | 137 +++++++++++++++++++++++- internal/clihelp/terminal_prose_test.go | 45 ++++++++ 3 files changed, 186 insertions(+), 1 deletion(-) diff --git a/cmd/openai/gcloud_help_test.go b/cmd/openai/gcloud_help_test.go index f1c2e3ac..dd3a5c2b 100644 --- a/cmd/openai/gcloud_help_test.go +++ b/cmd/openai/gcloud_help_test.go @@ -37,6 +37,11 @@ func TestMainGcloudHelpPreservesDetailsAfterShortName(t *testing.T) { if strings.Contains(description, "](") { t.Errorf("DESCRIPTION retained unrendered Markdown: %s", description) } + for _, label := range []string{"Built-in tools", "MCP Tools", "Function calls (custom tools)"} { + if !strings.Contains(got.stdout, label) || strings.Contains(got.stdout, "**"+label+"**") { + t.Errorf("tool guidance lost its text or retained emphasis markers: %s", label) + } + } } func TestMainGcloudHelpConcreteSyntaxAndLabels(t *testing.T) { diff --git a/internal/clihelp/terminal_prose.go b/internal/clihelp/terminal_prose.go index 00d6914a..fb721044 100644 --- a/internal/clihelp/terminal_prose.go +++ b/internal/clihelp/terminal_prose.go @@ -1,6 +1,10 @@ package clihelp -import "strings" +import ( + "strings" + "unicode" + "unicode/utf8" +) // terminalProse normalizes authored help, never request values or runtime data. // Keep code blocks and commands literal; preserve malformed or escaped markup. @@ -23,6 +27,14 @@ func terminalInlineProse(text string) string { end := min(i+2, len(text)) out.WriteString(text[i:end]) i = end + case '\'', '"': + next := inlineQuote(text, i) + if next < 0 { + out.WriteString(text[i:]) + return out.String() + } + out.WriteString(text[i:next]) + i = next case '`': start, end, next := inlineCode(text, i) if next < 0 { @@ -32,6 +44,18 @@ func terminalInlineProse(text string) string { } out.WriteString(text[start:end]) i = next + case '*', '_': + start, end, next := inlineEmphasis(text, i) + if next < 0 { + out.WriteString(text[i:]) + return out.String() + } + if end > start { + out.WriteString(terminalInlineProse(text[start:end])) + } else { + out.WriteString(text[i:next]) + } + i = next case '[': labelEnd := inlineClosing(text, i, '[', ']') if labelEnd < 0 { @@ -64,6 +88,117 @@ func terminalInlineProse(text string) string { return out.String() } +// Only prose-boundary spans are emphasis. Ambiguous path and wildcard tokens +// stay literal, including single-word *wildcards* and names such as _field_. +func inlineEmphasis(text string, start int) (int, int, int) { + marker, length := text[start], 1 + for start+length < len(text) && text[start+length] == marker { + length++ + } + begin := start + length + if length > 3 || begin == len(text) || !emphasisBoundary(text, start, true) { + return 0, 0, begin + } + first, _ := utf8.DecodeRuneInString(text[begin:]) + if !unicode.IsLetter(first) && !unicode.IsNumber(first) && first != '`' && first != '[' { + return 0, 0, begin + } + if emphasisURLToken(text, start) { + return 0, 0, begin + } + for cursor := begin; cursor < len(text); cursor++ { + if text[cursor] == '\\' { + cursor++ + continue + } + if text[cursor] == '`' { + if _, _, next := inlineCode(text, cursor); next >= 0 { + cursor = next - 1 + continue + } + return 0, 0, -1 + } + if text[cursor] == '\'' || text[cursor] == '"' { + if next := inlineQuote(text, cursor); next >= 0 { + cursor = next - 1 + continue + } + return 0, 0, -1 + } + if text[cursor] == '[' { + end := inlineClosing(text, cursor, '[', ']') + if end >= 0 && end+1 < len(text) && text[end+1] == '(' { + if end = inlineClosing(text, end+1, '(', ')'); end >= 0 { + cursor = end + continue + } + } + } + if text[cursor] != marker { + continue + } + end := cursor + 1 + for end < len(text) && text[end] == marker { + end++ + } + last, _ := utf8.DecodeLastRuneInString(text[begin:cursor]) + if end-cursor == length && !unicode.IsSpace(last) && emphasisBoundary(text, end, false) && !emphasisURLToken(text, cursor) { + body := text[begin:cursor] + if !strings.ContainsFunc(body, unicode.IsSpace) && (length == 1 || marker == '_' || strings.ContainsAny(body, ".\\/*?#=")) { + return 0, 0, begin + } + return begin, cursor, end + } + cursor = end - 1 + } + return 0, 0, -1 +} + +func emphasisURLToken(text string, position int) bool { + start := strings.LastIndexFunc(text[:position], unicode.IsSpace) + 1 + end := strings.IndexFunc(text[position:], unicode.IsSpace) + if end < 0 { + end = len(text) - position + } + return strings.Contains(text[start:position+end], "://") +} + +func emphasisBoundary(text string, position int, opening bool) bool { + if opening { + if position == 0 { + return true + } + before, _ := utf8.DecodeLastRuneInString(text[:position]) + return unicode.IsSpace(before) || strings.ContainsRune("([{:", before) + } + if position == len(text) { + return true + } + after, _ := utf8.DecodeRuneInString(text[position:]) + return unicode.IsSpace(after) || strings.ContainsRune(").,;:!?]}\"'", after) +} + +// Quoted argument fragments are literal. Apostrophes after word characters are +// not opening quotes, so contractions and possessives remain ordinary prose. +func inlineQuote(text string, start int) int { + if start > 0 { + previous, _ := utf8.DecodeLastRuneInString(text[:start]) + if unicode.IsLetter(previous) || unicode.IsNumber(previous) || previous == '_' { + return start + 1 + } + } + for cursor := start + 1; cursor < len(text); cursor++ { + if text[cursor] == '\\' { + cursor++ + continue + } + if text[cursor] == text[start] && emphasisBoundary(text, cursor+1, false) { + return cursor + 1 + } + } + return -1 +} + // Backtick runs must match exactly. Backslashes inside code remain literal. func inlineCode(text string, start int) (int, int, int) { length := 1 diff --git a/internal/clihelp/terminal_prose_test.go b/internal/clihelp/terminal_prose_test.go index f96cf3fc..b32ea847 100644 --- a/internal/clihelp/terminal_prose_test.go +++ b/internal/clihelp/terminal_prose_test.go @@ -73,3 +73,48 @@ func TestTerminalProseKeepsMalformedRuns(t *testing.T) { } } } + +func TestTerminalProseNormalizesOnlyAuthoredEmphasis(t *testing.T) { + for _, tc := range []struct{ name, source, want string }{ + {"tools", "- **Built-in tools**\n- **MCP Tools**\n- **Function calls (custom tools)**", "- Built-in tools\n- MCP Tools\n- Function calls (custom tools)"}, + {"strong", "Use **bold** and **another value**.", "Use bold and another value."}, + {"italic phrase", "Use *two words*.", "Use two words."}, + {"combined", "Use ***strong emphasis***.", "Use strong emphasis."}, + {"underscore prose", "Use _two words_ and __strong words__.", "Use two words and strong words."}, + {"punctuation", "(**Warning:** use **care**.)", "(Warning: use care.)"}, + {"Unicode", "Use **照片** or **图片**.", "Use 照片 or 图片."}, + {"code in emphasis", "Use **`data.*` carefully**.", "Use data.* carefully."}, + {"literal emphasis in code", "Keep `**literal**` and `_tokens_`.", "Keep **literal** and _tokens_."}, + {"literal nested code", "Keep ``*literal* and `ticks` ``.", "Keep *literal* and `ticks` ."}, + {"emphasis in link", "Read [**the guide**](https://example.invalid/**raw**/_id_).", "Read the guide (https://example.invalid/**raw**/_id_)."}, + {"escaped markers", `Keep \**literal\** and \_field\_.`, `Keep \**literal\** and \_field\_.`}, + {"field paths", "Keep data.*.id, foo_bar, data._id_, foo__bar__, and _field_.", "Keep data.*.id, foo_bar, data._id_, foo__bar__, and _field_."}, + {"wildcards", "Keep *.json, **/*.png, *name*, foo*bar*baz, and **name.ext**.", "Keep *.json, **/*.png, *name*, foo*bar*baz, and **name.ext**."}, + {"bare URL", "See https://example.invalid/(**raw**)/_id_?q=*value*.", "See https://example.invalid/(**raw**)/_id_?q=*value*."}, + {"quoted wildcard argument", `Example: --filter '*name*'.`, `Example: --filter '*name*'.`}, + {"quoted strong argument", `Example: --input '**literal** _tokens_'.`, `Example: --input '**literal** _tokens_'.`}, + {"quoted argument phrase", `Example: --input "**literal phrase** _literal phrase_".`, `Example: --input "**literal phrase** _literal phrase_".`}, + {"GJSON wildcard", `Use data.#(name=="*demo*")#.`, `Use data.#(name=="*demo*")#.`}, + {"escaped GJSON wildcard", `Use fav\.movie.#(name=="**demo**")#.`, `Use fav\.movie.#(name=="**demo**")#.`}, + {"quoted code markup", "Keep '--input `raw` [label](url)' literal.", "Keep '--input `raw` [label](url)' literal."}, + {"contractions", "Don't change the user's **chosen value**.", "Don't change the user's chosen value."}, + {"possessive", "Keep users' **chosen values**.", "Keep users' chosen values."}, + {"quoted contraction", "Keep 'don't change **literal**' intact.", "Keep 'don't change **literal**' intact."}, + {"quoted content in emphasis", "**Use '--input **literal**' carefully**.", "Use '--input **literal**' carefully."}, + {"malformed quote", "Keep '--input **unfinished** literal.", "Keep '--input **unfinished** literal."}, + {"URL inside emphasis", "**Read https://example.invalid/**raw** carefully**.", "Read https://example.invalid/**raw** carefully."}, + {"relative link inside emphasis", "**Read [guide](relative/**raw**) carefully**.", "Read guide (relative/**raw**) carefully."}, + {"malformed strong", "Keep **unfinished *span*.", "Keep **unfinished *span*."}, + {"malformed closing", "Keep **unbalanced* or ** spaced **.", "Keep **unbalanced* or ** spaced **."}, + {"empty markers", "Keep ** and __ and ****.", "Keep ** and __ and ****."}, + {"command", "openai responses create --input '**literal** _tokens_'", "openai responses create --input '**literal** _tokens_'"}, + {"indented code", " **literal** _tokens_ [field](url)", " **literal** _tokens_ [field](url)"}, + {"fence", "```text\n**literal** _tokens_\n```", "```text\n**literal** _tokens_\n```"}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := terminalProse(tc.source); got != tc.want { + t.Fatalf("terminal prose = %q; want %q", got, tc.want) + } + }) + } +} From 3005b1e4bb5b104fe4cdb226163d1fe6519c814c Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Thu, 8 Oct 2026 00:37:20 -0700 Subject: [PATCH 10/44] Keep quoted help references distinct from literal arguments --- cmd/openai/gcloud_help_test.go | 4 +++ internal/clihelp/terminal_prose.go | 37 ++++++++++++++++++++++++- internal/clihelp/terminal_prose_test.go | 28 +++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/cmd/openai/gcloud_help_test.go b/cmd/openai/gcloud_help_test.go index dd3a5c2b..fd20d5b2 100644 --- a/cmd/openai/gcloud_help_test.go +++ b/cmd/openai/gcloud_help_test.go @@ -42,6 +42,10 @@ func TestMainGcloudHelpPreservesDetailsAfterShortName(t *testing.T) { t.Errorf("tool guidance lost its text or retained emphasis markers: %s", label) } } + plain := strings.Join(strings.Fields(got.stdout), " ") + if !strings.Contains(plain, "'flex' (https://developers.openai.com/api/docs/guides/flex-processing)") { + t.Error("quoted service-tier value must retain its reference outside the quotes") + } } func TestMainGcloudHelpConcreteSyntaxAndLabels(t *testing.T) { diff --git a/internal/clihelp/terminal_prose.go b/internal/clihelp/terminal_prose.go index fb721044..b4b9b980 100644 --- a/internal/clihelp/terminal_prose.go +++ b/internal/clihelp/terminal_prose.go @@ -33,7 +33,11 @@ func terminalInlineProse(text string) string { out.WriteString(text[i:]) return out.String() } - out.WriteString(text[i:next]) + if reference, ok := quotedProseReference(text, i, next); ok { + out.WriteString(reference) + } else { + out.WriteString(text[i:next]) + } i = next case '`': start, end, next := inlineCode(text, i) @@ -88,6 +92,37 @@ func terminalInlineProse(text string) string { return out.String() } +// A quoted prose reference names the value, not its Markdown source. Keep flag +// arguments, assignments, query strings, and authored command examples literal. +func quotedProseReference(text string, start, next int) (string, bool) { + if next-start < 4 || text[start+1] != '[' { + return "", false + } + prefix := strings.TrimSpace(text[:start]) + if prefix != "" { + if strings.ContainsRune("=:<>!~%", rune(prefix[len(prefix)-1])) || + strings.Contains(prefix, "openai ") || strings.Contains(strings.ToLower(prefix), "example:") { + return "", false + } + words := strings.Fields(prefix) + if strings.HasPrefix(words[len(words)-1], "-") { + return "", false + } + } + link := text[start+1 : next-1] + labelEnd := inlineClosing(link, 0, '[', ']') + if labelEnd < 0 || labelEnd+1 >= len(link) || link[labelEnd+1] != '(' || + inlineClosing(link, labelEnd+1, '(', ')') != len(link)-1 { + return "", false + } + label, destination := link[1:labelEnd], link[labelEnd+2:len(link)-1] + if strings.HasPrefix(destination, "<") && strings.HasSuffix(destination, ">") { + destination = destination[1 : len(destination)-1] + } + quote := text[start : start+1] + return quote + terminalInlineProse(label) + quote + " (" + destination + ")", true +} + // Only prose-boundary spans are emphasis. Ambiguous path and wildcard tokens // stay literal, including single-word *wildcards* and names such as _field_. func inlineEmphasis(text string, start int) (int, int, int) { diff --git a/internal/clihelp/terminal_prose_test.go b/internal/clihelp/terminal_prose_test.go index b32ea847..3de0883f 100644 --- a/internal/clihelp/terminal_prose_test.go +++ b/internal/clihelp/terminal_prose_test.go @@ -118,3 +118,31 @@ func TestTerminalProseNormalizesOnlyAuthoredEmphasis(t *testing.T) { }) } } + +func TestTerminalProseDistinguishesQuotedReferencesAndArguments(t *testing.T) { + for _, tc := range []struct{ name, source, want string }{ + { + "actual service tier prose", + "- If set to '[flex](https://developers.openai.com/api/docs/guides/flex-processing)', then the request uses Flex Processing.", + "- If set to 'flex' (https://developers.openai.com/api/docs/guides/flex-processing), then the request uses Flex Processing.", + }, + {"quoted prose", `See "[the guide](https://example.invalid/a_(b))" for details.`, `See "the guide" (https://example.invalid/a_(b)) for details.`}, + {"quoted label markup", "See '[`the` **guide**](docs.md)'.", "See 'the guide' (docs.md)."}, + {"flag value", `Use --input '[label](url)'.`, `Use --input '[label](url)'.`}, + {"flag absolute URL", `Use --input '[label](https://example.invalid)'.`, `Use --input '[label](https://example.invalid)'.`}, + {"assigned flag", `Use --input='[label](url)'.`, `Use --input='[label](url)'.`}, + {"short flag", `Use -H '[label](url)'.`, `Use -H '[label](url)'.`}, + {"GJSON string", `Use data.#(name=="[label](url)")#.`, `Use data.#(name=="[label](url)")#.`}, + {"GJSON pattern", `Use data.#(name%"[label](url)")#.`, `Use data.#(name%"[label](url)")#.`}, + {"command argument", `Example: openai action '[label](url)'.`, `Example: openai action '[label](url)'.`}, + {"other command example", `Example: printf '[label](url)'.`, `Example: printf '[label](url)'.`}, + {"JSON value", `The value is "name": "[label](url)".`, `The value is "name": "[label](url)".`}, + {"malformed quoted link", `See '[label](unclosed'.`, `See '[label](unclosed'.`}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := terminalProse(tc.source); got != tc.want { + t.Fatalf("quoted reference = %q; want %q", got, tc.want) + } + }) + } +} From 5cdf026a4bbb0912cbfd553af1efb5ca8aa9d985 Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Thu, 8 Oct 2026 00:58:58 -0700 Subject: [PATCH 11/44] fix(output): emit structured stream events without pager buffering --- cmd/openai/deterministic_stream_test.go | 77 +++++++++++++++++++++++++ pkg/custom/cmdutil.go | 24 +++++--- 2 files changed, 92 insertions(+), 9 deletions(-) create mode 100644 cmd/openai/deterministic_stream_test.go diff --git a/cmd/openai/deterministic_stream_test.go b/cmd/openai/deterministic_stream_test.go new file mode 100644 index 00000000..10603a07 --- /dev/null +++ b/cmd/openai/deterministic_stream_test.go @@ -0,0 +1,77 @@ +package main + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// The server cannot send its terminal event until the consumer sees output. +// This tests the real stdout path, including its process-level pipe handling. +func TestMainStructuredStreamEmitsBeforeNextEvent(t *testing.T) { + for _, format := range []string{"json", "jsonl", "yaml", "raw", "pretty", "explore"} { + t.Run(format, func(t *testing.T) { + release := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + writeStreamingTextEvent(w, `{"type":"response.output_text.delta","delta":"synthetic first event","sequence_number":0}`) + w.(http.Flusher).Flush() + select { + case <-release: + case <-r.Context().Done(): + return + } + writeStreamingTextEvent(w, `{"type":"response.completed","response":{"id":"resp_synthetic","status":"completed","output":[]}}`) + io.WriteString(w, "data: [DONE]\n\n") + })) + t.Cleanup(server.Close) + t.Cleanup(func() { close(release) }) + child, stdout, stderr, ctx := startStreamingTextCommand(t, server, streamingTextArgs("responses", "--format", format)...) + prefix := make([]byte, 1) + read := make(chan error, 1) + go func() { _, err := io.ReadFull(stdout, prefix); read <- err }() + select { + case err := <-read: + if err != nil { + t.Fatal(err) + } + case <-ctx.Done(): + t.Fatal("first event stayed buffered while the server withheld the next event") + } + release <- struct{}{} + rest, err := io.ReadAll(stdout) + if err != nil { + t.Fatal(err) + } + if err := child.Wait(); err != nil { + t.Fatalf("stream failed: %v; stderr=%q", err, stderr.String()) + } + if format != "explore" && stderr.Len() != 0 { + t.Fatalf("unexpected diagnostics: %q", stderr.String()) + } + output := string(prefix) + string(rest) + if !strings.Contains(output, "synthetic first event") || !strings.Contains(output, "resp_synthetic") { + t.Fatalf("missing event data: %q", output) + } + if format == "json" || format == "jsonl" || format == "raw" || format == "explore" { + decoder := json.NewDecoder(strings.NewReader(output)) + for range 2 { + var event map[string]any + if err := decoder.Decode(&event); err != nil { + t.Fatalf("invalid JSON record: %v", err) + } + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + t.Fatalf("unexpected trailing data: %v", err) + } + } + if format == "jsonl" && strings.Count(output, "\n") != 2 { + t.Fatalf("JSONL must emit exactly one line per event: %q", output) + } + }) + } +} diff --git a/pkg/custom/cmdutil.go b/pkg/custom/cmdutil.go index 4e0714a3..eadc81e2 100644 --- a/pkg/custom/cmdutil.go +++ b/pkg/custom/cmdutil.go @@ -680,17 +680,23 @@ func showJSONIterator[T any](source jsonview.Iterator[T], itemsToDisplay int64, // The existing pager writes to process stdout. Other injected destinations // must stay on their own writer, including error output on stderr. stdout, processStdout := opts.Stdout.(*os.File) - if !processStdout || stdout != os.Stdout { - for iter.Next() { - formatted, err := formatJSON(iter.Current().Result, opts) - if err != nil { - return errors.Join(err, iter.Err()) - } - if _, err := (outputWriter{ctx: opts.Context, out: opts.Stdout}).Write(formatted); err != nil { - return errors.Join(err, iter.Err()) + if opts.OutputKind == OutputStreamEvent || !processStdout || stdout != os.Stdout { + writeEvents := func(out io.Writer) error { + for iter.Next() { + formatted, err := formatJSON(iter.Current().Result, opts) + if err != nil { + return errors.Join(err, iter.Err()) + } + if _, err := (outputWriter{ctx: opts.Context, out: out}).Write(formatted); err != nil { + return errors.Join(err, iter.Err()) + } } + return iter.Err() } - return iter.Err() + if processStdout && stdout == os.Stdout { + return streamToStdout(func(stdout *os.File) error { return writeEvents(stdout) }) + } + return writeEvents(opts.Stdout) } terminalWidth, terminalHeight, err := term.GetSize(os.Stdout.Fd()) From b7ca4a318c032bafec9ef82abed68b0e7cbd4dfc Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Thu, 8 Oct 2026 01:14:53 -0700 Subject: [PATCH 12/44] fix(output): close owned event streams after presentation --- cmd/openai/deterministic_stream_test.go | 2 +- pkg/custom/cmdutil.go | 12 +- .../deterministic_stream_cleanup_test.go | 251 ++++++++++++++++++ 3 files changed, 262 insertions(+), 3 deletions(-) create mode 100644 pkg/custom/deterministic_stream_cleanup_test.go diff --git a/cmd/openai/deterministic_stream_test.go b/cmd/openai/deterministic_stream_test.go index 10603a07..8c3fd212 100644 --- a/cmd/openai/deterministic_stream_test.go +++ b/cmd/openai/deterministic_stream_test.go @@ -14,7 +14,7 @@ import ( func TestMainStructuredStreamEmitsBeforeNextEvent(t *testing.T) { for _, format := range []string{"json", "jsonl", "yaml", "raw", "pretty", "explore"} { t.Run(format, func(t *testing.T) { - release := make(chan struct{}) + release := make(chan struct{}, 1) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") writeStreamingTextEvent(w, `{"type":"response.output_text.delta","delta":"synthetic first event","sequence_number":0}`) diff --git a/pkg/custom/cmdutil.go b/pkg/custom/cmdutil.go index eadc81e2..5fb74ffa 100644 --- a/pkg/custom/cmdutil.go +++ b/pkg/custom/cmdutil.go @@ -635,15 +635,23 @@ func ShowJSONIterator[T any](iter jsonview.Iterator[T], itemsToDisplay int64, op return showJSONIterator(iter, itemsToDisplay, opts, transformers.Select) } -func showJSONIterator[T any](source jsonview.Iterator[T], itemsToDisplay int64, opts ShowJSONOpts, selectTransformer transformerSelector) error { +func showJSONIterator[T any](source jsonview.Iterator[T], itemsToDisplay int64, opts ShowJSONOpts, selectTransformer transformerSelector) (resultErr error) { opts.setDefaults() if presentation, ok := savedImagePresentation(opts, OutputStreamEvent); ok { return presentation.output(func(out io.Writer) error { return saveFinalImageStream(opts.Context, source, presentation.plan, out) }) } + // SDK streams own response bodies and timers. Close even when presentation + // stops at a limit, cancellation, or an output failure. Saved-image streams + // retain their cleanup owner above; list iterators remain caller-owned. + if opts.OutputKind == OutputStreamEvent { + if closer, ok := any(source).(io.Closer); ok { + defer func() { resultErr = errors.Join(resultErr, closer.Close()) }() + } + } if itemsToDisplay == 0 { - return source.Err() + return errors.Join(opts.Context.Err(), source.Err()) } iter := &outputIterator[T]{ source: source, diff --git a/pkg/custom/deterministic_stream_cleanup_test.go b/pkg/custom/deterministic_stream_cleanup_test.go new file mode 100644 index 00000000..29e12983 --- /dev/null +++ b/pkg/custom/deterministic_stream_cleanup_test.go @@ -0,0 +1,251 @@ +package custom + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/option" + "github.com/openai/openai-go/v3/packages/ssestream" + "github.com/openai/openai-go/v3/responses" + "github.com/tidwall/gjson" +) + +// This iterator deliberately does not make Close idempotent. +// The presentation boundary must own exactly one explicit cleanup call. +type deterministicCleanupIterator struct { + events []outputJSON + reads, closes int + errorReadsAfterClose int + sourceErr, closeErr error +} + +func (s *deterministicCleanupIterator) Next() bool { s.reads++; return s.reads <= len(s.events) } +func (s *deterministicCleanupIterator) Current() outputJSON { return s.events[s.reads-1] } +func (s *deterministicCleanupIterator) Err() error { + if s.closes != 0 { + s.errorReadsAfterClose++ + } + return s.sourceErr +} +func (s *deterministicCleanupIterator) Close() error { s.closes++; return s.closeErr } + +func deterministicCleanupEvents() []outputJSON { + return []outputJSON{ + {gjson.Parse(`{"type":"future.event","value":"first"}`)}, + {gjson.Parse(`{"type":"future.event","value":"second"}`)}, + } +} + +func TestDeterministicStreamCleanupClosesEveryFormat(t *testing.T) { + for _, format := range []string{"auto", "text", "json", "jsonl", "raw", "yaml", "pretty", "explore"} { + t.Run(format, func(t *testing.T) { + for _, limit := range []int64{0, 1, -1} { + stream := &deterministicCleanupIterator{events: deterministicCleanupEvents()} + err := ShowJSONIterator(stream, limit, ShowJSONOpts{ + Context: t.Context(), OutputKind: OutputStreamEvent, + Format: format, ExplicitFormat: true, Stdout: io.Discard, Stderr: io.Discard, + }) + wantReads := int(limit) + if limit < 0 { + wantReads = len(stream.events) + 1 + } + if err != nil || stream.reads != wantReads || stream.closes != 1 || stream.errorReadsAfterClose != 0 { + t.Errorf("limit=%d: err=%v reads=%d closes=%d error reads after close=%d", limit, err, stream.reads, stream.closes, stream.errorReadsAfterClose) + } + } + }) + } +} + +func TestDeterministicStreamCleanupRetainsJoinedFailures(t *testing.T) { + closeErr := errors.New("synthetic stream cleanup failure") + sourceErr := errors.New("synthetic source failure") + outputErr := errors.New("synthetic output failure") + for _, tc := range []struct { + name string + limit int64 + cancelled, source, output bool + cancelOnWrite bool + wantReads int + }{ + {name: "zero initial source error", limit: 0, source: true}, + {name: "cancelled zero", limit: 0, cancelled: true}, + {name: "cancelled zero with source error", limit: 0, cancelled: true, source: true}, + {name: "cancelled before reading", limit: 1, cancelled: true, source: true}, + {name: "cancelled while writing", limit: 1, cancelOnWrite: true, source: true, wantReads: 1}, + {name: "sink and source failure", limit: 1, source: true, output: true, wantReads: 1}, + {name: "source failure at EOF", limit: -1, source: true, wantReads: 3}, + {name: "cleanup failure at EOF", limit: -1, wantReads: 3}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + if tc.cancelled { + cancel() + } + stream := &deterministicCleanupIterator{events: deterministicCleanupEvents(), closeErr: closeErr} + if tc.source { + stream.sourceErr = sourceErr + } + var out io.Writer = io.Discard + if tc.output { + out = failOutputWriter{outputErr} + } + if tc.cancelOnWrite { + out = &cancelOutputWriter{cancel: cancel} + } + err := ShowJSONIterator(stream, tc.limit, ShowJSONOpts{ + Context: ctx, OutputKind: OutputStreamEvent, Format: "jsonl", Stdout: out, + }) + if !errors.Is(err, closeErr) || errors.Is(err, sourceErr) != tc.source || + errors.Is(err, outputErr) != tc.output || errors.Is(err, context.Canceled) != (tc.cancelled || tc.cancelOnWrite) { + t.Errorf("lost or added failure: %v", err) + } + if stream.reads != tc.wantReads || stream.closes != 1 || stream.errorReadsAfterClose != 0 { + t.Errorf("reads=%d closes=%d error reads after close=%d", stream.reads, stream.closes, stream.errorReadsAfterClose) + } + }) + } +} + +func TestDeterministicStreamCleanupPreservesListOwnership(t *testing.T) { + for _, kind := range []OutputKind{OutputPageItem, OutputUnspecified} { + for _, limit := range []int64{0, 1, -1} { + stream := &deterministicCleanupIterator{events: deterministicCleanupEvents(), closeErr: errors.New("caller-owned cleanup")} + err := ShowJSONIterator(stream, limit, ShowJSONOpts{OutputKind: kind, Format: "jsonl", Stdout: io.Discard}) + if err != nil || stream.closes != 0 { + t.Fatalf("kind=%v limit=%d: err=%v closes=%d", kind, limit, err, stream.closes) + } + } + } +} + +type deterministicCleanupDecoder struct { + events, reads, closes int + closeErr error +} + +func (d *deterministicCleanupDecoder) Next() bool { d.reads++; return d.reads <= d.events } +func (d *deterministicCleanupDecoder) Event() ssestream.Event { + return ssestream.Event{Data: []byte(`{"type":"future.event","value":"synthetic"}`)} +} +func (d *deterministicCleanupDecoder) Err() error { return nil } +func (d *deterministicCleanupDecoder) Close() error { d.closes++; return d.closeErr } + +func TestDeterministicStreamCleanupSDKCachedCloseFailure(t *testing.T) { + closeErr := errors.New("synthetic decoder cleanup failure") + for _, limit := range []int64{0, 1, -1} { + decoder := &deterministicCleanupDecoder{events: 2, closeErr: closeErr} + stream := ssestream.NewStream[map[string]any](decoder, nil) + err := ShowJSONIterator(stream, limit, ShowJSONOpts{OutputKind: OutputStreamEvent, Format: "jsonl", Stdout: io.Discard}) + if !errors.Is(err, closeErr) || decoder.closes != 1 { + t.Errorf("limit=%d: err=%v decoder closes=%d", limit, err, decoder.closes) + } + // SDK EOF already closes the decoder. Explicit cleanup must return the + // cached error without a second decoder close, including after EOF. + if !errors.Is(stream.Close(), closeErr) || !errors.Is(stream.Close(), closeErr) || decoder.closes != 1 { + t.Errorf("limit=%d: SDK cleanup changed: closes=%d", limit, decoder.closes) + } + if stream.Next() { + t.Errorf("limit=%d: stream remained readable after cleanup", limit) + } + } +} + +func TestDeterministicStreamCleanupRepeatedLoopbackExit(t *testing.T) { + requests := make(chan chan struct{}, 1) + var active atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + done := make(chan struct{}) + active.Add(1) + defer func() { active.Add(-1); close(done) }() + requests <- done + w.Header().Set("Content-Type", "text/event-stream") + if _, err := io.WriteString(w, "data: {\"type\":\"response.output_text.delta\",\"delta\":\"synthetic\"}\n\n"); err != nil { + return + } + w.(http.Flusher).Flush() + <-r.Context().Done() + })) + t.Cleanup(server.Close) + client := openai.NewClient(option.WithAPIKey("sk-fake-cleanup-test"), option.WithBaseURL(server.URL), option.WithMaxRetries(0)) + for range 3 { + for _, limit := range []int64{0, 1} { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + stream := client.Responses.NewStreaming(ctx, responses.ResponseNewParams{}) + var done chan struct{} + select { + case done = <-requests: + case <-ctx.Done(): + stream.Close() + cancel() + t.Fatal("synthetic stream request did not reach the loopback server") + } + err := ShowJSONIterator(stream, limit, ShowJSONOpts{ + Context: ctx, Operation: responseStreamOperation, OutputKind: OutputStreamEvent, Format: "jsonl", Stdout: io.Discard, + }) + select { + case <-done: + case <-time.After(2 * time.Second): + stream.Close() + cancel() + t.Fatal("early presentation exit retained the active HTTP stream") + } + cancel() + if err != nil || active.Load() != 0 { + t.Fatalf("limit=%d: err=%v active streams=%d", limit, err, active.Load()) + } + } + } +} + +func TestDeterministicStreamCleanupKeepsSavedImageOwner(t *testing.T) { + for _, event := range []string{ + `{"type":"image_generation.completed","b64_json":"iVBORw0KGgo="}`, + `{"type":"image_generation.failed"}`, + } { + stream := &deterministicCleanupIterator{events: []outputJSON{{gjson.Parse(event)}}} + plan := &imageOutputPlan{directory: t.TempDir(), name: "synthetic", inline: "off"} + ctx := context.WithValue(t.Context(), imagePresentationKey{}, imagePresentation{plan: plan, writer: io.Discard}) + err := ShowJSONIterator(stream, 0, ShowJSONOpts{Context: ctx, Operation: imageGenerationOperation, OutputKind: OutputStreamEvent}) + failed := gjson.Get(event, "type").String() == "image_generation.failed" + if (err != nil) != failed || stream.reads != 1 || stream.closes != 1 { + t.Fatalf("saved-image owner changed: err=%v reads=%d closes=%d", err, stream.reads, stream.closes) + } + } +} + +type deterministicSpeechBody struct { + io.Reader + closes int +} + +func (b *deterministicSpeechBody) Close() error { b.closes++; return nil } + +func TestDeterministicStreamCleanupKeepsSpeechBodyOwner(t *testing.T) { + body := &deterministicSpeechBody{Reader: strings.NewReader("data: {\"type\":\"speech.audio.done\"}\n\ndata: [DONE]\n\n")} + opts := ShowJSONOpts{OutputKind: OutputStreamEvent, Operation: speechStreamOperation, Format: "jsonl", ExplicitFormat: true} + ctx := context.WithValue(t.Context(), speechOutputKey{}, opts) + request, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://synthetic.invalid/audio/speech", nil) + if err != nil { + t.Fatal(err) + } + response := &http.Response{Request: request, StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {"text/event-stream"}}, Body: body} + var out bytes.Buffer + handled, _, err := writeReadableSpeech(response, &out, "") + if !handled || err != nil || body.closes != 0 || out.String() != "{\"type\":\"speech.audio.done\"}\n" { + t.Fatalf("speech ownership changed: handled=%v err=%v closes=%d output=%q", handled, err, body.closes, out.String()) + } + if err := body.Close(); err != nil || body.closes != 1 { + t.Fatalf("caller cleanup failed: %v", err) + } +} From e151676fa5c3b43ddcb8facd8129751dedea6e4b Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Thu, 8 Oct 2026 01:19:45 -0700 Subject: [PATCH 13/44] Match Windows invocation syntax in help index test --- internal/clihelp/command_help_test.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/clihelp/command_help_test.go b/internal/clihelp/command_help_test.go index b7bcf27c..560bbe97 100644 --- a/internal/clihelp/command_help_test.go +++ b/internal/clihelp/command_help_test.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "reflect" + "runtime" "slices" "strings" "testing" @@ -208,7 +209,11 @@ func TestInheritedGlobalIndexKeepsEveryNameAndRootReference(t *testing.T) { if !found { t.Fatal("missing inherited flag index") } - for _, want := range []string{"--root-option, -r", "--future-00", "--future-11", "Global option details: '/tmp/CLI build/openai' --help", "This command supports text output only."} { + rootReference := "Global option details: '/tmp/CLI build/openai' --help" + if runtime.GOOS == "windows" { + rootReference = "Global option details: & '/tmp/CLI build/openai' --help" + } + for _, want := range []string{"--root-option, -r", "--future-00", "--future-11", rootReference, "This command supports text output only."} { if !strings.Contains(index, want) { t.Errorf("global index lost %q: %s", want, index) } From c01802104fb0b1b821543af1a84a430ec23ddac9 Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Thu, 8 Oct 2026 01:22:02 -0700 Subject: [PATCH 14/44] feat(output): preserve data with quiet and verbose controls --- cmd/openai/deterministic_output_test.go | 209 ++++++++++++++++++ cmd/openai/deterministic_output_unix_test.go | 65 ++++++ cmd/openai/main_image_models_test.go | 9 +- cmd/openai/readable_output_test.go | 6 +- cmd/openai/resource_summaries_test.go | 38 ++-- cmd/openai/resource_summaries_unix_test.go | 8 +- cmd/openai/saved_image_preview_test.go | 4 +- docs/deterministic-output.md | 81 +++++++ pkg/custom/cmdutil.go | 18 +- pkg/custom/cmdutil_pretty_color_test.go | 6 +- pkg/custom/cmdutil_test.go | 2 +- pkg/custom/command.go | 1 + pkg/custom/image_loading_feedback.go | 11 +- pkg/custom/image_models.go | 31 ++- pkg/custom/image_models_test.go | 12 +- ...image_picker_shell_profile_windows_test.go | 5 +- pkg/custom/image_picker_shell_setup.go | 4 +- pkg/custom/image_picker_shell_setup_test.go | 71 ++++-- pkg/custom/image_preferences.go | 3 +- pkg/custom/image_saving_commands.go | 2 +- pkg/custom/output_policy.go | 124 +++++++++++ pkg/custom/output_policy_test.go | 123 +++++++++++ pkg/custom/readable_errors.go | 1 + pkg/custom/readable_output.go | 2 +- pkg/custom/resource_summaries_test.go | 18 +- 25 files changed, 762 insertions(+), 92 deletions(-) create mode 100644 cmd/openai/deterministic_output_test.go create mode 100644 cmd/openai/deterministic_output_unix_test.go create mode 100644 docs/deterministic-output.md create mode 100644 pkg/custom/output_policy.go create mode 100644 pkg/custom/output_policy_test.go diff --git a/cmd/openai/deterministic_output_test.go b/cmd/openai/deterministic_output_test.go new file mode 100644 index 00000000..b8f03c29 --- /dev/null +++ b/cmd/openai/deterministic_output_test.go @@ -0,0 +1,209 @@ +package main + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +func TestMainPipedFormatsIgnoreForcedDecoration(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, readableModelResponse) + })) + defer server.Close() + for _, format := range []string{"json", "jsonl", "raw", "yaml", "pretty", "text", "explore"} { + for _, forced := range []string{"FORCE_COLOR=1", "CLICOLOR_FORCE=1"} { + t.Run(format+"/"+forced, func(t *testing.T) { + got := runMainDispatchWithEnv(t, "bash", []string{ + "OPENAI_BASE_URL=" + server.URL, "OPENAI_API_KEY=sk-fake-output-test", forced, + }, "openai", "--format", format, "models", "retrieve", "model_synthetic") + if got.code != 0 || strings.Contains(got.stdout, "\x1b") { + t.Fatalf("piped result changed or contains decoration: %+v", got) + } + if format == "json" || format == "jsonl" || format == "raw" || format == "explore" { + var data map[string]any + if err := json.Unmarshal([]byte(got.stdout), &data); err != nil || data["id"] != "model_synthetic" { + t.Fatalf("invalid selected JSON: %v; %q", err, got.stdout) + } + } else if !strings.Contains(got.stdout, "model_synthetic") { + t.Fatalf("selected data disappeared: %q", got.stdout) + } + }) + } + } +} + +func TestMainQuietPreservesSelectedPayloads(t *testing.T) { + const binary = "synthetic\x00\xff\x1b[31m\r\nbytes" + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + switch r.URL.Path { + case "/models/model_synthetic": + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, readableModelResponse) + case "/files": + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `{"object":"list","data":[{"id":"file_a","object":"file"},{"id":"file_b","object":"file"}],"has_more":false}`) + case "/files/file_synthetic/content": + w.Header().Set("Content-Type", "application/octet-stream") + io.WriteString(w, binary) + case "/containers/cntr_synthetic": + w.WriteHeader(http.StatusNoContent) + default: + http.Error(w, "synthetic unexpected route", http.StatusNotFound) + } + })) + defer server.Close() + for _, args := range [][]string{ + {"models", "retrieve", "model_synthetic"}, + {"--format", "json", "models", "retrieve", "model_synthetic"}, + {"--format", "jsonl", "files", "list"}, + {"--format", "yaml", "files", "list"}, + {"--format", "raw", "files", "list"}, + {"--format", "pretty", "models", "retrieve", "model_synthetic"}, + {"--transform", "extra.note", "--raw-output", "models", "retrieve", "model_synthetic"}, + {"files", "content", "file_synthetic", "--output", "-"}, + {"containers", "delete", "cntr_synthetic"}, + } { + t.Run(strings.Join(args, " "), func(t *testing.T) { + before := requests.Load() + ordinary := runReadableCommand(t, server, args...) + quiet := runReadableCommand(t, server, append([]string{"--quiet"}, args...)...) + if ordinary.code != 0 || quiet.code != ordinary.code || quiet.stdout != ordinary.stdout || quiet.stderr != "" { + t.Fatalf("quiet changed selected data: ordinary=%+v quiet=%+v", ordinary, quiet) + } + if requests.Load()-before != 2 { + t.Fatalf("quiet changed request count: %d", requests.Load()-before) + } + }) + } +} + +func TestMainVerboseKeepsDataAndProtectsMachineErrors(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if strings.HasSuffix(r.URL.Path, "model_failure") { + w.WriteHeader(http.StatusBadRequest) + io.WriteString(w, `{"error":{"message":"synthetic server detail","type":"invalid_request_error"}}`) + return + } + io.WriteString(w, readableModelResponse) + })) + defer server.Close() + for _, tc := range []struct { + name, model string + flags []string + verbose bool + failure bool + }{ + {"human success", "model_synthetic", nil, true, false}, + {"machine stdout text stderr", "model_synthetic", []string{"--format", "json", "--format-error", "text"}, true, false}, + {"machine success", "model_synthetic", []string{"--format", "json"}, false, false}, + {"machine failure", "model_failure", []string{"--format", "json"}, false, true}, + {"explicit machine error", "model_failure", []string{"--format-error", "json"}, false, true}, + {"human failure", "model_failure", nil, true, true}, + {"quiet takes precedence", "model_synthetic", []string{"--quiet"}, false, false}, + } { + t.Run(tc.name, func(t *testing.T) { + args := append([]string{"--verbose", "--organization", "synthetic-private-organization"}, tc.flags...) + args = append(args, "models", "retrieve", tc.model) + got := runReadableCommand(t, server, args...) + if (got.code != 0) != tc.failure || strings.Contains(got.stdout, "Command result:") || + strings.Contains(got.stderr, "Command result:") != tc.verbose || strings.Contains(got.stderr, "synthetic-private-organization") { + t.Fatalf("verbose policy changed data, privacy, or failure status: %+v", got) + } + if tc.failure && !tc.verbose { + var detail map[string]any + if json.Unmarshal([]byte(got.stderr), &detail) != nil || got.stdout != "" { + t.Fatalf("verbose corrupted machine error framing: %+v", got) + } + } + }) + } +} + +func TestMainQuietKeepsExplicitHelpVersionAndCompletion(t *testing.T) { + for _, args := range [][]string{{"models", "retrieve", "--help"}, {"--version"}, {"-v"}, {"@completion", "bash"}} { + t.Run(strings.Join(args, " "), func(t *testing.T) { + ordinary := runMainDispatch(t, "bash", append([]string{"openai"}, args...)...) + quiet := runMainDispatch(t, "bash", append([]string{"openai", "--quiet"}, args...)...) + if ordinary.code != 0 || ordinary.stdout == "" || quiet != ordinary { + t.Fatalf("quiet changed explicit help/protocol: ordinary=%+v quiet=%+v", ordinary, quiet) + } + }) + } +} + +func TestMainVerboseBinaryKeepsBytesAndLabelsFormatOption(t *testing.T) { + const payload = "synthetic-private-binary\x00\xff\x1b[31m\r\n" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/octet-stream") + io.WriteString(w, payload) + })) + defer server.Close() + got := runReadableCommand(t, server, "--verbose", "files", "content", "file_synthetic", "--output", "-") + if got.code != 0 || got.stdout != payload || got.stderr != "Command: files content\nFormat option: auto\nCommand result: completed\n" { + t.Fatalf("verbose changed binary data or reported its bytes as text: %+v", got) + } +} + +func TestMainQuietPreservesSavedImagePaths(t *testing.T) { + payload := imageGenerationPNG(t) + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, imageGenerationResponse(payload)) + })) + defer server.Close() + directory := t.TempDir() + got := runImageGeneration(t, server, t.TempDir(), "", "--quiet", "images", "generate", "--prompt", "synthetic fixture", "--output-dir", directory) + if got.code != 0 || got.stderr != "" || requests.Load() != 1 { + t.Fatalf("quiet image result changed: %+v, requests=%d", got, requests.Load()) + } + assertImageGenerationFiles(t, directory, got.stdout, 1, payload) +} + +func TestMainQuietKeepsFailurePayloadsAndSuppressesFallbackNotice(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + io.WriteString(w, `{"error":{"message":"synthetic rejection","type":"invalid_request_error"}}`) + })) + defer server.Close() + for _, format := range []string{"json", "jsonl", "raw", "explore"} { + t.Run(format, func(t *testing.T) { + got := runReadableCommand(t, server, "--quiet", "--format-error", format, "models", "retrieve", "model_synthetic") + var failure map[string]any + if got.code != 1 || got.stdout != "" || json.Unmarshal([]byte(got.stderr), &failure) != nil || failure["message"] != "synthetic rejection" { + t.Fatalf("quiet hid or decorated the failure: %+v", got) + } + }) + } +} + +func TestMainRedirectedErrorsIgnoreForcedDecoration(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + io.WriteString(w, `{"error":{"message":"synthetic failure","type":"invalid_request_error"}}`) + })) + defer server.Close() + for _, format := range []string{"json", "jsonl", "raw"} { + t.Run(format, func(t *testing.T) { + got := runMainDispatchWithEnv(t, "bash", []string{ + "OPENAI_BASE_URL=" + server.URL, "OPENAI_API_KEY=sk-fake-output-test", "FORCE_COLOR=1", + }, "openai", "--format-error", format, "models", "retrieve", "model_synthetic") + var data map[string]any + if got.code != 1 || got.stdout != "" || json.Unmarshal([]byte(got.stderr), &data) != nil { + t.Fatalf("error format or failure status changed: %+v", got) + } + }) + } +} diff --git a/cmd/openai/deterministic_output_unix_test.go b/cmd/openai/deterministic_output_unix_test.go new file mode 100644 index 00000000..a7779ff4 --- /dev/null +++ b/cmd/openai/deterministic_output_unix_test.go @@ -0,0 +1,65 @@ +//go:build !windows + +package main + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "strings" + "testing" + "time" +) + +func TestMainSummaryKeepsStderrPipeFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `{"object":"list","data":[{"id":"file_synthetic","object":"file","created_at":17}],"has_more":false}`) + })) + defer server.Close() + binary, err := os.Executable() + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name string + args []string + code int + }{ + {"summary diagnostic", nil, 1}, + {"quiet suppresses optional diagnostic", []string{"--quiet"}, 0}, + {"machine output has no summary", []string{"--format", "jsonl"}, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + reader, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer writer.Close() + if err := reader.Close(); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + args := append([]string{"-test.run=^TestMainDispatchProcess$", "--", "openai"}, tc.args...) + child := exec.CommandContext(ctx, binary, append(args, "files", "list")...) + child.Env = []string{"OPENAI_CLI_MAIN_DISPATCH_PROCESS=1", "OPENAI_BASE_URL=" + server.URL, "OPENAI_API_KEY=sk-fake-output-test", "FORCE_COLOR=0"} + var output bytes.Buffer + child.Stdout, child.Stderr = &output, writer + err = child.Run() + code := 0 + if exit, ok := err.(*exec.ExitError); ok { + code = exit.ExitCode() + } else if err != nil { + t.Fatal(err) + } + if ctx.Err() != nil || code != tc.code || !strings.Contains(output.String(), "file_synthetic") || strings.Contains(output.String(), "Summary;") { + t.Fatalf("stderr failure changed data/status: exit=%d stdout=%q context=%v", code, output.String(), ctx.Err()) + } + }) + } +} diff --git a/cmd/openai/main_image_models_test.go b/cmd/openai/main_image_models_test.go index 93b93aa4..0be9076b 100644 --- a/cmd/openai/main_image_models_test.go +++ b/cmd/openai/main_image_models_test.go @@ -165,6 +165,8 @@ func TestMainImageModelsExploreFallbackWarning(t *testing.T) { wantWarning := "" if format == "explore" { wantWarning = "Warning: Output format 'explore' not supported for non-terminal output; falling back to 'json'\n" + } else if format == "auto" { + wantWarning = "Choose a model:\n openai images generate --prompt \"A tiny orange robot\" --model gpt-image-2.5-sunburst\n" } if got.stderr != wantWarning { t.Errorf("stderr = %q; want %q", got.stderr, wantWarning) @@ -376,19 +378,18 @@ func TestMainImageModelsPipeIsReadableAndAllRevealsUnavailable(t *testing.T) { args = append(args, "--all") } got := runMainDispatchWithEnv(t, "bash", []string{"OPENAI_API_KEY=synthetic-models-key"}, args...) - if got.code != 0 || got.stderr != "" { + if got.code != 0 || !strings.Contains(got.stderr, `openai images generate --prompt "A tiny orange robot" --model gpt-image-2.5-sunburst`) { t.Fatalf("readable piped discovery failed: %+v", got) } for _, want := range []string{ "MODEL", "STATUS", "gpt-image-2.5-sunburst", "gpt-image-2.5-flare", "Visible", - `openai images generate --prompt "A tiny orange robot" --model gpt-image-2.5-sunburst`, "generation permissions can differ", } { if !strings.Contains(got.stdout, want) { t.Errorf("readable output missing %q: %s", want, got.stdout) } } - for _, hidden := range []string{`"id":`, `"source":`, "\x1b", "Checking image models", "default", "Leaving out --model"} { + for _, hidden := range []string{`"id":`, `"source":`, "\x1b", "Checking image models", "default", "Leaving out --model", "Choose a model", "openai images"} { if strings.Contains(got.stdout, hidden) { t.Errorf("readable pipe exposed JSON/progress/default/control %q: %q", hidden, got.stdout) } @@ -405,7 +406,7 @@ func TestMainImageModelsPipeIsReadableAndAllRevealsUnavailable(t *testing.T) { t.Errorf("default presentation exposed unavailable row %q", hidden) } } - if !strings.Contains(got.stdout, "2 retired or not visible") || !strings.Contains(got.stdout, "openai images models --all") { + if !strings.Contains(got.stdout, "2 retired or not visible") || !strings.Contains(got.stderr, "openai images models --all") { t.Errorf("missing hidden-row guidance: %q", got.stdout) } } diff --git a/cmd/openai/readable_output_test.go b/cmd/openai/readable_output_test.go index add1d025..0d4adfb0 100644 --- a/cmd/openai/readable_output_test.go +++ b/cmd/openai/readable_output_test.go @@ -146,7 +146,11 @@ func TestMainReadableListPreservesItemsAndRawPage(t *testing.T) { t.Run(tc.name, func(t *testing.T) { requests.Store(0) got := runReadableCommand(t, server, tc.args...) - if got.code != 0 || got.stderr != "" || int(requests.Load()) != tc.calls { + wantDiagnostics := "" + if tc.count > 0 { + wantDiagnostics = resourceSummaryHint + "\n" + } + if got.code != 0 || got.stderr != wantDiagnostics || int(requests.Load()) != tc.calls || strings.Contains(got.stdout, resourceSummaryHint) { t.Fatalf("result=%+v requests=%d; want %d", got, requests.Load(), tc.calls) } if tc.rawPage { diff --git a/cmd/openai/resource_summaries_test.go b/cmd/openai/resource_summaries_test.go index 3b8b0ffe..7404b63f 100644 --- a/cmd/openai/resource_summaries_test.go +++ b/cmd/openai/resource_summaries_test.go @@ -58,13 +58,13 @@ func TestMainResourceSummariesRetainActionFields(t *testing.T) { })) defer server.Close() got := runReadableCommand(t, server, tc.args...) - if got.code != 0 || got.stderr != "" || requests.Load() != 1 { + if got.code != 0 || got.stderr != resourceSummaryHint+"\n" || requests.Load() != 1 { t.Fatalf("result=%+v requests=%d", got, requests.Load()) } - if strings.Count(got.stdout, resourceSummaryHint) != 1 { - t.Fatalf("retrieve must explain omissions once: %q", got.stdout) + if strings.Contains(got.stdout, resourceSummaryHint) { + t.Fatalf("retrieve hint must stay on stderr: %q", got.stdout) } - for _, want := range append(tc.want, resourceSummaryHint) { + for _, want := range tc.want { if !strings.Contains(got.stdout, want+"\n") { t.Errorf("missing %q in %q", want, got.stdout) } @@ -88,7 +88,7 @@ func TestMainResourceSummaryListFilesInVectorBatch(t *testing.T) { })) defer server.Close() got := runReadableCommand(t, server, "vector-stores:file-batches", "list-files", "--vector-store-id", "vs_synthetic", "--batch-id", "vsfb_synthetic") - if got.code != 0 || got.stderr != "" || !strings.Contains(got.stdout, "Vector store ID: vs_synthetic\n") || !strings.Contains(got.stdout, resourceSummaryHint) || strings.Contains(got.stdout, "Created at:") { + if got.code != 0 || got.stderr != resourceSummaryHint+"\n" || !strings.Contains(got.stdout, "Vector store ID: vs_synthetic\n") || strings.Contains(got.stdout, resourceSummaryHint) || strings.Contains(got.stdout, "Created at:") { t.Fatalf("list-files did not summarize its file item: %+v", got) } } @@ -132,7 +132,11 @@ func TestMainResourceSummaryPaginationPreservesOrderDuplicatesAndLimits(t *testi actualCursors := slices.Clone(cursors) mu.Unlock() wantCursors := []string{"file_before", "file_b"}[:tc.calls] - if got.code != 0 || got.stderr != "" || !slices.Equal(actualCursors, wantCursors) { + wantDiagnostic := "" + if len(tc.ids) > 0 { + wantDiagnostic = resourceSummaryHint + "\n" + } + if got.code != 0 || got.stderr != wantDiagnostic || !slices.Equal(actualCursors, wantCursors) { t.Fatalf("result=%+v cursors=%q; want %q", got, actualCursors, wantCursors) } var ids []string @@ -144,8 +148,8 @@ func TestMainResourceSummaryPaginationPreservesOrderDuplicatesAndLimits(t *testi if !slices.Equal(ids, tc.ids) || strings.Contains(got.stdout, "Created at:") { t.Fatalf("summary records changed: ids=%q want=%q output=%q", ids, tc.ids, got.stdout) } - if len(ids) == 0 && got.stdout != "" || len(ids) > 0 && (strings.Count(got.stdout, resourceSummaryHint) != 1 || !strings.HasSuffix(got.stdout, resourceSummaryHint+"\n")) { - t.Fatalf("incorrect summary notice or zero limit output: %q", got.stdout) + if len(ids) == 0 && got.stdout != "" || strings.Contains(got.stdout, resourceSummaryHint) { + t.Fatalf("summary notice entered stdout or zero limit produced output: %q", got.stdout) } }) } @@ -171,12 +175,9 @@ func TestMainResourceSummaryListNoticeForMixedRecords(t *testing.T) { })) defer server.Close() got := runReadableCommand(t, server, "files", "list") - if got.code != 0 || got.stderr != "" || strings.Count(got.stdout, resourceSummaryHint) != tc.hints { + if got.code != 0 || got.stderr != strings.Repeat(resourceSummaryHint+"\n", tc.hints) || strings.Contains(got.stdout, resourceSummaryHint) { t.Fatalf("incorrect list notice: %+v", got) } - if tc.hints > 0 && !strings.HasSuffix(got.stdout, resourceSummaryHint+"\n") { - t.Fatalf("notice must follow all records: %q", got.stdout) - } var ids []string for _, line := range strings.Split(got.stdout, "\n") { if id, ok := strings.CutPrefix(line, "ID: "); ok { @@ -268,7 +269,7 @@ func TestMainResourceSummaryExplicitFormatsAndExtractionKeepFullData(t *testing. for _, format := range []string{"auto", "text", "TeXt"} { t.Run(format, func(t *testing.T) { got := runReadableCommand(t, server, "--format", format, "models", "retrieve", "model_synthetic") - if got.code != 0 || got.stderr != "" || !strings.Contains(got.stdout, resourceSummaryHint) || strings.Contains(got.stdout, "Created:") { + if got.code != 0 || got.stderr != resourceSummaryHint+"\n" || strings.Contains(got.stdout, resourceSummaryHint) || strings.Contains(got.stdout, "Created:") { t.Fatalf("explicit readable format did not summarize: %+v", got) } }) @@ -423,7 +424,7 @@ func TestMainResourceSummaryPreservesLargeActionArguments(t *testing.T) { })) defer server.Close() got := runReadableCommand(t, server, "beta:threads:runs", "retrieve", "--thread-id", "thread_synthetic", "--run-id", "run_synthetic") - if got.code != 0 || got.stderr != "" || strings.Count(got.stdout, argument) != 1 || !strings.Contains(got.stdout, resourceSummaryHint) || strings.Contains(got.stdout, "Created at:") { + if got.code != 0 || got.stderr != resourceSummaryHint+"\n" || strings.Count(got.stdout, argument) != 1 || strings.Contains(got.stdout, resourceSummaryHint) || strings.Contains(got.stdout, "Created at:") { t.Fatalf("large action summary failed: exit=%d stdout bytes=%d stderr=%q", got.code, len(got.stdout), got.stderr) } } @@ -448,15 +449,18 @@ func TestMainResourceSummaryPreservesPartialPageOnFailure(t *testing.T) { args = append([]string{"--format-error", "json"}, args...) } got := runReadableCommand(t, server, args...) - if got.code != 1 || requests.Load() != 2 || strings.Count(got.stdout, "ID: file_first\n") != 1 || strings.Count(got.stdout, "ID: file_second\n") != 1 || strings.Count(got.stdout, resourceSummaryHint) != 1 || !strings.HasSuffix(got.stdout, resourceSummaryHint+"\n") || strings.Contains(got.stdout, "Created at:") { + if got.code != 1 || requests.Load() != 2 || strings.Count(got.stdout, "ID: file_first\n") != 1 || strings.Count(got.stdout, "ID: file_second\n") != 1 || strings.Contains(got.stdout, resourceSummaryHint) || strings.Contains(got.stdout, "Created at:") { t.Fatalf("partial output or pagination failure changed: %+v requests=%d", got, requests.Load()) } if format == "json" { + if strings.Contains(got.stderr, resourceSummaryHint) { + t.Fatalf("summary hint corrupted the selected machine error: %q", got.stderr) + } var failure map[string]any if err := json.Unmarshal([]byte(got.stderr), &failure); err != nil || failure["message"] != "synthetic second-page failure" || failure["type"] != "invalid_request_error" { t.Fatalf("explicit API error details lost: %q (%v)", got.stderr, err) } - } else if !strings.Contains(got.stderr, "The API rejected the request.") || !strings.Contains(got.stderr, "--format-error json") || strings.Contains(got.stderr, "synthetic second-page failure") { + } else if strings.Count(got.stderr, resourceSummaryHint) != 1 || !strings.HasPrefix(got.stderr, resourceSummaryHint+"\n") || !strings.Contains(got.stderr, "The API rejected the request.") || !strings.Contains(got.stderr, "--format-error json") || strings.Contains(got.stderr, "synthetic second-page failure") { t.Fatalf("expected safe guidance with explicit error-details option: %q", got.stderr) } }) @@ -528,7 +532,7 @@ func TestMainResourceSummaryWritesBeforeNextPageArrives(t *testing.T) { if err != nil { t.Fatal(err) } - if err := child.Wait(); err != nil || stderr.Len() != 0 || !strings.Contains(string(rest), "ID: file_second\n") { + if err := child.Wait(); err != nil || stderr.String() != resourceSummaryHint+"\n" || !strings.Contains(string(rest), "ID: file_second\n") || strings.Contains(string(rest), resourceSummaryHint) { t.Fatalf("completion error=%v stderr=%q output=%q", err, stderr.String(), rest) } } diff --git a/cmd/openai/resource_summaries_unix_test.go b/cmd/openai/resource_summaries_unix_test.go index 822512cd..6d119ca0 100644 --- a/cmd/openai/resource_summaries_unix_test.go +++ b/cmd/openai/resource_summaries_unix_test.go @@ -110,8 +110,8 @@ func TestMainResourceSummaryClosedStdoutPreservesPageFailure(t *testing.T) { case <-ctx.Done(): t.Fatal("second-page request did not arrive") } - // Only the final summary hint remains to write after this response. - // Close the actual child stdout consumer before releasing that response. + // Close the actual child stdout consumer before the final response. + // The summary hint belongs to stderr; page failures must remain visible. if err := stdout.Close(); err != nil { t.Fatal(err) } @@ -132,8 +132,8 @@ func TestMainResourceSummaryClosedStdoutPreservesPageFailure(t *testing.T) { t.Errorf("requests = %d; want exactly two pages", requests.Load()) } if !tc.pageFails { - if code != 0 || stderr.Len() != 0 { - t.Fatalf("closing only the final hint must succeed quietly: exit=%d stderr=%q", code, stderr.String()) + if code != 0 || stderr.String() != resourceSummaryHint+"\n" { + t.Fatalf("closed stdout must preserve the stderr hint: exit=%d stderr=%q", code, stderr.String()) } return } diff --git a/cmd/openai/saved_image_preview_test.go b/cmd/openai/saved_image_preview_test.go index cd9a29ff..67266f42 100644 --- a/cmd/openai/saved_image_preview_test.go +++ b/cmd/openai/saved_image_preview_test.go @@ -128,8 +128,8 @@ func TestMainImageInlinePreferenceCommandsAreLocal(t *testing.T) { for _, mode := range []string{"off", "on", "off"} { got := runMainDispatchWithStdin(t, "bash", env, input, "openai", "images", "inline", mode) require.Zero(t, got.code, got.stderr) - require.Empty(t, got.stderr) - require.Contains(t, got.stdout, "Automatic image previews "+mode) + require.Empty(t, got.stdout) + require.Contains(t, got.stderr, "Automatic image previews "+mode) actual, err := imageprefs.Load(t.Context(), prefs) require.NoError(t, err) require.Equal(t, mode, actual) diff --git a/docs/deterministic-output.md b/docs/deterministic-output.md new file mode 100644 index 00000000..f6fac97e --- /dev/null +++ b/docs/deterministic-output.md @@ -0,0 +1,81 @@ +# Output in scripts and terminals + +The CLI uses readable text by default, including pipes and redirected files. +A filename such as `answer.json` does not select JSON. +Select the format explicitly: + +```sh +openai models list --format jsonl > models.jsonl +openai responses create --model MODEL --input "Hello" --format json > response.json +``` + +`--format` supports `auto`, `text`, `explore`, `json`, `jsonl`, `pretty`, `raw`, and `yaml`. +Explicit formats keep their existing data selection and layout. +Pipes and files receive no added ANSI colors, including when FORCE_COLOR or CLICOLOR_FORCE is set. +NO_COLOR disables terminal colors. +Requested raw strings and binary data can contain escape bytes; the CLI preserves those bytes. + +## Quiet and verbose + +`--quiet` suppresses optional hints, progress, and success receipts. +It preserves selected data, requested help, errors, and exit status. +Saved image paths remain selected data. +Quiet does not change the format or discard stdout. + +`--verbose` reports the command, format option, and command result on stderr. +The format option does not change an existing binary response into text. +It excludes argument values, credentials, URLs, headers, prompts, filenames, and response bodies. +Command completion does not imply that asynchronous API work has finished. +Quiet overrides verbose when both flags are present. +`--debug` remains a separate troubleshooting control. +`-v` still means version. There are no new JSON or raw aliases. + +Machine error formats suppress optional verbose prose. +Select text errors explicitly to combine verbose diagnostics with machine stdout: + +```sh +openai models list --format jsonl --verbose --format-error text > models.jsonl +openai models list --format jsonl --quiet > models.jsonl +``` + +## Lists and streams + +Lists retain their existing record formats. +`json` emits independent JSON values, not one JSON array. +`jsonl` emits one compact JSON value per line. +Empty structured lists emit no bytes. +`raw` retains the page envelope and its existing pagination behavior. +`yaml` retains its existing independently converted values; it does not add document separators. +Use JSONL when a consumer requires unambiguous record boundaries. + +```sh +openai files list --format jsonl --max-items 100 > files.jsonl +openai files list --format json --transform id --raw-output +``` + +`--max-items -1` remains unlimited. +`--max-items 0` emits no items; the SDK can still make its initial request. +GJSON extraction applies to each item. Missing paths retain the original item. +`--raw-output` unquotes strings; `--format raw` selects original response data. + +Noninteractive event streams emit each event as it arrives. +The CLI does not collect event streams into arrays or send them through an automatic pager. +Explicit terminal explore retains its interactive viewer. +Nonterminal explore retains its JSON fallback. +Piped stdout never opens a pager or picker. +CI does not infer a format or introduce a new output mode. + +## Failures and saved output + +A later page or stream failure leaves earlier output available and returns nonzero. +The CLI preserves source errors when an output sink also fails. +Existing handling of a lone closed stdout pipe remains unchanged. +A broken output sink cannot guarantee a complete output document. + +Binary stdout preserves every byte. +Redirecting stderr does not redirect selected stdout data. +Explicit error formats control stderr data, including API error payloads. + +Shell redirection opens or truncates its destination before the CLI starts. +The CLI cannot restore a file truncated by `>` after a failed request. +Explicit `--output` files follow the download command's save contract. diff --git a/pkg/custom/cmdutil.go b/pkg/custom/cmdutil.go index 5fb74ffa..f0e430fe 100644 --- a/pkg/custom/cmdutil.go +++ b/pkg/custom/cmdutil.go @@ -236,6 +236,9 @@ type outputWriteError struct{ error } func (e *outputWriteError) Unwrap() error { return e.error } func isOutputBrokenPipe(err error) bool { + if _, diagnostic := err.(*diagnosticWriteError); diagnostic { + return false + } if outputErr, ok := err.(*outputWriteError); ok { return strings.Contains(outputErr.Error(), "broken pipe") } @@ -410,6 +413,9 @@ func guessExtension(data []byte) string { } func shouldUseColors(w io.Writer) bool { + if !isTerminal(w) || os.Getenv("NO_COLOR") != "" { + return false + } force, ok := os.LookupEnv("FORCE_COLOR") if ok { if force == "1" { @@ -426,9 +432,11 @@ func shouldUseColors(w io.Writer) bool { // to w. Lipgloss v2 styles always render full color escapes, so the output must // be downsampled for its destination. This matches the lipgloss v1 behavior the // CLI had before: plain text when w is not a terminal or NO_COLOR is set, and -// color on a terminal. CLICOLOR_FORCE is honored, and FORCE_COLOR works the same -// way as it does for the json and jsonl formats. +// color on a terminal. Color overrides never decorate pipes or files. func prettyColorProfile(w io.Writer, environ []string) colorprofile.Profile { + if !isTerminal(w) { + return colorprofile.NoTTY + } lookup := func(name string) string { value := "" for _, kv := range environ { @@ -597,7 +605,7 @@ func showJSON(res gjson.Result, opts ShowJSONOpts, selectTransformer transformer return err } if omitted { - return readable.WriteText(out, resourceSummaryHint) + return writeOutputHint(opts, resourceSummaryHint) } return nil } @@ -605,7 +613,7 @@ func showJSON(res gjson.Result, opts ShowJSONOpts, selectTransformer transformer if isTerminal(opts.Stdout) { return jsonview.ExploreJSONWithOutput(opts.Title, res, opts.Stdout) } - if opts.ExplicitFormat { + if opts.ExplicitFormat && outputDiagnosticsAllowed(opts.Context) { if _, err := (outputWriter{ctx: opts.Context, out: opts.Stderr}).WriteString(warningExploreNotSupported); err != nil { return err } @@ -677,7 +685,7 @@ func showJSONIterator[T any](source jsonview.Iterator[T], itemsToDisplay int64, } return err } - if opts.ExplicitFormat { + if opts.ExplicitFormat && outputDiagnosticsAllowed(opts.Context) { if _, err := (outputWriter{ctx: opts.Context, out: opts.Stderr}).WriteString(warningExploreNotSupported); err != nil { return err } diff --git a/pkg/custom/cmdutil_pretty_color_test.go b/pkg/custom/cmdutil_pretty_color_test.go index e1ee25a9..aacae8ff 100644 --- a/pkg/custom/cmdutil_pretty_color_test.go +++ b/pkg/custom/cmdutil_pretty_color_test.go @@ -68,7 +68,7 @@ func TestPrettyOutputIsPlainWhenNotATerminal(t *testing.T) { } } -func TestPrettyOutputHonorsForcedColor(t *testing.T) { +func TestPrettyOutputDoesNotForceColorIntoPipes(t *testing.T) { readPipe, writePipe, err := os.Pipe() require.NoError(t, err) t.Cleanup(func() { readPipe.Close() }) @@ -79,8 +79,8 @@ func TestPrettyOutputHonorsForcedColor(t *testing.T) { env map[string]string wantColor bool }{ - {name: "FORCE_COLOR=1", env: map[string]string{"FORCE_COLOR": "1"}, wantColor: true}, - {name: "CLICOLOR_FORCE=1", env: map[string]string{"CLICOLOR_FORCE": "1"}, wantColor: true}, + {name: "FORCE_COLOR=1", env: map[string]string{"FORCE_COLOR": "1"}, wantColor: false}, + {name: "CLICOLOR_FORCE=1", env: map[string]string{"CLICOLOR_FORCE": "1"}, wantColor: false}, {name: "FORCE_COLOR=0 with CLICOLOR_FORCE=1", env: map[string]string{"FORCE_COLOR": "0", "CLICOLOR_FORCE": "1"}, wantColor: false}, {name: "NO_COLOR=1 with CLICOLOR_FORCE=1", env: map[string]string{"NO_COLOR": "1", "CLICOLOR_FORCE": "1"}, wantColor: false}, } diff --git a/pkg/custom/cmdutil_test.go b/pkg/custom/cmdutil_test.go index 4f5b4616..2bbc6ad2 100644 --- a/pkg/custom/cmdutil_test.go +++ b/pkg/custom/cmdutil_test.go @@ -760,7 +760,7 @@ func TestFormatJSONForOutputUsesFinalDestinationForColors(t *testing.T) { }{ {name: "terminal destination behind pager", stdout: writePipe, destination: os.Stdout, wantColor: true}, {name: "nonterminal final destination", stdout: os.Stdout, destination: writePipe, wantColor: false}, - {name: "forced color for nonterminal", stdout: writePipe, destination: writePipe, forceColor: "1", wantColor: true}, + {name: "forced color for nonterminal", stdout: writePipe, destination: writePipe, forceColor: "1", wantColor: false}, {name: "disabled color for terminal", stdout: writePipe, destination: os.Stdout, forceColor: "0", wantColor: false}, } diff --git a/pkg/custom/command.go b/pkg/custom/command.go index 8b049498..62b5ba52 100644 --- a/pkg/custom/command.go +++ b/pkg/custom/command.go @@ -30,6 +30,7 @@ func ConfigureCommand(root *cli.Command) { configureTaskCommands(root) configureCommandPresentation(root) configureManpageCommands(root) + configureOutputPolicy(root) ConfigureCommandErrors(root) root.Flags = append(root.Flags, mtlsClientFlags()...) previousBefore := root.Before diff --git a/pkg/custom/image_loading_feedback.go b/pkg/custom/image_loading_feedback.go index 3cec1a5a..90e3d07d 100644 --- a/pkg/custom/image_loading_feedback.go +++ b/pkg/custom/image_loading_feedback.go @@ -40,10 +40,13 @@ func runWithImageLoading(ctx context.Context, command *cli.Command, plan *imageO // Restore normal signal handling once cancellation starts. A second Ctrl-C // can still terminate the process if a terminal write or cleanup is blocked. stopReset := context.AfterFunc(ctx, stopSignals) - stopLoading := startLoadingFeedback(ctx, os.Stderr, label, loadingAnimationSupported(os.Getenv), imageLoadingSpinner(os.Getenv, runtime.GOOS), func() int { - width, _, _ := term.GetSize(os.Stderr.Fd()) - return width - }) + stopLoading := func() {} + if outputDiagnosticsAllowed(ctx) { + stopLoading = startLoadingFeedback(ctx, os.Stderr, label, loadingAnimationSupported(os.Getenv), imageLoadingSpinner(os.Getenv, runtime.GOOS), func() int { + width, _, _ := term.GetSize(os.Stderr.Fd()) + return width + }) + } plan.stopLoading = stopLoading defer func() { stopLoading() diff --git a/pkg/custom/image_models.go b/pkg/custom/image_models.go index 70511540..20ab61e5 100644 --- a/pkg/custom/image_models.go +++ b/pkg/custom/image_models.go @@ -105,7 +105,7 @@ func handleImagesModels(ctx context.Context, command *cli.Command) error { return err } client := openai.NewClient(GetDefaultRequestOptions(command)...) - if human && isTerminal(os.Stderr) && !root.Bool("debug") { + if human && isTerminal(os.Stderr) && !root.Bool("debug") && outputDiagnosticsAllowed(ctx) { if _, err := fmt.Fprintln(os.Stderr, "Checking image models..."); err != nil { return err } @@ -123,12 +123,16 @@ func handleImagesModels(ctx context.Context, command *cli.Command) error { } } if human { - if err := writeImageModels(root.Writer, report, command.Bool("all"), invocation); err != nil { + var hints io.Writer + if outputDiagnosticsAllowed(ctx) { + hints = os.Stderr + } + if err := writeImageModels(root.Writer, report, command.Bool("all"), invocation, hints); err != nil { return err } } else { // Presentation warnings use ShowJSON's stderr default, not the command error buffer. - if err := writeImageModelsData(command, report, nil); err != nil { + if err := writeImageModelsData(ctx, command, report, nil); err != nil { return err } } @@ -141,7 +145,7 @@ func handleImagesModels(ctx context.Context, command *cli.Command) error { return nil } -func writeImageModelsData(command *cli.Command, report imageModelsReport, stderr io.Writer) error { +func writeImageModelsData(ctx context.Context, command *cli.Command, report imageModelsReport, stderr io.Writer) error { root := command.Root() payload, err := json.Marshal(report) if err != nil { @@ -149,7 +153,8 @@ func writeImageModelsData(command *cli.Command, report imageModelsReport, stderr } obj := gjson.ParseBytes(payload) opts := ShowJSONOpts{ - Format: root.String("format"), ExplicitFormat: root.IsSet("format"), + Context: context.WithoutCancel(ctx), + Format: root.String("format"), ExplicitFormat: root.IsSet("format"), RawOutput: root.Bool("raw-output"), Title: "Image models", Transform: root.String("transform"), } // The report includes completed checks even after discovery is canceled. @@ -158,8 +163,9 @@ func writeImageModelsData(command *cli.Command, report imageModelsReport, stderr return ShowJSON(obj, opts) } -func writeImageModels(out io.Writer, report imageModelsReport, all bool, invocation string) error { +func writeImageModels(out io.Writer, report imageModelsReport, all bool, invocation string, hints io.Writer) error { var text strings.Builder + var guidance strings.Builder if report.Source == "offline" { text.WriteString("Known image models — access not checked (offline)\n\n") } else { @@ -189,17 +195,24 @@ func writeImageModels(out io.Writer, report imageModelsReport, all bool, invocat text.WriteString("No known, active image models were visible to this key.\n") } if hidden > 0 { - fmt.Fprintf(&text, "\n%d retired or not visible. Include them and dated versions: %s images models --all\n", hidden, invocation) + fmt.Fprintf(&text, "\n%d retired or not visible.\n", hidden) + fmt.Fprintf(&guidance, "Include them and dated versions: %s images models --all\n", invocation) } if choice != "" { - fmt.Fprintf(&text, "\nChoose a model:\n %s images generate --prompt \"A tiny orange robot\" --model %s\n", invocation, choice) + fmt.Fprintf(&guidance, "Choose a model:\n %s images generate --prompt \"A tiny orange robot\" --model %s\n", invocation, choice) } text.WriteString("\nKnown image IDs from this CLI; newly released models may not be listed.\n") if report.Source == "live" { text.WriteString("Visible means model information was accessible; generation permissions can differ.\n") } - return readable.WriteText(out, text.String()) + if err := readable.WriteText(out, text.String()); err != nil { + return err + } + if hints != nil && guidance.Len() > 0 { + return readable.WriteText(hints, guidance.String()) + } + return nil } func imageModelStatusText(model imagemodels.Result) string { diff --git a/pkg/custom/image_models_test.go b/pkg/custom/image_models_test.go index 68098fe8..e614c73d 100644 --- a/pkg/custom/image_models_test.go +++ b/pkg/custom/image_models_test.go @@ -31,7 +31,7 @@ func TestImageModelsPresentationPartialAndHidden(t *testing.T) { imageModelTestRow("dall-e-2", imagemodels.StatusRetired, ""), }} var out strings.Builder - if err := writeImageModels(&out, report, false, "openai"); err != nil { + if err := writeImageModels(&out, report, false, "openai", &out); err != nil { t.Fatal(err) } for _, want := range []string{"gpt-image-2.5-sunburst", "gpt-image-2.5-flare", "Could not check (timeout)", "2 retired or not visible", "openai images models --all", `openai images generate --prompt "A tiny orange robot" --model gpt-image-2.5-sunburst`, "generation permissions can differ"} { @@ -45,7 +45,7 @@ func TestImageModelsPresentationPartialAndHidden(t *testing.T) { } } out.Reset() - if err := writeImageModels(&out, report, true, "openai"); err != nil { + if err := writeImageModels(&out, report, true, "openai", &out); err != nil { t.Fatal(err) } if !strings.Contains(out.String(), "gpt-image-1") || !strings.Contains(out.String(), "dall-e-2") { @@ -62,7 +62,7 @@ func TestImageModelsSuggestsOnlyVisibleOrUncheckedNames(t *testing.T) { } for _, all := range []bool{false, true} { var out strings.Builder - if err := writeImageModels(&out, report, all, "openai"); err != nil { + if err := writeImageModels(&out, report, all, "openai", &out); err != nil { t.Fatal(err) } eligible := status == imagemodels.StatusVisible || status == imagemodels.StatusNotChecked @@ -165,7 +165,7 @@ func TestImageModelsWriteErrors(t *testing.T) { if expected == nil { expected = io.ErrShortWrite } - if got := writeImageModels(imageModelsFailWriter{failure}, imageModelsReport{}, false, "openai"); !errors.Is(got, expected) { + if got := writeImageModels(imageModelsFailWriter{failure}, imageModelsReport{}, false, "openai", nil); !errors.Is(got, expected) { t.Fatalf("error=%v; want %v", got, expected) } } @@ -270,7 +270,7 @@ func TestImageModelsDataFormatsAndExtraction(t *testing.T) { func imageModelsOutputCommand(out, stderr io.Writer, report imageModelsReport) *cli.Command { return &cli.Command{Name: "openai", Writer: out, Flags: []cli.Flag{ &cli.StringFlag{Name: "format", Value: "auto"}, &cli.StringFlag{Name: "transform"}, &cli.BoolFlag{Name: "raw-output"}, - }, Action: func(_ context.Context, command *cli.Command) error { - return writeImageModelsData(command, report, stderr) + }, Action: func(ctx context.Context, command *cli.Command) error { + return writeImageModelsData(ctx, command, report, stderr) }} } diff --git a/pkg/custom/image_picker_shell_profile_windows_test.go b/pkg/custom/image_picker_shell_profile_windows_test.go index 1fe4aa15..87d52b7c 100644 --- a/pkg/custom/image_picker_shell_profile_windows_test.go +++ b/pkg/custom/image_picker_shell_profile_windows_test.go @@ -87,9 +87,10 @@ func TestImagePickerWindowsShellHomeExplicitOverride(t *testing.T) { original := []byte("# shell startup\n") require.NoError(t, os.WriteFile(profile, original, 0600)) require.NoError(t, os.WriteFile(otherProfile, []byte("# unrelated native profile\n"), 0600)) - output, err := runPickerShellSetup(t, t.Context(), "bash", "--install-picker", "--profile", profile) + output, diagnostic, err := runPickerShellSetupWithDiagnostics(t, t.Context(), "bash", "--install-picker", "--profile", profile) require.NoError(t, err) - require.Contains(t, output, "saved for future terminals") + require.Empty(t, output) + require.Equal(t, "Tab setup saved for future terminals. Existing custom bindings are preserved.\n", diagnostic) installed, err := os.ReadFile(profile) require.NoError(t, err) require.Contains(t, string(installed), "# >>> openai image picker") diff --git a/pkg/custom/image_picker_shell_setup.go b/pkg/custom/image_picker_shell_setup.go index 328a633d..72fe960d 100644 --- a/pkg/custom/image_picker_shell_setup.go +++ b/pkg/custom/image_picker_shell_setup.go @@ -3,7 +3,6 @@ package custom import ( "context" "errors" - "fmt" "os" "path/filepath" "slices" @@ -161,8 +160,7 @@ func configureImagePickerShellSetup(root *cli.Command) { } else if remove { message = "Tab shortcut setup removed. Open a new terminal to finish." } - _, err = fmt.Fprintln(command.Root().Writer, message) - return err + return writeOutputReceipt(ctx, message) } } diff --git a/pkg/custom/image_picker_shell_setup_test.go b/pkg/custom/image_picker_shell_setup_test.go index 3d192945..3e29c05a 100644 --- a/pkg/custom/image_picker_shell_setup_test.go +++ b/pkg/custom/image_picker_shell_setup_test.go @@ -29,16 +29,40 @@ func pickerShellSetupHome(t *testing.T) string { func runPickerShellSetup(t *testing.T, ctx context.Context, args ...string) (string, error) { t.Helper() - var output, diagnostics bytes.Buffer + output, _, err := runPickerShellSetupWithDiagnostics(t, ctx, args...) + return output, err +} + +// Tests call this serially because receipts use the actual process stderr. +func runPickerShellSetupWithDiagnostics(t *testing.T, ctx context.Context, args ...string) (string, string, error) { + t.Helper() + diagnostics, err := os.CreateTemp(t.TempDir(), "picker-setup-stderr-") + require.NoError(t, err) + previousStderr := os.Stderr + os.Stderr = diagnostics + closed := false + defer func() { + os.Stderr = previousStderr + if !closed { + require.NoError(t, diagnostics.Close()) + } + }() + var output bytes.Buffer root := &cli.Command{ - Name: "openai", Writer: &output, ErrWriter: &diagnostics, + Name: "openai", Writer: &output, ErrWriter: diagnostics, ExitErrHandler: func(context.Context, *cli.Command, error) {}, Commands: []*cli.Command{{Name: "@completion", Action: autocomplete.OutputCompletionScript}}, } configureImagePickerCompletion(root) configureImagePickerShellSetup(root) - err := root.Run(ctx, append([]string{"openai", "@completion"}, args...)) - return output.String(), err + err = root.Run(ctx, append([]string{"openai", "@completion"}, args...)) + os.Stderr = previousStderr + closeErr := diagnostics.Close() + closed = true + require.NoError(t, closeErr) + diagnosticBytes, readErr := os.ReadFile(diagnostics.Name()) + require.NoError(t, readErr) + return output.String(), string(diagnosticBytes), err } func TestImagePickerShellSetupInstallRemoveExplicitProfile(t *testing.T) { @@ -50,9 +74,10 @@ func TestImagePickerShellSetupInstallRemoveExplicitProfile(t *testing.T) { require.NoError(t, os.WriteFile(profile, original, 0600)) require.NoError(t, declineImagePickerTab(t.Context(), shell)) for range 2 { - output, err := runPickerShellSetup(t, t.Context(), shell, "--install-picker", "--profile", profile) + output, diagnostic, err := runPickerShellSetupWithDiagnostics(t, t.Context(), shell, "--install-picker", "--profile", profile) require.NoError(t, err) - require.Contains(t, output, "for future terminals") + require.Empty(t, output) + require.Equal(t, "Tab setup saved for future terminals. Existing custom bindings are preserved.\n", diagnostic) targets, err := imagePickerShellTarget(t.Context(), shell, false, profile) require.NoError(t, err) installed, err := autocomplete.IsPickerInstalled(t.Context(), targets[0]) @@ -67,9 +92,10 @@ func TestImagePickerShellSetupInstallRemoveExplicitProfile(t *testing.T) { require.False(t, declined, "explicit installation must clear an old opt-out") } for range 2 { - output, err := runPickerShellSetup(t, t.Context(), shell, "--uninstall-picker", "--profile", profile) + output, diagnostic, err := runPickerShellSetupWithDiagnostics(t, t.Context(), shell, "--uninstall-picker", "--profile", profile) require.NoError(t, err) - require.Contains(t, output, "setup removed") + require.Empty(t, output) + require.Equal(t, "Tab shortcut setup removed. Open a new terminal to finish.\n", diagnostic) data, err := os.ReadFile(profile) require.NoError(t, err) require.Equal(t, original, data) @@ -90,13 +116,14 @@ func TestImagePickerShellSetupRejectsPowerShellWithoutWriting(t *testing.T) { if explicit { args = append(args, "--profile", profile) } - output, err := runPickerShellSetup(t, t.Context(), args...) + output, diagnostic, err := runPickerShellSetupWithDiagnostics(t, t.Context(), args...) var exit cli.ExitCoder require.ErrorAs(t, err, &exit) require.Equal(t, 2, exit.ExitCode()) require.Contains(t, err.Error(), "PowerShell uses normal Tab completion") require.Contains(t, err.Error(), "openai images generate") require.Empty(t, output) + require.Empty(t, diagnostic) entries, err := os.ReadDir(home) require.NoError(t, err) require.Empty(t, entries) @@ -107,9 +134,10 @@ func TestImagePickerShellSetupRejectsPowerShellWithoutWriting(t *testing.T) { func TestImagePickerShellSetupAutomaticPowerShellIsQuietAndDoesNotWrite(t *testing.T) { home := pickerShellSetupHome(t) t.Setenv("SHELL", filepath.Join(home, "pwsh")) - output, err := runPickerShellSetup(t, t.Context(), "--install-picker", "--automatic") + output, diagnostic, err := runPickerShellSetupWithDiagnostics(t, t.Context(), "--install-picker", "--automatic") require.NoError(t, err) require.Empty(t, output) + require.Empty(t, diagnostic) entries, err := os.ReadDir(home) require.NoError(t, err) require.Empty(t, entries) @@ -151,9 +179,10 @@ func TestImagePickerShellSetupBashRemovalAfterLoginPrecedenceChanges(t *testing. unmanaged, err := os.Stat(filepath.Join(home, ".bash_profile")) require.NoError(t, err) for range 2 { - output, err := runPickerShellSetup(t, t.Context(), "bash", "--uninstall-picker") + output, diagnostic, err := runPickerShellSetupWithDiagnostics(t, t.Context(), "bash", "--uninstall-picker") require.NoError(t, err) - require.Contains(t, output, "setup removed") + require.Empty(t, output) + require.Equal(t, "Tab shortcut setup removed. Open a new terminal to finish.\n", diagnostic) for name, original := range originals { data, err := os.ReadFile(filepath.Join(home, name)) require.NoError(t, err) @@ -207,9 +236,10 @@ func TestImagePickerShellSetupPartialFailureIsRetryable(t *testing.T) { modified := []byte("# >>> openai image picker modified\n") require.NoError(t, os.WriteFile(interactive, original, 0600)) require.NoError(t, os.WriteFile(login, modified, 0600)) - output, err := runPickerShellSetup(t, t.Context(), "bash", "--install-picker") + output, diagnostic, err := runPickerShellSetupWithDiagnostics(t, t.Context(), "bash", "--install-picker") require.ErrorContains(t, err, "Some startup files may already be configured") require.Empty(t, output) + require.Empty(t, diagnostic) data, err := os.ReadFile(interactive) require.NoError(t, err) require.Contains(t, string(data), "# >>> openai image picker v1 >>>") @@ -261,9 +291,10 @@ func TestImagePickerShellSetupBashRemovalContinuesAfterUnrelatedFailure(t *testi modified := "# >>> openai image picker modified\n" require.NoError(t, os.WriteFile(filepath.Join(home, ".bash_login"), []byte(modified), 0600)) for range 2 { - output, err := runPickerShellSetup(t, t.Context(), "bash", "--uninstall-picker") + output, diagnostic, err := runPickerShellSetupWithDiagnostics(t, t.Context(), "bash", "--uninstall-picker") require.ErrorContains(t, err, "Could not finish Tab shortcut setup") require.Empty(t, output, "partial cleanup must not claim success") + require.Empty(t, diagnostic, "partial cleanup must not print a success receipt") data, readErr := os.ReadFile(profile) require.NoError(t, readErr) require.Equal(t, original, string(data), "an independent unsafe file must not stop intact profile cleanup") @@ -315,9 +346,10 @@ func TestImagePickerShellSetupRemovalRetainsPreferenceAndCleanupFailures(t *test preference, err := imagePickerTabChoicePath("zsh") require.NoError(t, err) require.NoError(t, os.MkdirAll(preference, 0700)) - output, err := runPickerShellSetup(t, t.Context(), "zsh", "--uninstall-picker") + output, diagnostic, err := runPickerShellSetupWithDiagnostics(t, t.Context(), "zsh", "--uninstall-picker") require.Error(t, err) require.Empty(t, output) + require.Empty(t, diagnostic) var failure *imageSavingError require.ErrorAs(t, err, &failure) require.ErrorContains(t, failure.cause, "invalid Tab shortcut preference") @@ -359,9 +391,10 @@ func TestImagePickerShellSetupUnknownShellAndAutomaticHomeDoNotWrite(t *testing. for _, args := range [][]string{{"unknown", "--install-picker"}, {"--automatic", "--install-picker"}} { home := pickerShellSetupHome(t) t.Setenv("SHELL", "/bin/zsh") - output, err := runPickerShellSetup(t, t.Context(), args...) + output, diagnostic, err := runPickerShellSetupWithDiagnostics(t, t.Context(), args...) require.Error(t, err) require.NotContains(t, output, "enabled") + require.Empty(t, diagnostic) entries, err := os.ReadDir(home) require.NoError(t, err) require.Empty(t, entries) @@ -427,10 +460,12 @@ func TestImagePickerShellSetupCancellationWhileAnotherSetupHoldsLock(t *testing. func TestImagePickerShellSetupPreservesCompletionOutput(t *testing.T) { home := pickerShellSetupHome(t) - normal, err := runPickerShellSetup(t, t.Context(), "zsh") + normal, normalDiagnostic, err := runPickerShellSetupWithDiagnostics(t, t.Context(), "zsh") require.NoError(t, err) - picker, err := runPickerShellSetup(t, t.Context(), "zsh", "--picker") + require.Empty(t, normalDiagnostic) + picker, pickerDiagnostic, err := runPickerShellSetupWithDiagnostics(t, t.Context(), "zsh", "--picker") require.NoError(t, err) + require.Empty(t, pickerDiagnostic) require.NotEmpty(t, normal) require.True(t, strings.HasPrefix(picker, normal)) require.Greater(t, len(picker), len(normal)) diff --git a/pkg/custom/image_preferences.go b/pkg/custom/image_preferences.go index bed64dec..a2bc0cc3 100644 --- a/pkg/custom/image_preferences.go +++ b/pkg/custom/image_preferences.go @@ -9,7 +9,6 @@ import ( "strings" "github.com/openai/openai-cli/internal/imageprefs" - "github.com/openai/openai-cli/internal/readable" "github.com/urfave/cli/v3" ) @@ -62,7 +61,7 @@ func imageInlinePreferenceCommands() []*cli.Command { if mode == "on" { message += "\nOn also permits image-font activation in local Apple Terminal." } - return readable.WriteText(imageCommandWriter(command), message) + return writeOutputReceipt(ctx, message) }, }) } diff --git a/pkg/custom/image_saving_commands.go b/pkg/custom/image_saving_commands.go index 40541d7d..17b59e64 100644 --- a/pkg/custom/image_saving_commands.go +++ b/pkg/custom/image_saving_commands.go @@ -172,7 +172,7 @@ func imageSavingWorkflow(next cli.ActionFunc) cli.ActionFunc { if err := runWithImageLoading(ctx, command, plan, next); err != nil { return err } - if warnPreference { + if warnPreference && outputDiagnosticsAllowed(ctx) { // Wait until presentation succeeds so this plain-text warning cannot // mix with a structured command error. ErrWriter buffers failures. return readable.WriteText(outputWriter{ctx: ctx, out: os.Stderr}, "Could not read the inline preference; automatic previews are off for this command. Use --inline auto, on or off to override it, or check your image-preferences.json settings.") diff --git a/pkg/custom/output_policy.go b/pkg/custom/output_policy.go new file mode 100644 index 00000000..d1474641 --- /dev/null +++ b/pkg/custom/output_policy.go @@ -0,0 +1,124 @@ +package custom + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "slices" + "strings" + + "github.com/openai/openai-cli/internal/readable" + "github.com/urfave/cli/v3" +) + +type outputPolicyKey struct{} + +type outputPolicy struct { + quiet bool + diagnostics bool +} + +// Diagnostic failures belong to stderr, even inside stdout's stream lifecycle. +type diagnosticWriteError struct{ error } + +func (e *diagnosticWriteError) Unwrap() error { return e.error } + +func quietOutput(ctx context.Context) bool { + if ctx == nil { + return false + } + policy, _ := ctx.Value(outputPolicyKey{}).(outputPolicy) + return policy.quiet +} + +// Optional prose must never precede a selected machine-readable error payload. +func outputDiagnosticsAllowed(ctx context.Context) bool { + if ctx == nil { + return true + } + policy, ok := ctx.Value(outputPolicyKey{}).(outputPolicy) + return !ok || policy.diagnostics +} + +func outputPolicyContext(ctx context.Context, command *cli.Command) context.Context { + root := command.Root() + return context.WithValue(ctx, outputPolicyKey{}, outputPolicy{ + quiet: root.Bool("quiet"), + diagnostics: !root.Bool("quiet") && errorOutputFormat(root) == "text" && + root.String("transform-error") == "", + }) +} + +func writeOutputHint(opts ShowJSONOpts, message string) error { + if !outputDiagnosticsAllowed(opts.Context) { + return nil + } + if err := readable.WriteText(outputWriter{ctx: opts.Context, out: opts.Stderr}, message); err != nil { + return &diagnosticWriteError{err} + } + return nil +} + +func writeOutputReceipt(ctx context.Context, message string) error { + if !outputDiagnosticsAllowed(ctx) { + return nil + } + return readable.WriteText(outputWriter{ctx: ctx, out: os.Stderr}, message) +} + +func configureOutputPolicy(root *cli.Command) { + root.Flags = append(root.Flags, + &cli.BoolFlag{Name: "quiet", Usage: "Suppress optional feedback. Keep data, help, errors and exit status. Overrides verbose.", HideDefault: true}, + &cli.BoolFlag{Name: "verbose", Usage: "Report command details on stderr. Omitted for machine errors; use --format-error text to enable.", HideDefault: true}, + ) + var visit func(*cli.Command, []string) + visit = func(command *cli.Command, path []string) { + if command.Name == "__complete" || command.Name == "help" { + return + } + if command != root { + path = append(slices.Clone(path), command.Name) + } + if command.Action != nil { + next := command.Action + // Capture declaration names, never the invoked executable or arguments. + label := strings.Join(path, " ") + protocol := command.Name == "@completion" + command.Action = func(ctx context.Context, command *cli.Command) error { + root := command.Root() + ctx = outputPolicyContext(ctx, command) + err := next(ctx, command) + if protocol && !command.Bool("install-picker") && !command.Bool("uninstall-picker") || + !root.Bool("verbose") || !outputDiagnosticsAllowed(ctx) { + return err + } + format := strings.ToLower(root.String("format")) + if format == "" { + format = "auto" + } + if !slices.Contains(OutputFormats, format) { + format = "unknown" + } + return errors.Join(err, writeVerboseResult(os.Stderr, label, format, err)) + } + } + for _, child := range command.Commands { + visit(child, path) + } + } + visit(root, nil) +} + +// Only static command declarations and validated format names reach this sink. +// An action returning nil confirms command completion, not asynchronous API work. +func writeVerboseResult(out io.Writer, command, format string, failure error) error { + result := "completed" + if failure != nil { + result = "failed" + } + _, err := fmt.Fprintf(outputWriter{ctx: context.Background(), out: out}, + "Command: %s\nFormat option: %s\nCommand result: %s\n", command, format, result) + return err +} diff --git a/pkg/custom/output_policy_test.go b/pkg/custom/output_policy_test.go new file mode 100644 index 00000000..dd115378 --- /dev/null +++ b/pkg/custom/output_policy_test.go @@ -0,0 +1,123 @@ +package custom + +import ( + "bytes" + "context" + "errors" + "io" + "strings" + "testing" + + "github.com/urfave/cli/v3" +) + +func TestOutputPolicyPreservesDataAndFailures(t *testing.T) { + for _, flags := range [][]string{{}, {"--quiet"}, {"--quiet", "--verbose"}, {"--quiet=false"}} { + t.Run(strings.Join(flags, " "), func(t *testing.T) { + var output bytes.Buffer + cause := cli.Exit("synthetic action failure", 27) + var quiet bool + root := &cli.Command{Name: "openai", Writer: &output, ExitErrHandler: func(context.Context, *cli.Command, error) {}, + Flags: []cli.Flag{&cli.StringFlag{Name: "format"}, &cli.StringFlag{Name: "format-error"}}, + Commands: []*cli.Command{{Name: "synthetic", Action: func(ctx context.Context, command *cli.Command) error { + quiet = quietOutput(ctx) + io.WriteString(command.Root().Writer, "selected\x00\xffdata\n") + return cause + }}}, + } + configureOutputPolicy(root) + args := append([]string{"openai"}, flags...) + err := root.Run(context.Background(), append(args, "synthetic")) + if !errors.Is(err, cause) || output.String() != "selected\x00\xffdata\n" { + t.Fatalf("selected bytes or failure changed: %q, %v", output.String(), err) + } + wantQuiet := len(flags) > 0 && flags[0] == "--quiet" + if quiet != wantQuiet { + t.Fatalf("quiet=%v, want %v", quiet, wantQuiet) + } + if quietOutput(context.Background()) { + t.Fatal("output policy escaped its command context") + } + }) + } +} + +func TestOutputPolicyProtectsMachineErrors(t *testing.T) { + for _, tc := range []struct { + flags []string + allow bool + }{ + {nil, true}, + {[]string{"--format", "json"}, false}, + {[]string{"--format", "jsonl"}, false}, + {[]string{"--format", "raw"}, false}, + {[]string{"--format", "yaml"}, false}, + {[]string{"--format", "json", "--format-error", "text"}, true}, + {[]string{"--format-error", "json"}, false}, + {[]string{"--transform-error", "message"}, false}, + {[]string{"--quiet"}, false}, + } { + t.Run(strings.Join(tc.flags, " "), func(t *testing.T) { + root := &cli.Command{Name: "openai", Flags: []cli.Flag{ + &cli.StringFlag{Name: "format"}, &cli.StringFlag{Name: "format-error"}, &cli.StringFlag{Name: "transform-error"}, + }, Commands: []*cli.Command{{Name: "synthetic", Action: func(ctx context.Context, _ *cli.Command) error { + if got := outputDiagnosticsAllowed(ctx); got != tc.allow { + t.Errorf("diagnostics allowed=%v, want %v", got, tc.allow) + } + return nil + }}}} + configureOutputPolicy(root) + args := append([]string{"openai"}, tc.flags...) + if err := root.Run(context.Background(), append(args, "synthetic")); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestVerboseResultNeverIncludesFailureDetails(t *testing.T) { + var output bytes.Buffer + failure := errors.New("synthetic-secret https://example.invalid/?token=synthetic prompt=private") + if err := writeVerboseResult(&output, "models list", "json", failure); err != nil { + t.Fatal(err) + } + if output.String() != "Command: models list\nFormat option: json\nCommand result: failed\n" { + t.Fatalf("unexpected verbose output: %q", output.String()) + } + cause := errors.New("synthetic sink failure") + if err := writeVerboseResult(outputPolicyFailureWriter{cause}, "models list", "json", nil); !errors.Is(err, cause) { + t.Fatalf("diagnostic write failure disappeared: %v", err) + } +} + +func TestOutputPolicyReachesLocalReceiptActions(t *testing.T) { + for _, args := range [][]string{ + {"--quiet", "@completion", "install", "picker"}, + {"@completion", "--quiet", "install", "picker"}, + {"@completion", "install", "picker", "--quiet"}, + {"--quiet", "@manpages"}, + } { + t.Run(strings.Join(args, " "), func(t *testing.T) { + calls := 0 + action := func(ctx context.Context, _ *cli.Command) error { + calls++ + if !quietOutput(ctx) || outputDiagnosticsAllowed(ctx) { + t.Error("local receipt action lost quiet policy") + } + return nil + } + root := &cli.Command{Name: "openai", Commands: []*cli.Command{ + {Name: "@manpages", Action: action}, + {Name: "@completion", Commands: []*cli.Command{{Name: "install", Commands: []*cli.Command{{Name: "picker", Action: action}}}}}, + }} + configureOutputPolicy(root) + if err := root.Run(context.Background(), append([]string{"openai"}, args...)); err != nil || calls != 1 { + t.Fatalf("local dispatch changed: calls=%d, error=%v", calls, err) + } + }) + } +} + +type outputPolicyFailureWriter struct{ err error } + +func (w outputPolicyFailureWriter) Write([]byte) (int, error) { return 0, w.err } diff --git a/pkg/custom/readable_errors.go b/pkg/custom/readable_errors.go index 8fe82eb2..b06a0300 100644 --- a/pkg/custom/readable_errors.go +++ b/pkg/custom/readable_errors.go @@ -113,6 +113,7 @@ func ShowCommandError(root *cli.Command, failure error, out io.Writer) error { value = gjson.ParseBytes(data) } return ShowJSON(value, ShowJSONOpts{ + Context: outputPolicyContext(context.Background(), root), Operation: "", OutputKind: OutputUnspecified, Format: format, ExplicitFormat: root.IsSet("format-error") || root.IsSet("format"), Stdout: out, Stderr: out, Title: "Error", Transform: root.String("transform-error"), diff --git a/pkg/custom/readable_output.go b/pkg/custom/readable_output.go index 42d4eb2a..6a31433a 100644 --- a/pkg/custom/readable_output.go +++ b/pkg/custom/readable_output.go @@ -132,7 +132,7 @@ func showReadableIterator(iter jsonview.Iterator[outputJSON], opts ShowJSONOpts) emitted = true } if omitted { - if err := readable.WriteText(out, resourceSummaryHint); err != nil { + if err := writeOutputHint(opts, resourceSummaryHint); err != nil { iterErr := iter.Err() // Closing stdout may suppress an output-only broken pipe, but must // never turn a failed page fetch into a successful command. diff --git a/pkg/custom/resource_summaries_test.go b/pkg/custom/resource_summaries_test.go index fc06f006..2844d87d 100644 --- a/pkg/custom/resource_summaries_test.go +++ b/pkg/custom/resource_summaries_test.go @@ -26,27 +26,26 @@ func TestResourceSummaryNoticePreservesFinalErrors(t *testing.T) { source := &transformTestIterator{items: []any{item, item}, err: upstreamErr} var output bytes.Buffer writer := resourceSummaryTestWriter(func(p []byte) (int, error) { - if strings.Contains(string(p), hint) { - require.Equal(t, 3, source.calls, "notice must follow the whole list") - return 0, tc.err - } - return output.Write(p) + require.Equal(t, hint+"\n", string(p), "stderr must contain only the final notice") + require.Equal(t, 3, source.calls, "notice must follow the whole list") + return 0, tc.err }) err := ShowJSONIterator(source, -1, ShowJSONOpts{ - Operation: "(resource) files > (method) list", OutputKind: OutputPageItem, Stdout: writer, + Operation: "(resource) files > (method) list", OutputKind: OutputPageItem, Stdout: &output, Stderr: writer, }) want := tc.err if want == nil { want = io.ErrShortWrite } if errors.Is(tc.err, syscall.EPIPE) { - require.NotErrorIs(t, err, syscall.EPIPE, "a broken hint pipe must not make the page error suppressible") + require.ErrorIs(t, err, syscall.EPIPE, "stderr failure must remain beside the page failure") require.False(t, isOutputBrokenPipe(err)) } else { require.ErrorIs(t, err, want) } require.ErrorIs(t, err, upstreamErr) require.Equal(t, 2, strings.Count(output.String(), "ID: file_summary\n")) + require.NotContains(t, output.String(), hint) }) } @@ -59,9 +58,9 @@ func TestResourceSummaryNoticePreservesFinalErrors(t *testing.T) { ctx, cancel := context.WithCancel(t.Context()) defer cancel() source := &resourceSummaryEndIterator{transformTestIterator: transformTestIterator{items: []any{item, item}, err: pageErr}, finish: cancel} - var output bytes.Buffer + var output, diagnostic bytes.Buffer err := ShowJSONIterator(source, -1, ShowJSONOpts{ - Context: ctx, Operation: "(resource) files > (method) list", OutputKind: OutputPageItem, Stdout: &output, + Context: ctx, Operation: "(resource) files > (method) list", OutputKind: OutputPageItem, Stdout: &output, Stderr: &diagnostic, }) require.ErrorIs(t, err, context.Canceled) if pageErr != nil { @@ -69,6 +68,7 @@ func TestResourceSummaryNoticePreservesFinalErrors(t *testing.T) { } require.Equal(t, 2, strings.Count(output.String(), "ID: file_summary\n")) require.NotContains(t, output.String(), hint) + require.Empty(t, diagnostic.String()) }) } } From c9817b1e32b6ba41a0eee27c0c16d172f6a0d0a3 Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Thu, 8 Oct 2026 01:22:12 -0700 Subject: [PATCH 15/44] Recognize complete root help in dispatch test --- cmd/openai/main_dispatch_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/openai/main_dispatch_test.go b/cmd/openai/main_dispatch_test.go index 93d38857..f9c215f6 100644 --- a/cmd/openai/main_dispatch_test.go +++ b/cmd/openai/main_dispatch_test.go @@ -127,7 +127,7 @@ func TestMainDispatchOrdinaryArguments(t *testing.T) { func TestMainDispatchEmptyArguments(t *testing.T) { want := runMainDispatch(t, "bash", "openai", "--help") - if want.code != 0 || want.stderr != "" || !strings.Contains(want.stdout, "OpenAI CLI") { + if want.code != 0 || want.stderr != "" || !strings.HasPrefix(want.stdout, "NAME:\n openai\n") { t.Fatalf("root help control failed: %+v", want) } for _, argv := range [][]string{nil, {"openai"}, {""}, {"__complete"}, {"openai", "", "--help"}} { From fced6ab1bb19d30f168ad5b15aafa9ca3ac46c36 Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Thu, 8 Oct 2026 01:41:06 -0700 Subject: [PATCH 16/44] test(output): add reproducible pipe and verbosity demos --- scripts/demos/output/README.md | 52 +++++++++ scripts/demos/output/arrival.py | 21 ++++ scripts/demos/output/fixtures.json | 7 ++ scripts/demos/output/main.go | 162 +++++++++++++++++++++++++++++ scripts/demos/output/record.sh | 64 ++++++++++++ scripts/demos/output/scene.sh | 62 +++++++++++ scripts/demos/output/validate.py | 79 ++++++++++++++ 7 files changed, 447 insertions(+) create mode 100644 scripts/demos/output/README.md create mode 100644 scripts/demos/output/arrival.py create mode 100644 scripts/demos/output/fixtures.json create mode 100644 scripts/demos/output/main.go create mode 100644 scripts/demos/output/record.sh create mode 100644 scripts/demos/output/scene.sh create mode 100644 scripts/demos/output/validate.py diff --git a/scripts/demos/output/README.md b/scripts/demos/output/README.md new file mode 100644 index 00000000..66477ed7 --- /dev/null +++ b/scripts/demos/output/README.md @@ -0,0 +1,52 @@ +# Deterministic output comparison + +This recipe records real CLI executables against one synthetic loopback API. +The comparison contains three short scenes: + +1. The baseline sends forced ANSI color into a JSON parser and delays its first structured stream event. +2. The candidate preserves plain piped JSON and emits the first event before the delayed second event. +3. Quiet preserves selected data, while verbose keeps optional details on stderr. + +The visible examples use `openai`. +The recorder creates temporary executable links; it does not install either executable. +Every scene uses an isolated home and environment with a fake key. +The fixture rejects unexpected routes, request bodies, query parameters, and credentials. +It logs validated synthetic request fields only. + +`arrival.py` adds consumer timestamps for the demo. +The CLI itself does not add timestamps or change its event bytes. +The fixture sends its second event two seconds after its first event. +The validator compares complete original events and checks their measured arrival gap. +These measurements illustrate timing; handshake regression tests provide the separate correctness gate. + +The recipe reuses `../capture_and_render.sh` for capture, rendering, cleanup, and assembly. +It requires existing asciinema 3.2.1, agg 1.9.0, ffmpeg, ffprobe, Python 3, and Bash. +The fixture uses only the Go standard library. +No dependency installation, upload, live API call, or CLI installation occurs. + +Build the fixture into the task's evidence directory: + +```sh +GOMAXPROCS=2 go build -p 2 -o /path/to/evidence/bin/output-demo-api ./scripts/demos/output +``` + +Verify both CLI executables against their source revisions before recording. +The original baseline is `da762ffff4f35732f4720ac4db531d8d764f2cbe`. +Use an empty output directory outside Git. +Obtain the coordinator's serialized PTY window before running the recorder. + +```sh +DEMO_API_BINARY=/path/to/evidence/bin/output-demo-api \ + bash scripts/demos/output/record.sh \ + /path/to/evidence/bin/openai-before /path/to/evidence/bin/openai-after \ + "$BEFORE_SHA" "$AFTER_SHA" /path/to/evidence/demo/capture +``` + +The recorder retains individual and combined casts, GIFs, screenshots, transcripts, request records, and command statuses. +It also retains raw piped output, stderr files, source hashes, executable hashes, and tool versions. +No visible exit-status footer appears; `statuses.tsv` preserves all process results. + +Inspect all three screenshots and representative GIF frames before sharing. +Check clipping, command spacing, stream timing, and the separation of data from diagnostics. +This is an asciinema terminal replay, not native graphical-terminal or Windows validation. +Keep recordings outside Git and do not publish them without authorization. diff --git a/scripts/demos/output/arrival.py b/scripts/demos/output/arrival.py new file mode 100644 index 00000000..c2280e48 --- /dev/null +++ b/scripts/demos/output/arrival.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +"""Show consumer arrival times while retaining the exact CLI event bytes.""" +import json +import pathlib +import sys +import time + +started = time.monotonic() +arrivals = [] +with pathlib.Path("events.jsonl").open("xb") as output: + for line in sys.stdin.buffer: + elapsed = time.monotonic() - started + event = json.loads(line) + output.write(line) + arrivals.append({"seconds": elapsed, "type": event["type"]}) + print(f'{elapsed:4.2f}s {event["type"]}', flush=True) +with pathlib.Path("arrivals.json").open("x") as output: + json.dump(arrivals, output, indent=2) + output.write("\n") +if len(arrivals) != 2: + raise SystemExit("Expected exactly two synthetic events") diff --git a/scripts/demos/output/fixtures.json b/scripts/demos/output/fixtures.json new file mode 100644 index 00000000..bae26117 --- /dev/null +++ b/scripts/demos/output/fixtures.json @@ -0,0 +1,7 @@ +{ + "model": {"id":"demo-model","object":"model","created":17,"owned_by":"demo-project"}, + "events": [ + {"type":"response.output_text.delta","delta":"Hello","sequence_number":0}, + {"type":"response.completed","response":{"id":"resp_demo","status":"completed","output":[]}} + ] +} diff --git a/scripts/demos/output/main.go b/scripts/demos/output/main.go new file mode 100644 index 00000000..63380883 --- /dev/null +++ b/scripts/demos/output/main.go @@ -0,0 +1,162 @@ +// output-demo-api serves only fixed synthetic model and stream responses. +package main + +import ( + "context" + _ "embed" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "os/signal" + "sync" + "sync/atomic" + "syscall" + "time" +) + +//go:embed fixtures.json +var fixture []byte + +func main() { + if len(os.Args) != 3 { + fmt.Fprintln(os.Stderr, "usage: output-demo-api ADDRESS_FILE REQUEST_LOG") + os.Exit(2) + } + var payload struct { + Model json.RawMessage `json:"model"` + Events []json.RawMessage `json:"events"` + } + if err := json.Unmarshal(fixture, &payload); err != nil { + panic(err) + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + panic(err) + } + requests, err := os.OpenFile(os.Args[2], os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + panic(err) + } + defer func() { + if err := requests.Close(); err != nil { + panic(fmt.Errorf("close synthetic request log: %w", err)) + } + }() + var logMu sync.Mutex + var failed atomic.Bool + server := &http.Server{ReadHeaderTimeout: 5 * time.Second} + server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer synthetic-demo-key" || r.URL.RawQuery != "" { + failed.Store(true) + http.Error(w, "only the synthetic demo request is accepted", http.StatusBadRequest) + return + } + record := map[string]any{"method": r.Method, "path": r.URL.Path} + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v1/models/demo-model": + bodyBytes, bodyErr := io.Copy(io.Discard, r.Body) + if bodyErr != nil { + failed.Store(true) + http.Error(w, "could not read synthetic request body", http.StatusBadRequest) + return + } + if bodyBytes != 0 { + failed.Store(true) + http.Error(w, "unexpected synthetic GET request body", http.StatusBadRequest) + return + } + case r.Method == http.MethodPost && r.URL.Path == "/v1/responses": + var body map[string]any + decoder := json.NewDecoder(r.Body) + if err := decoder.Decode(&body); err != nil || len(body) != 3 || + body["model"] != "demo-model" || body["input"] != "Say hi" || body["stream"] != true { + failed.Store(true) + http.Error(w, "unexpected synthetic request body", http.StatusBadRequest) + return + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + failed.Store(true) + http.Error(w, "unexpected trailing request data", http.StatusBadRequest) + return + } + record["body"] = body + default: + failed.Store(true) + http.Error(w, "only the synthetic demo routes are accepted", http.StatusNotFound) + return + } + // Record validated fixture fields only. Never log request headers. + logMu.Lock() + err := json.NewEncoder(requests).Encode(record) + logMu.Unlock() + if err != nil { + failed.Store(true) + http.Error(w, "could not record synthetic request", http.StatusInternalServerError) + return + } + if r.Method == http.MethodGet { + w.Header().Set("Content-Type", "application/json") + if _, err := w.Write(payload.Model); err != nil { + failed.Store(true) + } + return + } + w.Header().Set("Content-Type", "text/event-stream") + for index, event := range payload.Events { + if index > 0 { + timer := time.NewTimer(2 * time.Second) + select { + case <-timer.C: + case <-r.Context().Done(): + timer.Stop() + failed.Store(true) + return + } + } + data, err := json.Marshal(event) + if err != nil { + failed.Store(true) + return + } + if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil { + failed.Store(true) + return + } + if err := http.NewResponseController(w).Flush(); err != nil { + failed.Store(true) + return + } + } + if _, err := io.WriteString(w, "data: [DONE]\n\n"); err != nil { + failed.Store(true) + } + }) + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + shutdownDone := make(chan error, 1) + go func() { + <-ctx.Done() + shutdown, done := context.WithTimeout(context.Background(), 3*time.Second) + defer done() + shutdownDone <- server.Shutdown(shutdown) + }() + if err := os.WriteFile(os.Args[1], []byte("http://"+listener.Addr().String()+"/v1"), 0600); err != nil { + panic(err) + } + serveErr := server.Serve(listener) + cancel() + shutdownErr := <-shutdownDone + if serveErr != nil && serveErr != http.ErrServerClosed { + panic(serveErr) + } + if shutdownErr != nil { + panic(fmt.Errorf("shut down synthetic API: %w", shutdownErr)) + } + if failed.Load() { + panic("synthetic fixture did not complete every request") + } +} diff --git a/scripts/demos/output/record.sh b/scripts/demos/output/record.sh new file mode 100644 index 00000000..91e16437 --- /dev/null +++ b/scripts/demos/output/record.sh @@ -0,0 +1,64 @@ +#!/bin/bash +set -euo pipefail + +if [ "$#" -ne 5 ]; then + echo 'usage: record.sh BEFORE_BINARY AFTER_BINARY BEFORE_SHA AFTER_SHA OUTPUT_DIR' >&2 + exit 2 +fi +demo_source="$(cd "$(dirname "$0")" && pwd)" +demo_root="$(cd "$demo_source/../../.." && pwd)" +source "$demo_source/../capture_and_render.sh" +demo_prepare_capture "$demo_root" "$1" "$2" "$3" "$4" "$5" \ + "${DEMO_API_BINARY:-$demo_root/dist/demos/bin/output-demo-api}" +demo_python="$(command -v python3)" +test "$("$demo_asciinema" --version)" = 'asciinema 3.2.1' +test "$("$demo_agg" --version)" = 'agg 1.9.0' +ln -s "$demo_python" "$demo_runtime/before/python3" +ln -s "$demo_python" "$demo_runtime/after/python3" +cp "$demo_source/scene.sh" "$demo_runtime/scene.sh" +demo_start_api "$demo_output/requests.jsonl" + +{ + echo 'feature: deterministic output and quiet/verbose' + echo "captured before commit: $demo_before_sha" + echo "captured after commit: $demo_after_sha" + echo "before binary: $demo_before" + echo "after binary: $demo_after" + echo 'fixtures: one model and two fixed events, separated by two seconds; loopback only; fake key' + echo 'capture: real PTY; isolated environment and HOME; bash --noprofile --norc' + echo 'consumer: Python json.tool parses exact piped bytes; arrival.py reports actual event arrival times' + echo 'statuses: every CLI, tee, and Python consumer result is retained in statuses.tsv' + echo 'render: asciinema 3.2.1 + agg 1.9.0 resvg; Menlo 24px; asciinema theme; 104x26; 20fps' + echo 'scope: terminal replay; no native Apple Terminal, PowerShell, or cmd.exe validation' + demo_capture_metadata + "$demo_python" --version + shasum -a 256 "$demo_source/record.sh" "$demo_source/scene.sh" "$demo_source/main.go" \ + "$demo_source/fixtures.json" "$demo_source/arrival.py" "$demo_source/validate.py" \ + "$demo_ffmpeg" "$demo_ffprobe" "$demo_python" +} > "$demo_output/metadata.txt" + +demo_window_size=104x26 +demo_render_options=(--renderer resvg --font-family Menlo --font-size 24 --line-height 1.2 \ + --theme asciinema --fps-cap 20 --last-frame-duration 3) +for demo_scene in before after controls; do + demo_command_dir="$demo_runtime/after" + demo_label='AFTER | plain pipes and immediate events' + if [ "$demo_scene" = before ]; then + demo_command_dir="$demo_runtime/before" + demo_label='BEFORE | forced color and buffered events' + elif [ "$demo_scene" = controls ]; then + demo_label='AFTER | quiet and verbose' + fi + demo_home="$demo_runtime/home-$demo_scene" + demo_data="$demo_output/data/$demo_scene" + mkdir -p "$demo_home" "$demo_data" + cp "$demo_source/arrival.py" "$demo_data/arrival.py" + demo_capture_scene "$demo_scene" 0 "$demo_command_dir" "$demo_api_url" "$demo_label" \ + FORCE_COLOR=0 "HOME=$demo_home" "XDG_CONFIG_HOME=$demo_home/config" \ + "DEMO_SCENE=$demo_scene" "DEMO_DATA_DIR=$demo_data" "DEMO_STATUS_LOG=$demo_output/statuses.tsv" +done +demo_stop_api +"$demo_python" "$demo_source/validate.py" "$demo_output" "$demo_source/fixtures.json" \ + | tee "$demo_output/validation.txt" +demo_assemble_capture 300 before after controls +printf 'Recorded deterministic-output terminal replays in %s\n' "$demo_output" diff --git a/scripts/demos/output/scene.sh b/scripts/demos/output/scene.sh new file mode 100644 index 00000000..bbb731e5 --- /dev/null +++ b/scripts/demos/output/scene.sh @@ -0,0 +1,62 @@ +#!/bin/bash +set -uo pipefail +test -t 0 && test -t 1 && test -t 2 || exit 99 +cd "$DEMO_DATA_DIR" || exit 98 + +say() { + printf "$@" || exit 98 +} + +record_status() { + printf '%s\t%s\t%s\n' "$DEMO_SCENE" "$1" "$2" >> "$DEMO_STATUS_LOG" || exit 98 + test "$2" -eq "$3" || exit 97 +} + +say '\033[2J\033[H\033[?25l' +say '%s\n' "$DEMO_SCENE_LABEL" +say '%s\n\n' 'Synthetic local API; real CLI pipes and files.' +sleep 0.5 || exit 98 + +if [ "$DEMO_SCENE" = controls ]; then + say '%s\n' 'Quiet keeps the selected data.' + say '%s\n' '$ openai --quiet models retrieve demo-model > quiet.txt 2> quiet.stderr' + if openai --quiet models retrieve demo-model > quiet.txt 2> quiet.stderr; then demo_status=0; else demo_status=$?; fi + record_status quiet "$demo_status" 0 + say '%s\n' '$ cat quiet.txt' + cat quiet.txt || exit 98 + say '\n' + sleep 1 || exit 98 + + say '%s\n' 'Verbose details stay on stderr.' + say '%s\n' '$ openai --verbose models retrieve demo-model > model.txt 2> details.txt' + if openai --verbose models retrieve demo-model > model.txt 2> details.txt; then demo_status=0; else demo_status=$?; fi + record_status verbose "$demo_status" 0 + say '%s\n' '$ cat model.txt' + cat model.txt || exit 98 + say '%s\n' '$ cat details.txt' + cat details.txt || exit 98 +else + say '%s\n' 'Piped JSON reaches a standard parser.' + say '%s\n' '$ FORCE_COLOR=1 openai --format json models retrieve demo-model \' + say '%s\n' ' | tee model.json | python3 -m json.tool' + FORCE_COLOR=1 openai --format json models retrieve demo-model | tee model.json | python3 -m json.tool + demo_pipeline=("${PIPESTATUS[@]}") + record_status json-cli "${demo_pipeline[0]}" 0 + record_status json-tee "${demo_pipeline[1]}" 0 + demo_parser_status=0 + if [ "$DEMO_SCENE" = before ]; then demo_parser_status=1; fi + record_status json-parser "${demo_pipeline[2]}" "$demo_parser_status" + say '\n' + sleep 1 || exit 98 + + say '%s\n' 'The API sends its second event two seconds later.' + say '%s\n' '$ openai --format jsonl responses create --model demo-model \' + say '%s\n' " --input 'Say hi' --stream=true | python3 arrival.py" + openai --format jsonl responses create --model demo-model --input 'Say hi' --stream=true | python3 arrival.py + demo_pipeline=("${PIPESTATUS[@]}") + record_status stream-cli "${demo_pipeline[0]}" 0 + record_status stream-consumer "${demo_pipeline[1]}" 0 + say '%s\n' 'Times above come from the Python consumer.' +fi +say '\n$ ' +sleep 3 || exit 98 diff --git a/scripts/demos/output/validate.py b/scripts/demos/output/validate.py new file mode 100644 index 00000000..18391366 --- /dev/null +++ b/scripts/demos/output/validate.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Validate parser outcomes, original records, arrival order, and output routing.""" +import json +import pathlib +import sys + + +def check(condition, message): + if not condition: + raise SystemExit(message) + + +def main(): + check(len(sys.argv) == 3, "usage: validate.py CAPTURE_DIRECTORY FIXTURES_JSON") + directory = pathlib.Path(sys.argv[1]) + fixture = json.loads(pathlib.Path(sys.argv[2]).read_text()) + before = directory / "data" / "before" + after = directory / "data" / "after" + controls = directory / "data" / "controls" + before_json = (before / "model.json").read_bytes() + check(b"\x1b" in before_json, "baseline did not reproduce forced-color pipe decoration") + try: + json.loads(before_json) + except json.JSONDecodeError: + pass + else: + raise SystemExit("baseline forced-color output unexpectedly parsed as JSON") + after_json = (after / "model.json").read_bytes() + check(b"\x1b" not in after_json, "candidate still decorates piped JSON") + check(json.loads(after_json) == fixture["model"], "candidate JSON data changed") + + for scene, path in [("before", before), ("after", after)]: + data = (path / "events.jsonl").read_bytes() + lines = data.splitlines() + check(len(lines) == 2 and [json.loads(line) for line in lines] == fixture["events"], + f"{scene}: JSONL must preserve both original records") + check(b"\x1b" not in data, f"{scene}: JSONL consumer received decoration") + arrivals = json.loads((path / "arrivals.json").read_text()) + check([item["type"] for item in arrivals] == [item["type"] for item in fixture["events"]], + f"{scene}: arrival records changed order") + gap = arrivals[1]["seconds"] - arrivals[0]["seconds"] + if scene == "before": + check(gap < 0.5, "baseline no longer groups the delayed events") + else: + check(gap >= 1.5, "candidate did not expose the first event before the delayed second event") + check((before / "events.jsonl").read_bytes() == (after / "events.jsonl").read_bytes(), + "event bytes differ between binaries") + + expected_data = b"ID: demo-model\nOwned by: demo-project\n" + check((controls / "quiet.txt").read_bytes() == expected_data, "quiet discarded or changed selected data") + check((controls / "quiet.stderr").read_bytes() == b"", "quiet printed optional feedback") + check((controls / "model.txt").read_bytes() == expected_data, "verbose contaminated stdout") + expected_details = ("Summary; use --format json for full data.\n" + "Command: models retrieve\nFormat option: auto\nCommand result: completed\n") + check((controls / "details.txt").read_text() == expected_details, "verbose stderr contract changed") + + model_request = {"method": "GET", "path": "/v1/models/demo-model"} + stream_request = {"method": "POST", "path": "/v1/responses", + "body": {"model": "demo-model", "input": "Say hi", "stream": True}} + requests = [json.loads(line) for line in (directory / "requests.jsonl").read_text().splitlines()] + check(requests == [model_request, stream_request, model_request, stream_request, model_request, model_request], + "synthetic request count, sequence, or content changed") + statuses = [] + for scene in ["before", "after"]: + statuses.extend([f"{scene}\tjson-cli\t0", f"{scene}\tjson-tee\t0", + f"{scene}\tjson-parser\t{1 if scene == 'before' else 0}", + f"{scene}\tstream-cli\t0", f"{scene}\tstream-consumer\t0"]) + statuses.extend(["controls\tquiet\t0", "controls\tverbose\t0"]) + check((directory / "statuses.tsv").read_text().splitlines() == statuses, "unexpected process statuses") + for scene in ["before", "after", "controls"]: + transcript = (directory / f"{scene}.txt").read_text() + check("$ openai" in transcript or "$ FORCE_COLOR=1 openai" in transcript, f"{scene}: missing real command") + check("[exit " not in transcript, f"{scene}: visible exit footer") + check("synthetic-demo-key" not in transcript, f"{scene}: fixture key entered the recording") + print("PASS: six synthetic requests, twelve process statuses, JSON/JSONL bytes, event timing, and stderr routing") + + +if __name__ == "__main__": + main() From 2be471504e992c64f1b743c64109451a602d50ea Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Thu, 8 Oct 2026 02:21:53 -0700 Subject: [PATCH 17/44] fix(output): skip verbose epilogues after interruption --- docs/deterministic-output.md | 2 ++ pkg/custom/output_policy.go | 6 ++++ pkg/custom/output_policy_test.go | 62 ++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+) diff --git a/docs/deterministic-output.md b/docs/deterministic-output.md index f6fac97e..2a927e9f 100644 --- a/docs/deterministic-output.md +++ b/docs/deterministic-output.md @@ -26,6 +26,8 @@ Quiet does not change the format or discard stdout. The format option does not change an existing binary response into text. It excludes argument values, credentials, URLs, headers, prompts, filenames, and response bodies. Command completion does not imply that asynchronous API work has finished. +Verbose omits its final details after cancellation, deadlines, or an existing interruption status. +The original action error and exit status remain. Quiet overrides verbose when both flags are present. `--debug` remains a separate troubleshooting control. `-v` still means version. There are no new JSON or raw aliases. diff --git a/pkg/custom/output_policy.go b/pkg/custom/output_policy.go index d1474641..37e095fa 100644 --- a/pkg/custom/output_policy.go +++ b/pkg/custom/output_policy.go @@ -94,6 +94,12 @@ func configureOutputPolicy(root *cli.Command) { !root.Bool("verbose") || !outputDiagnosticsAllowed(ctx) { return err } + // Optional feedback must not restart output after an interrupted action. + var exit cli.ExitCoder + if ctx.Err() != nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || + errors.As(err, &exit) && (exit.ExitCode() == 124 || exit.ExitCode() == 130 || exit.ExitCode() == 143) { + return err + } format := strings.ToLower(root.String("format")) if format == "" { format = "auto" diff --git a/pkg/custom/output_policy_test.go b/pkg/custom/output_policy_test.go index dd115378..4da30c8f 100644 --- a/pkg/custom/output_policy_test.go +++ b/pkg/custom/output_policy_test.go @@ -5,6 +5,7 @@ import ( "context" "errors" "io" + "os" "strings" "testing" @@ -121,3 +122,64 @@ func TestOutputPolicyReachesLocalReceiptActions(t *testing.T) { type outputPolicyFailureWriter struct{ err error } func (w outputPolicyFailureWriter) Write([]byte) (int, error) { return 0, w.err } + +type interruptedOutputTestError struct { + cause error + code int +} + +func (e *interruptedOutputTestError) Error() string { return "" } +func (e *interruptedOutputTestError) Unwrap() error { return e.cause } +func (e *interruptedOutputTestError) ExitCode() int { return e.code } + +func TestOutputPolicyInterruptedActionSkipsVerbose(t *testing.T) { + closed, err := os.CreateTemp(t.TempDir(), "closed-diagnostics") + if err != nil { + t.Fatal(err) + } + if err := closed.Close(); err != nil { + t.Fatal(err) + } + previous := os.Stderr + os.Stderr = closed + t.Cleanup(func() { os.Stderr = previous }) + for _, tc := range []struct { + name string + cause error + code int + cancelParent bool + wantWrite bool + }{ + {name: "child cancellation", cause: context.Canceled, code: 130}, + {name: "child deadline", cause: context.DeadlineExceeded, code: 124}, + {name: "joined cancellation", cause: errors.Join(errors.New("synthetic failure"), context.Canceled), code: 130}, + {name: "deadline exit code", cause: errors.New("synthetic deadline"), code: 124}, + {name: "interrupt exit code", cause: errors.New("synthetic interrupt"), code: 130}, + {name: "termination exit code", cause: errors.New("synthetic termination"), code: 143}, + {name: "parent cancellation", cause: errors.New("synthetic failure"), code: 27, cancelParent: true}, + {name: "ordinary failure keeps diagnostics", cause: errors.New("synthetic failure"), code: 27, wantWrite: true}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + failure := &interruptedOutputTestError{cause: tc.cause, code: tc.code} + root := &cli.Command{Name: "openai", ExitErrHandler: func(context.Context, *cli.Command, error) {}, + Action: func(context.Context, *cli.Command) error { + if tc.cancelParent { + cancel() + } + return failure + }, + } + configureOutputPolicy(root) + err := root.Run(ctx, []string{"openai", "--verbose"}) + var exit cli.ExitCoder + if !errors.Is(err, failure) || !errors.As(err, &exit) || exit.ExitCode() != tc.code { + t.Fatalf("action error or exit status changed: %v", err) + } + if errors.Is(err, os.ErrClosed) != tc.wantWrite { + t.Fatalf("optional verbose write ran after interruption, or ordinary diagnostics disappeared: %v", err) + } + }) + } +} From 35feacc454369dbc24d32fca425bd6ef72e8f37d Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Thu, 8 Oct 2026 02:26:00 -0700 Subject: [PATCH 18/44] test(output): cover native dispatch and interruption causes --- cmd/openai/deterministic_output_test.go | 16 ++++++++-------- cmd/openai/deterministic_output_unix_test.go | 2 +- cmd/openai/deterministic_stream_test.go | 2 +- cmd/openai/main_shell_test.go | 4 ++-- pkg/custom/output_policy_test.go | 2 ++ 5 files changed, 14 insertions(+), 12 deletions(-) diff --git a/cmd/openai/deterministic_output_test.go b/cmd/openai/deterministic_output_test.go index b8f03c29..cceebe7b 100644 --- a/cmd/openai/deterministic_output_test.go +++ b/cmd/openai/deterministic_output_test.go @@ -10,7 +10,7 @@ import ( "testing" ) -func TestMainPipedFormatsIgnoreForcedDecoration(t *testing.T) { +func TestMainDispatchOutputPipedFormatsIgnoreForcedDecoration(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") io.WriteString(w, readableModelResponse) @@ -38,7 +38,7 @@ func TestMainPipedFormatsIgnoreForcedDecoration(t *testing.T) { } } -func TestMainQuietPreservesSelectedPayloads(t *testing.T) { +func TestMainDispatchOutputQuietPreservesSelectedPayloads(t *testing.T) { const binary = "synthetic\x00\xff\x1b[31m\r\nbytes" var requests atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -85,7 +85,7 @@ func TestMainQuietPreservesSelectedPayloads(t *testing.T) { } } -func TestMainVerboseKeepsDataAndProtectsMachineErrors(t *testing.T) { +func TestMainDispatchOutputVerboseKeepsDataAndProtectsMachineErrors(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if strings.HasSuffix(r.URL.Path, "model_failure") { @@ -128,7 +128,7 @@ func TestMainVerboseKeepsDataAndProtectsMachineErrors(t *testing.T) { } } -func TestMainQuietKeepsExplicitHelpVersionAndCompletion(t *testing.T) { +func TestMainDispatchOutputQuietKeepsExplicitHelpVersionAndCompletion(t *testing.T) { for _, args := range [][]string{{"models", "retrieve", "--help"}, {"--version"}, {"-v"}, {"@completion", "bash"}} { t.Run(strings.Join(args, " "), func(t *testing.T) { ordinary := runMainDispatch(t, "bash", append([]string{"openai"}, args...)...) @@ -140,7 +140,7 @@ func TestMainQuietKeepsExplicitHelpVersionAndCompletion(t *testing.T) { } } -func TestMainVerboseBinaryKeepsBytesAndLabelsFormatOption(t *testing.T) { +func TestMainDispatchOutputVerboseBinaryKeepsBytesAndLabelsFormatOption(t *testing.T) { const payload = "synthetic-private-binary\x00\xff\x1b[31m\r\n" server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/octet-stream") @@ -153,7 +153,7 @@ func TestMainVerboseBinaryKeepsBytesAndLabelsFormatOption(t *testing.T) { } } -func TestMainQuietPreservesSavedImagePaths(t *testing.T) { +func TestMainDispatchOutputQuietPreservesSavedImagePaths(t *testing.T) { payload := imageGenerationPNG(t) var requests atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -170,7 +170,7 @@ func TestMainQuietPreservesSavedImagePaths(t *testing.T) { assertImageGenerationFiles(t, directory, got.stdout, 1, payload) } -func TestMainQuietKeepsFailurePayloadsAndSuppressesFallbackNotice(t *testing.T) { +func TestMainDispatchOutputQuietKeepsFailurePayloadsAndSuppressesFallbackNotice(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) @@ -188,7 +188,7 @@ func TestMainQuietKeepsFailurePayloadsAndSuppressesFallbackNotice(t *testing.T) } } -func TestMainRedirectedErrorsIgnoreForcedDecoration(t *testing.T) { +func TestMainDispatchOutputRedirectedErrorsIgnoreForcedDecoration(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) diff --git a/cmd/openai/deterministic_output_unix_test.go b/cmd/openai/deterministic_output_unix_test.go index a7779ff4..75416707 100644 --- a/cmd/openai/deterministic_output_unix_test.go +++ b/cmd/openai/deterministic_output_unix_test.go @@ -15,7 +15,7 @@ import ( "time" ) -func TestMainSummaryKeepsStderrPipeFailure(t *testing.T) { +func TestMainDispatchOutputSummaryKeepsStderrPipeFailure(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") io.WriteString(w, `{"object":"list","data":[{"id":"file_synthetic","object":"file","created_at":17}],"has_more":false}`) diff --git a/cmd/openai/deterministic_stream_test.go b/cmd/openai/deterministic_stream_test.go index 8c3fd212..ca56e3e9 100644 --- a/cmd/openai/deterministic_stream_test.go +++ b/cmd/openai/deterministic_stream_test.go @@ -11,7 +11,7 @@ import ( // The server cannot send its terminal event until the consumer sees output. // This tests the real stdout path, including its process-level pipe handling. -func TestMainStructuredStreamEmitsBeforeNextEvent(t *testing.T) { +func TestMainDispatchOutputStructuredStreamEmitsBeforeNextEvent(t *testing.T) { for _, format := range []string{"json", "jsonl", "yaml", "raw", "pretty", "explore"} { t.Run(format, func(t *testing.T) { release := make(chan struct{}, 1) diff --git a/cmd/openai/main_shell_test.go b/cmd/openai/main_shell_test.go index a1809da2..043fce82 100644 --- a/cmd/openai/main_shell_test.go +++ b/cmd/openai/main_shell_test.go @@ -268,7 +268,7 @@ func TestMainNativeShell(t *testing.T) { t.Run("explicit inline off preserves remembered on", func(t *testing.T) { home := t.TempDir() got := runNativeShell(t, shell, directory, home, server.URL, invocation+" images inline on") - if got.code != 0 || got.stderr != "" { + if got.code != 0 || got.stdout != "" || !strings.Contains(got.stderr, "Automatic image previews on.") { t.Fatalf("remember inline on: %+v", got) } assertNativeShellImagePreference(t, home, true) @@ -291,7 +291,7 @@ func TestMainNativeShell(t *testing.T) { defer server.Close() for _, mode := range []string{"off", "on", "off"} { got := runNativeShell(t, shell, work, home, server.URL, shell.binary+" images inline "+mode) - if got.code != 0 || got.stderr != "" || !strings.Contains(got.stdout, "Automatic image previews "+mode+".") { + if got.code != 0 || got.stdout != "" || !strings.Contains(got.stderr, "Automatic image previews "+mode+".") { t.Fatalf("remember inline %s: %+v", mode, got) } assertNativeShellImagePreference(t, home, mode == "on") diff --git a/pkg/custom/output_policy_test.go b/pkg/custom/output_policy_test.go index 4da30c8f..07c2759e 100644 --- a/pkg/custom/output_policy_test.go +++ b/pkg/custom/output_policy_test.go @@ -152,6 +152,8 @@ func TestOutputPolicyInterruptedActionSkipsVerbose(t *testing.T) { }{ {name: "child cancellation", cause: context.Canceled, code: 130}, {name: "child deadline", cause: context.DeadlineExceeded, code: 124}, + {name: "cancellation cause with ordinary exit", cause: context.Canceled, code: 27}, + {name: "deadline cause with ordinary exit", cause: context.DeadlineExceeded, code: 27}, {name: "joined cancellation", cause: errors.Join(errors.New("synthetic failure"), context.Canceled), code: 130}, {name: "deadline exit code", cause: errors.New("synthetic deadline"), code: 124}, {name: "interrupt exit code", cause: errors.New("synthetic interrupt"), code: 130}, From a41c2266eb89302d529f2174fb06e414b53accdf Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Thu, 8 Oct 2026 02:45:38 -0700 Subject: [PATCH 19/44] Update process tests for diagnostic stream routing --- cmd/openai/global_flags_test.go | 8 ++++-- cmd/openai/image_picker_shell_setup_test.go | 28 +++++++++++++-------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/cmd/openai/global_flags_test.go b/cmd/openai/global_flags_test.go index a0dc540d..7625c200 100644 --- a/cmd/openai/global_flags_test.go +++ b/cmd/openai/global_flags_test.go @@ -91,7 +91,11 @@ func TestMainGlobalFlagsPlacementHeadersAndOutput(t *testing.T) { got := runMainDispatchWithEnv(t, "bash", globalFlagsEnv(server, "OPENAI_PROJECT_ID=proj-env", "OPENAI_ORG_ID=org-env", "OPENAI_BASE_URL=http://127.0.0.1:1"), globalFlagsAt([]string{"models", "list"}, flags, position)...) - if got.code != 0 || got.stderr != "" { + wantStderr := "" + if format == "text" { + wantStderr = "Summary; use --format json for full data.\n" + } + if got.code != 0 || got.stderr != wantStderr { t.Fatalf("command failed: %+v", got) } request := globalFlagsOneRequest(t, requests) @@ -112,7 +116,7 @@ func TestMainGlobalFlagsPlacementHeadersAndOutput(t *testing.T) { t.Fatalf("JSON items changed: %q; error=%v", got.stdout, err) } case "text": - if got.stdout != "ID: model_synthetic\nOwned by: synthetic\nSummary; use --format json for full data.\n" { + if got.stdout != "ID: model_synthetic\nOwned by: synthetic\n" { t.Fatalf("text output changed: %q", got.stdout) } case "raw": diff --git a/cmd/openai/image_picker_shell_setup_test.go b/cmd/openai/image_picker_shell_setup_test.go index 8b9bdf78..f7885dfb 100644 --- a/cmd/openai/image_picker_shell_setup_test.go +++ b/cmd/openai/image_picker_shell_setup_test.go @@ -31,7 +31,7 @@ func TestMainPickerShellSetupRoundTrip(t *testing.T) { } for _, action := range []string{"--install-picker", "--install-picker", "--uninstall-picker", "--uninstall-picker"} { got := runMainDispatchWithEnv(t, shell, pickerSetupProcessEnv(home), "openai", "@completion", shell, action, "--profile", profile) - if got.code != 0 || got.stderr != "" { + if got.code != 0 || got.stdout != "" { t.Fatalf("setup failed: %+v", got) } data, err := os.ReadFile(profile) @@ -39,11 +39,11 @@ func TestMainPickerShellSetupRoundTrip(t *testing.T) { t.Fatal(err) } if action == "--install-picker" { - if !strings.Contains(got.stdout, "for future terminals") || !strings.HasPrefix(string(data), original) || strings.Count(string(data), "# >>> openai image picker") != 1 { - t.Fatalf("installation lost content or duplicated setup: %q, %q", got.stdout, data) + if got.stderr != "Tab setup saved for future terminals. Existing custom bindings are preserved.\n" || !strings.HasPrefix(string(data), original) || strings.Count(string(data), "# >>> openai image picker") != 1 { + t.Fatalf("installation lost content or duplicated setup: %q, %q", got.stderr, data) } - } else if string(data) != original || !strings.Contains(got.stdout, "setup removed") { - t.Fatalf("removal lost original content: %q, %q", got.stdout, data) + } else if string(data) != original || got.stderr != "Tab shortcut setup removed. Open a new terminal to finish.\n" { + t.Fatalf("removal lost original content: %q, %q", got.stderr, data) } } }) @@ -59,7 +59,7 @@ func TestMainPickerShellSetupBashRemovalAfterLoginPrecedenceChanges(t *testing.T t.Fatal(err) } got := runMainDispatchWithEnv(t, "bash", env, "openai", "@completion", "bash", "--install-picker") - if got.code != 0 || got.stderr != "" { + if got.code != 0 || got.stdout != "" || got.stderr != "Tab setup saved for future terminals. Existing custom bindings are preserved.\n" { t.Fatalf("installation failed: %+v", got) } if err := os.WriteFile(filepath.Join(home, ".bash_profile"), []byte(preferred), 0600); err != nil { @@ -67,7 +67,7 @@ func TestMainPickerShellSetupBashRemovalAfterLoginPrecedenceChanges(t *testing.T } for range 2 { got = runMainDispatchWithEnv(t, "bash", env, "openai", "@completion", "bash", "--uninstall-picker") - if got.code != 0 || got.stderr != "" || !strings.Contains(got.stdout, "setup removed") { + if got.code != 0 || got.stdout != "" || got.stderr != "Tab shortcut setup removed. Open a new terminal to finish.\n" { t.Fatalf("removal failed: %+v", got) } for name, want := range map[string]string{".profile": original, ".bash_profile": preferred} { @@ -93,7 +93,7 @@ func TestMainPickerShellSetupBashRemovalRestoresAbsentLoginProfile(t *testing.T) home := t.TempDir() env := pickerSetupProcessEnv(home) got := runMainDispatchWithEnv(t, "bash", env, "openai", "@completion", "bash", "--install-picker") - if got.code != 0 || got.stderr != "" { + if got.code != 0 || got.stdout != "" || got.stderr != "Tab setup saved for future terminals. Existing custom bindings are preserved.\n" { t.Fatalf("installation failed: %+v", got) } personal := "# later login settings\n" @@ -102,7 +102,7 @@ func TestMainPickerShellSetupBashRemovalRestoresAbsentLoginProfile(t *testing.T) } for range 2 { got = runMainDispatchWithEnv(t, "bash", env, "openai", "@completion", "bash", "--uninstall-picker") - if got.code != 0 || got.stderr != "" || !strings.Contains(got.stdout, "setup removed") { + if got.code != 0 || got.stdout != "" || got.stderr != "Tab shortcut setup removed. Open a new terminal to finish.\n" { t.Fatalf("removal failed: %+v", got) } for _, name := range []string{".bash_profile", ".bashrc"} { @@ -264,7 +264,7 @@ func TestMainPickerShellSetupPartialRemovalReportsFailureAfterCleanup(t *testing t.Fatal(err) } got := runMainDispatchWithEnv(t, "bash", env, "openai", "@completion", "bash", "--install-picker") - if got.code != 0 || got.stderr != "" { + if got.code != 0 || got.stdout != "" || got.stderr != "Tab setup saved for future terminals. Existing custom bindings are preserved.\n" { t.Fatalf("installation failed: %+v", got) } if err := os.Symlink(profile, filepath.Join(home, ".bash_profile")); err != nil { @@ -315,9 +315,15 @@ func TestMainPickerShellSetupIgnoresRequestConfiguration(t *testing.T) { } for _, action := range []string{spelling.install, spelling.remove} { got := runMainDispatchWithEnv(t, "zsh", env, "openai", "--format-error", "json", "@completion", "zsh", action, spelling.profile, profile) - if got.code != 0 || got.stderr != "" || got.stdout == "" { + if got.code != 0 || got.stderr != "" || got.stdout != "" { t.Fatalf("local setup depended on request configuration: %+v", got) } + if action == spelling.install { + data, err := os.ReadFile(profile) + if err != nil || strings.Count(string(data), "# >>> openai image picker") != 1 { + t.Fatalf("silent installation did not create setup: %q, %v", data, err) + } + } } if _, err := os.Lstat(profile); !os.IsNotExist(err) { t.Fatalf("setup round trip did not restore absent profile: %v", err) From d5ca5b4ee037221b07cd204c22abe95000405417 Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Thu, 8 Oct 2026 02:59:52 -0700 Subject: [PATCH 20/44] Group output controls in complete help --- cmd/openai/deterministic_output_test.go | 50 +++++++++++++++++++++++-- pkg/custom/help.go | 2 +- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/cmd/openai/deterministic_output_test.go b/cmd/openai/deterministic_output_test.go index cceebe7b..d08e170d 100644 --- a/cmd/openai/deterministic_output_test.go +++ b/cmd/openai/deterministic_output_test.go @@ -129,17 +129,59 @@ func TestMainDispatchOutputVerboseKeepsDataAndProtectsMachineErrors(t *testing.T } func TestMainDispatchOutputQuietKeepsExplicitHelpVersionAndCompletion(t *testing.T) { - for _, args := range [][]string{{"models", "retrieve", "--help"}, {"--version"}, {"-v"}, {"@completion", "bash"}} { + for _, args := range [][]string{{"--help"}, {"models", "retrieve", "--help"}, {"--version"}, {"-v"}, {"@completion", "bash"}} { t.Run(strings.Join(args, " "), func(t *testing.T) { ordinary := runMainDispatch(t, "bash", append([]string{"openai"}, args...)...) - quiet := runMainDispatch(t, "bash", append([]string{"openai", "--quiet"}, args...)...) - if ordinary.code != 0 || ordinary.stdout == "" || quiet != ordinary { - t.Fatalf("quiet changed explicit help/protocol: ordinary=%+v quiet=%+v", ordinary, quiet) + if ordinary.code != 0 || ordinary.stdout == "" || ordinary.stderr != "" { + t.Fatalf("explicit help/protocol failed: %+v", ordinary) + } + for _, flag := range []string{"--quiet", "--verbose"} { + t.Run(flag, func(t *testing.T) { + got := runMainDispatch(t, "bash", append([]string{"openai", flag}, args...)...) + if got != ordinary { + t.Fatalf("%s changed explicit help/protocol: ordinary=%+v got=%+v", flag, ordinary, got) + } + }) } }) } } +func TestMainDispatchOutputHelpGroupsLongFlagsAndPreservesVersion(t *testing.T) { + t.Run("Output group", func(t *testing.T) { + got := runMainDispatch(t, "bash", "openai", "--help") + if got.code != 0 || got.stderr != "" { + t.Fatalf("root help failed: %+v", got) + } + _, globals, ok := strings.Cut(got.stdout, "\nGLOBAL OPTIONS:") + if !ok { + t.Fatalf("root help lost global options: %q", got.stdout) + } + _, outputAndRest, ok := strings.Cut(globals, "\n Output\n") + if !ok { + t.Fatalf("root help lost the Output group: %q", globals) + } + output, _, ok := strings.Cut(outputAndRest, "\n\n Request options\n") + if !ok { + t.Fatalf("Output group boundary is missing: %q", outputAndRest) + } + for _, name := range []string{"--quiet", "--verbose"} { + // A complete definition line also rejects short aliases or value labels. + definition := "\n " + name + "\n" + if strings.Count(got.stdout, definition) != 1 || strings.Count(output, definition) != 1 { + t.Errorf("%s must appear once, long-only, inside Output: %q", name, got.stdout) + } + } + }) + t.Run("version alias", func(t *testing.T) { + version := runMainDispatch(t, "bash", "openai", "--version") + short := runMainDispatch(t, "bash", "openai", "-v") + if version.code != 0 || version.stdout == "" || version.stderr != "" || short != version { + t.Fatalf("-v must preserve --version: version=%+v short=%+v", version, short) + } + }) +} + func TestMainDispatchOutputVerboseBinaryKeepsBytesAndLabelsFormatOption(t *testing.T) { const payload = "synthetic-private-binary\x00\xff\x1b[31m\r\n" server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { diff --git a/pkg/custom/help.go b/pkg/custom/help.go index f67eef1e..c26d92fc 100644 --- a/pkg/custom/help.go +++ b/pkg/custom/help.go @@ -192,7 +192,7 @@ func setCompleteHelpContent(command *cli.Command, content clihelp.Content) { func configureHelpGroups(root *cli.Command) { groups := []clihelp.FlagGroup{ {Title: "Authentication", Names: []string{"api-key", "admin-api-key", "webhook-secret", "mtls-client-cert-file", "mtls-client-key-file"}, Owner: root}, - {Title: "Output", Names: []string{"format", "format-error", "transform", "transform-error", "raw-output"}, Owner: root}, + {Title: "Output", Names: []string{"format", "format-error", "transform", "transform-error", "raw-output", "quiet", "verbose"}, Owner: root}, {Title: "Request options", Names: []string{"organization", "project", "base-url", "header"}, Owner: root}, {Title: "Troubleshooting", Names: []string{"debug"}, Owner: root}, } From 61979e093a4826b3733d3206346e3733c2ea2ffe Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Thu, 8 Oct 2026 01:01:58 -0700 Subject: [PATCH 21/44] [files] add upload paths, metadata and download routes --- README.md | 19 +- cmd/openai/files_workflow_test.go | 388 ++++++++++++++++++ docs/files.md | 148 +++++++ pkg/custom/cmdutil.go | 3 + pkg/custom/command.go | 1 + pkg/custom/file_commands.go | 76 ++++ pkg/custom/file_commands_test.go | 162 ++++++++ pkg/custom/file_results.go | 115 ++++++ pkg/custom/file_results_test.go | 169 ++++++++ pkg/custom/local_errors.go | 2 + pkg/transformers/project_file_metadata.go | 51 +++ .../project_file_metadata_test.go | 54 +++ scripts/demos/files-workflow/README.md | 53 +++ scripts/demos/files-workflow/main.go | 154 +++++++ scripts/demos/files-workflow/record.sh | 52 +++ scripts/demos/files-workflow/scene.sh | 40 ++ scripts/demos/files-workflow/validate.py | 53 +++ 17 files changed, 1539 insertions(+), 1 deletion(-) create mode 100644 cmd/openai/files_workflow_test.go create mode 100644 docs/files.md create mode 100644 pkg/custom/file_commands.go create mode 100644 pkg/custom/file_commands_test.go create mode 100644 pkg/custom/file_results.go create mode 100644 pkg/custom/file_results_test.go create mode 100644 pkg/transformers/project_file_metadata.go create mode 100644 pkg/transformers/project_file_metadata_test.go create mode 100644 scripts/demos/files-workflow/README.md create mode 100644 scripts/demos/files-workflow/main.go create mode 100644 scripts/demos/files-workflow/record.sh create mode 100644 scripts/demos/files-workflow/scene.sh create mode 100644 scripts/demos/files-workflow/validate.py diff --git a/README.md b/README.md index d6e3a32f..e90fb494 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,19 @@ Use `--format json` or `--format jsonl` in scripts that parse API data. See [reading command results](docs/readable-output.md) for extraction, binary output, and the scope of readable presentation. +Upload a local file with an explicit purpose, inspect its metadata, then download its contents: + +```sh +openai files upload "upload space.txt" --purpose user_data +openai files get file-example +openai files download file-example --output "downloaded copy.txt" +openai files download file-example > copy.txt +``` + +Replace `file-example` with the returned file ID. Upload takes a plain path, without an added `@`. +`create`, `retrieve`, `content`, and their existing flags remain available. +See [working with files](docs/files.md) for literal filenames, output formats, and script examples. + `openai images generate --prompt "A tiny orange robot"` saves images to `~/Downloads/gpt-images/` and prints their paths, including in pipes. The CLI uses `gpt-image-2.5-sunburst` when saving without an explicit model or legacy @@ -232,7 +245,11 @@ presenting the client certificate during the proxy's own TLS handshake. ### Passing files as arguments -To pass files to your API, you can use the `@myfile.ext` syntax: +For JSON input values, `@myfile.ext` reads local file contents: + +Binary upload flags use plain paths. For example, use `files upload --file "upload space.txt" --purpose user_data`. +Here, `--file "@upload space.txt"` names an actual file starting with `@`; it does not expand another file reference. +See [working with files](docs/files.md). ```bash openai --arg @abe.jpg diff --git a/cmd/openai/files_workflow_test.go b/cmd/openai/files_workflow_test.go new file mode 100644 index 00000000..8abc81f9 --- /dev/null +++ b/cmd/openai/files_workflow_test.go @@ -0,0 +1,388 @@ +package main + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/require" +) + +const filesWorkflowID = "file-example-full-copyable-0123456789abcdefghijklmnopqrstuvwxyz" +const filesWorkflowMetadata = `{"id":"` + filesWorkflowID + `","object":"file","bytes":13,"created_at":1700000000,"filename":"upload space.txt","purpose":"user_data","status":"uploaded","extra":{"note":"preserved value"}}` + +func filesWorkflowUploadServer(t *testing.T, filename string, payload []byte) (*httptest.Server, *atomic.Int32) { + t.Helper() + requests := new(atomic.Int32) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if r.Method != http.MethodPost || r.URL.Path != "/files" || !strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data;") { + t.Errorf("unexpected upload request: %s %s, type=%q", r.Method, r.URL.Path, r.Header.Get("Content-Type")) + } + if err := r.ParseMultipartForm(1 << 20); err != nil { + t.Error(err) + w.WriteHeader(http.StatusBadRequest) + return + } + defer r.MultipartForm.RemoveAll() + if values := r.MultipartForm.Value["purpose"]; len(values) != 1 || values[0] != "user_data" { + t.Errorf("upload purpose changed: %q", values) + } + if len(r.MultipartForm.File["file"]) != 1 { + t.Errorf("expected exactly one uploaded file") + } + file, header, err := r.FormFile("file") + if err != nil { + t.Error(err) + w.WriteHeader(http.StatusBadRequest) + return + } + defer file.Close() + body, err := io.ReadAll(file) + if err != nil || string(body) != string(payload) || header.Filename != filename { + t.Errorf("upload changed: filename=%q bytes=%q error=%v", header.Filename, body, err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, filesWorkflowMetadata) + })) + t.Cleanup(server.Close) + return server, requests +} + +func TestMainFilesWorkflowUploadPaths(t *testing.T) { + for _, tc := range []struct { + name, filename string + payload []byte + args []string + }{ + {"spaces", "upload space.txt", []byte("hello files!\n"), []string{"upload", "upload space.txt", "--purpose", "user_data"}}, + {"options first", "upload space.txt", []byte("hello files!\n"), []string{"upload", "--purpose", "user_data", "upload space.txt"}}, + {"apostrophe", "upload's copy.txt", []byte("apostrophe\n"), []string{"upload", "upload's copy.txt", "--purpose", "user_data"}}, + {"literal at", "@upload space.txt", []byte("literal at\n"), []string{"upload", "@upload space.txt", "--purpose", "user_data"}}, + {"literal dash", "-upload space.txt", []byte("literal dash\n"), []string{"upload", "--purpose", "user_data", "--", "-upload space.txt"}}, + {"literal double dash", "--", []byte("literal marker\n"), []string{"upload", "--purpose", "user_data", "--", "--"}}, + {"empty", "empty.txt", []byte{}, []string{"upload", "empty.txt", "--purpose", "user_data"}}, + {"binary", "binary.bin", []byte{0, 255, 27, 13, 10, 128}, []string{"upload", "binary.bin", "--purpose", "user_data"}}, + {"upload flag", "@upload space.txt", []byte("flag literal\n"), []string{"upload", "--file", "@upload space.txt", "--purpose", "user_data"}}, + {"create flag", "@upload space.txt", []byte("legacy literal\n"), []string{"create", "--file", "@upload space.txt", "--purpose", "user_data"}}, + {"upload assigned flag", "upload space.txt", []byte("assigned\n"), []string{"upload", "--file=upload space.txt", "--purpose=user_data"}}, + {"upload repeated flag", "upload space.txt", []byte("last file wins\n"), []string{"upload", "--file", "missing.txt", "--file", "upload space.txt", "--purpose", "user_data"}}, + {"create repeated flag", "upload space.txt", []byte("last file wins\n"), []string{"create", "--file", "missing.txt", "--file", "upload space.txt", "--purpose", "user_data"}}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Chdir(t.TempDir()) + require.NoError(t, os.WriteFile(tc.filename, tc.payload, 0o600)) + server, requests := filesWorkflowUploadServer(t, tc.filename, tc.payload) + got := runReadableCommand(t, server, append([]string{"files"}, tc.args...)...) + require.Zero(t, got.code, "%+v", got) + require.Empty(t, got.stderr) + require.Contains(t, got.stdout, filesWorkflowID) + require.NotContains(t, got.stdout, "Uploaded ") + require.NotContains(t, got.stdout, "Download it:") + require.EqualValues(t, 1, requests.Load()) + }) + } +} + +func TestMainFilesWorkflowUploadValidation(t *testing.T) { + t.Chdir(t.TempDir()) + require.NoError(t, os.WriteFile("upload.txt", []byte("synthetic"), 0o600)) + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + w.WriteHeader(http.StatusBadRequest) + })) + defer server.Close() + for _, tc := range []struct { + name string + args []string + }{ + {"missing purpose", []string{"upload", "upload.txt"}}, + {"missing path", []string{"upload", "--purpose", "user_data"}}, + {"missing local file", []string{"upload", "missing.txt", "--purpose", "user_data"}}, + {"extra positional", []string{"upload", "upload.txt", "second.txt", "--purpose", "user_data"}}, + {"positional and file flag", []string{"upload", "upload.txt", "--file", "upload.txt", "--purpose", "user_data"}}, + {"file flag and positional", []string{"upload", "--file", "upload.txt", "--purpose", "user_data", "upload.txt"}}, + {"create keeps flag syntax", []string{"create", "upload.txt", "--purpose", "user_data"}}, + } { + t.Run(tc.name, func(t *testing.T) { + before := requests.Load() + got := runReadableCommand(t, server, append([]string{"files"}, tc.args...)...) + require.NotZero(t, got.code, "%+v", got) + require.NotEmpty(t, got.stderr) + require.NotContains(t, got.stdout+got.stderr, "Uploaded ") + require.NotContains(t, got.stdout+got.stderr, "Download it:") + require.Equal(t, before, requests.Load(), "validation must precede the request") + }) + } +} + +func TestMainFilesWorkflowUploadStdinCompatibility(t *testing.T) { + t.Chdir(t.TempDir()) + const filename = "@upload space.txt" + payload := []byte("synthetic stdin parameters\n") + require.NoError(t, os.WriteFile(filename, payload, 0o600)) + server, requests := filesWorkflowUploadServer(t, filename, payload) + for _, tc := range []struct { + name, stdin string + legacy, current []string + }{ + {"JSON purpose", `{"purpose":"user_data"}`, []string{"--file", filename}, []string{filename}}, + {"YAML purpose", "purpose: user_data\n", []string{"--file", filename}, []string{filename}}, + {"JSON request", `{"purpose":"user_data","file":"@upload space.txt"}`, nil, nil}, + {"flag overrides purpose", `{"purpose":"batch"}`, []string{"--file", filename, "--purpose", "user_data"}, []string{filename, "--purpose", "user_data"}}, + {"path overrides JSON file", `{"purpose":"user_data","file":"missing.txt"}`, []string{"--file", filename}, []string{filename}}, + } { + t.Run(tc.name, func(t *testing.T) { + inputPath := filepath.Join(t.TempDir(), "request.json") + require.NoError(t, os.WriteFile(inputPath, []byte(tc.stdin), 0o600)) + var want mainDispatchResult + for index, args := range [][]string{ + append([]string{"openai", "files", "create"}, tc.legacy...), + append([]string{"openai", "files", "upload"}, tc.current...), + } { + input, err := os.Open(inputPath) + require.NoError(t, err) + got := runMainDispatchWithStdin(t, "bash", []string{"OPENAI_BASE_URL=" + server.URL, "OPENAI_API_KEY=sk-fake-files-workflow-test", "FORCE_COLOR=0"}, input, args...) + require.NoError(t, input.Close()) + require.Zero(t, got.code, "%+v", got) + require.Empty(t, got.stderr) + if index == 0 { + want = got + } + require.Equal(t, want, got) + } + }) + } + require.EqualValues(t, 10, requests.Load()) +} + +func TestMainFilesWorkflowMachineOutput(t *testing.T) { + path := filepath.Join(t.TempDir(), "upload space.txt") + payload := []byte("hello files!\n") + require.NoError(t, os.WriteFile(path, payload, 0o600)) + uploadServer, uploadRequests := filesWorkflowUploadServer(t, filepath.Base(path), payload) + var metadataRequests atomic.Int32 + metadataServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + metadataRequests.Add(1) + if r.Method != http.MethodGet || r.URL.Path != "/files/"+filesWorkflowID { + t.Errorf("metadata request changed: %s %s", r.Method, r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, filesWorkflowMetadata) + })) + defer metadataServer.Close() + for _, tc := range []struct { + name string + flags []string + want string + }{ + {"piped default", nil, ""}, + {"explicit auto", []string{"--format", "auto"}, ""}, + {"explicit text", []string{"--format", "text"}, ""}, + {"json", []string{"--format", "json"}, "json"}, + {"jsonl", []string{"--format", "jsonl"}, "json"}, + {"raw", []string{"--format", "raw"}, filesWorkflowMetadata + "\n"}, + {"extract ID", []string{"--transform", "id"}, `"` + filesWorkflowID + "\"\n"}, + {"raw ID", []string{"--transform", "id", "--raw-output"}, filesWorkflowID + "\n"}, + {"raw timestamp", []string{"--transform", "created_at", "--raw-output"}, "1700000000\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + for _, route := range []struct { + server *httptest.Server + legacy, current []string + }{ + {uploadServer, []string{"files", "create", "--file", path, "--purpose", "user_data"}, []string{"files", "upload", path, "--purpose", "user_data"}}, + {metadataServer, []string{"files", "retrieve", filesWorkflowID}, []string{"files", "get", filesWorkflowID}}, + } { + want := runReadableCommand(t, route.server, append(route.legacy, tc.flags...)...) + got := runReadableCommand(t, route.server, append(route.current, tc.flags...)...) + require.Zero(t, want.code, "%+v", want) + require.Empty(t, want.stderr) + require.Equal(t, want, got, "%q", route.current) + switch tc.want { + case "json": + var value map[string]any + require.NoError(t, json.Unmarshal([]byte(got.stdout), &value)) + require.JSONEq(t, filesWorkflowMetadata, got.stdout) + case "": + require.Contains(t, got.stdout, filesWorkflowID) + require.Contains(t, got.stdout, "1700000000") + default: + require.Equal(t, tc.want, got.stdout) + } + require.NotContains(t, got.stdout, "Download it:") + } + }) + } + require.EqualValues(t, 18, uploadRequests.Load()) + require.EqualValues(t, 18, metadataRequests.Load()) +} + +func TestMainFilesWorkflowDownloadBytes(t *testing.T) { + for _, tc := range []struct{ name, contentType, body string }{ + {"text", "text/plain", "synthetic text\nsecond line\n"}, + {"binary", "application/octet-stream", "\x00\xff\x1b\r\nsynthetic\x80\x00"}, + } { + t.Run(tc.name, func(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if r.Method != http.MethodGet || r.URL.Path != "/files/"+filesWorkflowID+"/content" { + t.Errorf("download request changed: %s %s", r.Method, r.URL.Path) + } + w.Header().Set("Content-Type", tc.contentType) + _, _ = io.WriteString(w, tc.body) + })) + defer server.Close() + for _, flags := range [][]string{nil, {"--output", "-"}, {"--format", "json"}, {"--format", "raw"}, {"--raw-output"}} { + for _, route := range []string{"content", "download"} { + got := runReadableCommand(t, server, append([]string{"files", route, filesWorkflowID}, flags...)...) + require.Equal(t, mainDispatchResult{stdout: tc.body}, got, "%s %v", route, flags) + } + } + for _, flag := range []string{"--output", "-o"} { + path := filepath.Join(t.TempDir(), "downloaded copy's.bin") + var want mainDispatchResult + for index, route := range []string{"content", "download"} { + got := runReadableCommand(t, server, "files", route, "--file-id", filesWorkflowID, flag, path) + require.Zero(t, got.code, "%+v", got) + require.Empty(t, got.stderr) + if index == 0 { + want = got + } + require.Equal(t, want, got) + body, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, tc.body, string(body)) + require.NoError(t, os.Remove(path)) + } + } + require.EqualValues(t, 14, requests.Load()) + }) + } +} + +func TestMainFilesWorkflowFailuresPreserveExits(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if r.URL.Path == "/files/"+filesWorkflowID+"/content" { + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = io.WriteString(w, "synthetic download") + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = io.WriteString(w, `{"error":{"message":"Synthetic file was not found.","type":"invalid_request_error","code":"file_not_found"}}`) + })) + defer server.Close() + path := filepath.Join(t.TempDir(), "upload.txt") + require.NoError(t, os.WriteFile(path, []byte("synthetic"), 0o600)) + destination := filepath.Join(t.TempDir(), "missing", "download.bin") + for _, pair := range [][2][]string{ + {{"files", "create", "--file", path, "--purpose", "user_data"}, {"files", "upload", path, "--purpose", "user_data"}}, + {{"files", "retrieve", "file-missing"}, {"files", "get", "file-missing"}}, + {{"files", "content", "file-missing"}, {"files", "download", "file-missing"}}, + {{"files", "content", filesWorkflowID, "--output", destination}, {"files", "download", filesWorkflowID, "--output", destination}}, + } { + want := runReadableCommand(t, server, pair[0]...) + got := runReadableCommand(t, server, pair[1]...) + require.NotZero(t, want.code, "%q: %+v", pair[0], want) + require.NotEmpty(t, want.stderr) + require.Equal(t, want, got, "%q", pair[1]) + require.NotContains(t, got.stdout+got.stderr, "Uploaded ") + require.NotContains(t, got.stdout+got.stderr, "Download it:") + } + require.EqualValues(t, 8, requests.Load()) +} + +func TestMainFilesWorkflowShellRedirection(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX shell redirection; native Windows shell coverage is separate") + } + binary, err := os.Executable() + require.NoError(t, err) + quote := func(value string) string { return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" } + for _, shell := range []nativeShell{ + {name: "bash", executable: "bash", args: []string{"--noprofile", "--norc", "-c"}}, + {name: "zsh", executable: "zsh", args: []string{"-f", "-c"}}, + } { + t.Run(shell.name, func(t *testing.T) { + path, err := exec.LookPath(shell.executable) + if err != nil { + t.Skipf("%s is not installed", shell.executable) + } + shell.executable = path + for _, payload := range []string{"synthetic text\n", "\x00\xff\x1b\r\nsynthetic\x80\x00"} { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if r.Method != http.MethodGet || r.URL.Path != "/files/"+filesWorkflowID+"/content" { + t.Errorf("redirected download request changed: %s %s", r.Method, r.URL.Path) + } + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = io.WriteString(w, payload) + })) + t.Cleanup(server.Close) + work := t.TempDir() + for _, route := range []string{"content", "download"} { + destination := filepath.Join(work, "redirected copy's.bin") + script := "export OPENAI_CLI_MAIN_DISPATCH_PROCESS=1 OPENAI_API_KEY=sk-fake-files-workflow-test; " + quote(binary) + " -test.run='^TestMainDispatchProcess$' -- openai files " + route + " " + filesWorkflowID + " > " + quote(destination) + got := runNativeShell(t, shell, work, t.TempDir(), server.URL, script) + require.Equal(t, mainDispatchResult{}, got, "%s %s", shell.name, route) + body, err := os.ReadFile(destination) + require.NoError(t, err) + require.Equal(t, payload, string(body), "%s %s", shell.name, route) + } + require.EqualValues(t, 2, requests.Load()) + } + }) + } +} + +func TestMainFilesWorkflowHelpAndCompletionStayLocal(t *testing.T) { + env := []string{"OPENAI_BASE_URL=invalid-files-help-url", "OPENAI_CUSTOM_HEADERS=invalid-files-help-headers", "OPENAI_CLI_COMPLETION_FILE_VALUES=1"} + for _, route := range []string{"upload", "get", "download", "create", "retrieve", "content"} { + t.Run(route, func(t *testing.T) { + cases := [][]string{ + {"openai", "files", route, "--help"}, + {"openai", "help", "--all", "files", route}, + {"openai", "files", route, "--format", "json", "--help"}, + } + if route == "upload" || route == "create" { + cases = append(cases, []string{"openai", "files", route, "--file", "missing-local.txt", "--purpose", "user_data", "--help"}) + } else { + cases = append(cases, []string{"openai", "files", route, "--file-id", "file-missing", "--help"}) + } + for _, args := range cases { + got := runMainDispatchWithEnv(t, "bash", env, args...) + require.Zero(t, got.code, "%+v", got) + require.Empty(t, got.stderr) + require.Contains(t, got.stdout, "openai files "+route) + if route == "upload" || route == "create" { + require.Contains(t, got.stdout, "Path to the file to upload") + require.NotContains(t, got.stdout, "The File object (not file name)") + } + } + }) + } + for _, style := range []string{"bash", "zsh", "fish", "pwsh"} { + for _, route := range []string{"upload", "get", "download"} { + got := runMainDispatchWithEnv(t, style, env, mainCompletionArgs(style, "files", route[:2])...) + require.Zero(t, got.code, "%s: %+v", style, got) + require.Empty(t, got.stderr) + require.True(t, strings.HasPrefix(got.stdout, route), "%s: %+v", style, got) + } + got := runMainDispatchWithEnv(t, style, env, mainCompletionArgs(style, "files", "upload", "--file", "synthetic-")...) + require.Equal(t, mainDispatchResult{code: 10}, got, "%s file completion", style) + } +} diff --git a/docs/files.md b/docs/files.md new file mode 100644 index 00000000..4c2520cd --- /dev/null +++ b/docs/files.md @@ -0,0 +1,148 @@ +# Upload, inspect, and download files + +Upload a local file with an explicit purpose: + +```sh +openai files upload "upload space.txt" --purpose user_data +``` + +Use the returned file ID to inspect its metadata: + +```sh +openai files get file-example +``` + +Download its contents to a local path: + +```sh +openai files download file-example --output "downloaded copy.txt" +``` + +You can also redirect the contents: + +```sh +openai files download file-example > copy.txt +``` + +Replace `file-example` with the full ID from your upload response. + +## Upload paths and purpose + +Upload takes a plain local path. Quote paths that contain spaces or shell characters. +The existing `--file` flag remains available: + +```sh +openai files upload --file "upload space.txt" --purpose user_data +openai files create --file "upload space.txt" --purpose user_data +``` + +Provide one positional path or `--file`, not both. +Upload does not add or remove a leading `@`. +The following command uploads a file whose actual name starts with `@`: + +```sh +openai files upload "@upload space.txt" --purpose user_data +``` + +For `upload space.txt`, use `"upload space.txt"`, without an added `@`. +The binary upload path does not use the JSON input expansion syntax. + +Prefix a path starting with `-` with `./`: + +```sh +openai files upload "./-notes.txt" --purpose user_data +``` + +Alternatively, place options before `--`. The CLI treats the following path as literal input: + +```sh +openai files upload --purpose user_data -- "-notes.txt" +``` + +Use `./-` for a file named `-`. The existing `--file -` behavior reads file contents from stdin. + +Supply `--purpose` for each upload. The CLI does not select a purpose for you. +The example uses `user_data`; select the purpose required by your API workflow. +The API applies purpose, file type, and size requirements. +See the [Files upload reference](https://developers.openai.com/api/reference/resources/files/methods/create) for current requirements. + +## Upload receipt and metadata + +An interactive upload shows a short receipt after the API accepts the upload: + +```text +Uploaded upload space.txt (13 B) +ID: file-example +Purpose: user_data + +Download it: openai files download file-example --output 'upload space.txt' +``` + +The receipt uses the returned filename, size, ID, and purpose. +A successful upload does not mean that downstream processing has finished. + +`files get` shows metadata, including the full ID and timestamps in UTC. +For example, `created_at: 1700000000` becomes: + +```text +Created at: 2023-11-14T22:13:20Z +``` + +The `Z` suffix means UTC. The displayed fields depend on the API response. +`files get` retrieves metadata. Use `files download` for the file contents. + +The receipt and readable timestamps appear with automatic or text output when stdout and stderr are terminals. +Pipes, redirected stderr, and the existing `create` and `retrieve` commands keep their existing output. + +## Download destinations + +`--output PATH` writes the exact downloaded bytes to that path. +Its parent directory must already exist. An existing destination file is overwritten. +The `-o` alias remains available: + +```sh +openai files download file-example -o "downloaded copy.bin" +``` + +Redirected stdout receives the exact contents, including binary bytes. +Upload receipts and metadata do not appear in downloaded contents. + +With terminal stdout and no destination, download keeps the existing content behavior. +The CLI displays text safely and saves binary contents to a generated local file. +Use `--output -` to force contents to stdout. + +## Scripts and existing commands + +`create`, `retrieve`, and `content` keep their existing names and flags: + +```sh +openai files create --file "upload space.txt" --purpose user_data +openai files retrieve --file-id file-example +openai files content --file-id file-example --output "downloaded copy.txt" +``` + +Choose JSON explicitly when a script parses metadata: + +```sh +openai files get file-example --format json > metadata.json +openai files retrieve --file-id file-example --format json > metadata.json +``` + +JSON retains the original timestamp fields and other API data. +Extraction and raw string output remain available: + +```sh +openai files upload "upload space.txt" --purpose user_data --transform id --raw-output +openai files get file-example --transform filename --raw-output +``` + +Interactive receipts do not appear in JSON, extraction, or piped upload output. +See [reading command results](readable-output.md) for output formats. + +Inspect command help without making an API request: + +```sh +openai files upload --help +openai files get --help +openai files download --help +``` diff --git a/pkg/custom/cmdutil.go b/pkg/custom/cmdutil.go index f0e430fe..6d8c5eb5 100644 --- a/pkg/custom/cmdutil.go +++ b/pkg/custom/cmdutil.go @@ -557,6 +557,9 @@ func showJSON(res gjson.Result, opts ShowJSONOpts, selectTransformer transformer if err := opts.Context.Err(); err != nil { return err } + if handled, err := showFileResult(res, opts); handled { + return err + } if presentation, ok := savedImagePresentation(opts, OutputResponse); ok { return presentation.output(func(out io.Writer) error { return presentation.plan.save(opts.Context, []byte(res.Raw), out) diff --git a/pkg/custom/command.go b/pkg/custom/command.go index 06cd143f..4c214227 100644 --- a/pkg/custom/command.go +++ b/pkg/custom/command.go @@ -29,6 +29,7 @@ func ConfigureCommand(root *cli.Command) { configureReadableSpeech(root) configureCommandSubgroups(root) configureTaskCommands(root) + configureFileCommands(root) configureCommandPresentation(root) configureManpageCommands(root) configureOutputPolicy(root) diff --git a/pkg/custom/file_commands.go b/pkg/custom/file_commands.go new file mode 100644 index 00000000..7b4c3555 --- /dev/null +++ b/pkg/custom/file_commands.go @@ -0,0 +1,76 @@ +package custom + +import ( + "context" + "errors" + "fmt" + "os" + + "github.com/openai/openai-cli/internal/clihelp" + "github.com/urfave/cli/v3" +) + +type fileCommandKey struct{} + +const ( + fileUploadCommand = "upload" + fileGetCommand = "get" +) + +// Extend task routes after cloning so compatibility commands keep their output. +func configureFileCommands(root *cli.Command) { + files := root.Command("files") + if files == nil { + return + } + if upload := files.Command("upload"); upload != nil { + upload.Usage = "Upload a local file with an explicit purpose." + upload.UsageText = clihelp.Invocation(root.Name, os.Args) + " files upload [PATH | --file PATH] --purpose PURPOSE [options]" + upload.Description = "Pass a plain local path. Paths with spaces need quotes.\nA leading @ is part of the filename; do not add @ to a plain path.\nUse --purpose PURPOSE -- -name for a filename starting with a dash.\nThe API validates file types and purposes. Upload success does not mean processing is complete." + upload.Arguments = []cli.Argument{&cli.StringArgs{Name: "path", Max: 1}} + next := upload.Action + upload.Action = func(ctx context.Context, command *cli.Command) error { + if command.Args().Present() { + return fmt.Errorf("Unexpected extra arguments: %v", command.Args().Slice()) + } + if paths := command.StringArgs("path"); len(paths) != 0 { + if command.IsSet("file") { + return errors.New("Use either a positional upload path or --file, not both.") + } + if paths[0] == "" { + return errors.New("The upload path must not be empty.") + } + if err := command.Set("file", paths[0]); err != nil { + return err + } + } + return next(context.WithValue(ctx, fileCommandKey{}, fileUploadCommand), command) + } + } + for _, route := range []struct{ name, source, usage string }{ + {"get", "retrieve", "Show file metadata, including its ID and creation time."}, + {"download", "content", "Download file contents to a path or stdout."}, + } { + if files.Command(route.name) != nil { + continue + } + source := files.Command(route.source) + if source == nil { + continue + } + command := cloneResourceCommand(source) + command.Name, command.Aliases, command.Hidden = route.name, nil, false + command.Usage = route.usage + if route.name == fileGetCommand { + command.Description = "Show metadata for a file. Use files download to retrieve its contents." + next := command.Action + command.Action = func(ctx context.Context, command *cli.Command) error { + return next(context.WithValue(ctx, fileCommandKey{}, fileGetCommand), command) + } + } else { + command.Description = "Use --output PATH to save the contents, or redirect stdout to a file.\nUse --output - to force stdout. Existing download behavior also applies in a terminal." + } + files.Commands = append(files.Commands, command) + markCompatibilityCommand(source) + } +} diff --git a/pkg/custom/file_commands_test.go b/pkg/custom/file_commands_test.go new file mode 100644 index 00000000..5ed7b2df --- /dev/null +++ b/pkg/custom/file_commands_test.go @@ -0,0 +1,162 @@ +package custom + +import ( + "context" + "errors" + "io" + "reflect" + "testing" + + "github.com/openai/openai-cli/internal/requestflag" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" +) + +func fileWorkflowCommandTree(action cli.ActionFunc) *cli.Command { + return &cli.Command{Name: "openai", Writer: io.Discard, ErrWriter: io.Discard, Commands: []*cli.Command{{ + Name: "files", Commands: []*cli.Command{ + {Name: "create", Action: action, Flags: []cli.Flag{ + &requestflag.Flag[string]{Name: "file", Required: true, BodyPath: "file", FileInput: true}, + &requestflag.Flag[string]{Name: "purpose", Required: true, BodyPath: "purpose"}, + &requestflag.Flag[string]{Name: "future", Aliases: []string{"f"}, Default: "preserved", BodyPath: "future"}, + }}, + {Name: "retrieve", Action: action, Flags: []cli.Flag{ + &requestflag.Flag[string]{Name: "file-id", Required: true, PathParam: "file_id"}, + }}, + {Name: "content", Action: action, Flags: []cli.Flag{ + &requestflag.Flag[string]{Name: "file-id", Required: true, PathParam: "file_id"}, + &requestflag.Flag[string]{Name: "output", Aliases: []string{"o"}}, + }}, + }, + }}} +} + +func TestFileCommandsPreserveGeneratedFlagsAndDecoratedHooks(t *testing.T) { + root := fileWorkflowCommandTree(func(context.Context, *cli.Command) error { return nil }) + files := root.Command("files") + for _, name := range []string{"create", "retrieve", "content"} { + source := files.Command(name) + source.Metadata = map[string]any{"future": "preserved"} + source.Before = func(ctx context.Context, _ *cli.Command) (context.Context, error) { return ctx, nil } + source.After = func(context.Context, *cli.Command) error { return nil } + source.ShellComplete = func(context.Context, *cli.Command) {} + } + configureTaskCommands(root) + configureFileCommands(root) + for _, pair := range [][2]string{{"create", "upload"}, {"retrieve", "get"}, {"content", "download"}} { + source, friendly := files.Command(pair[0]), files.Command(pair[1]) + require.NotSame(t, source, friendly) + require.Len(t, friendly.Flags, len(source.Flags)) + for i := range source.Flags { + require.Same(t, source.Flags[i], friendly.Flags[i], "%s flag identity", pair[1]) + } + require.Equal(t, reflect.ValueOf(source.Before).Pointer(), reflect.ValueOf(friendly.Before).Pointer()) + require.Equal(t, reflect.ValueOf(source.After).Pointer(), reflect.ValueOf(friendly.After).Pointer()) + require.Equal(t, reflect.ValueOf(source.ShellComplete).Pointer(), reflect.ValueOf(friendly.ShellComplete).Pointer()) + require.Equal(t, "preserved", friendly.Metadata["future"]) + friendly.Metadata["future"] = "changed" + require.Equal(t, "preserved", source.Metadata["future"]) + } + file := files.Command("upload").Flags[0].(*requestflag.Flag[string]) + purpose := files.Command("upload").Flags[1].(*requestflag.Flag[string]) + future := files.Command("upload").Flags[2].(*requestflag.Flag[string]) + require.True(t, file.Required) + require.True(t, file.FileInput) + require.Equal(t, "file", file.BodyPath) + require.True(t, purpose.Required) + require.Empty(t, purpose.Default) + require.Equal(t, "preserved", future.Default) + require.Equal(t, []string{"f"}, future.Aliases) + require.Empty(t, files.Command("create").Arguments) + for _, name := range []string{"upload", "get", "download"} { + require.False(t, files.Command(name).Hidden) + } +} + +func TestFileUploadParsedArgumentsAndActionPassthrough(t *testing.T) { + for _, tc := range []struct { + name, wantFile string + args []string + wantError string + }{ + {"quoted path", "upload space.txt", []string{"upload space.txt", "--purpose", "user_data"}, ""}, + {"literal at", "@upload space.txt", []string{"@upload space.txt", "--purpose", "user_data"}, ""}, + {"literal dash", "-notes.txt", []string{"--purpose", "user_data", "--", "-notes.txt"}, ""}, + {"legacy flag", "upload space.txt", []string{"--file", "upload space.txt", "--purpose", "user_data"}, ""}, + {"legacy stdin", "-", []string{"--file", "-", "--purpose", "user_data"}, ""}, + {"path and flag", "", []string{"one", "--file", "two", "--purpose", "user_data"}, "Use either"}, + {"path and empty flag", "", []string{"one", "--file=", "--purpose", "user_data"}, "Use either"}, + {"empty positional", "", []string{"", "--purpose", "user_data"}, "must not be empty"}, + {"extra positional", "", []string{"one", "two", "--purpose", "user_data"}, "Unexpected extra arguments"}, + {"options after delimiter", "", []string{"--", "one", "--purpose", "user_data"}, "Unexpected extra arguments"}, + } { + t.Run(tc.name, func(t *testing.T) { + calls := 0 + failure := errors.New("decorated handler result") + root := fileWorkflowCommandTree(func(ctx context.Context, command *cli.Command) error { + calls++ + require.Equal(t, fileUploadCommand, ctx.Value(fileCommandKey{})) + require.Equal(t, tc.wantFile, command.String("file")) + require.True(t, command.IsSet("file")) + require.Equal(t, "user_data", command.String("purpose")) + require.Empty(t, command.Args().Slice(), "the generated action must not receive consumed paths") + return failure + }) + configureTaskCommands(root) + configureFileCommands(root) + err := root.Run(t.Context(), append([]string{"openai", "files", "upload"}, tc.args...)) + if tc.wantError != "" { + require.ErrorContains(t, err, tc.wantError) + require.Zero(t, calls) + } else { + require.ErrorIs(t, err, failure) + require.Equal(t, 1, calls) + } + }) + } +} + +func TestFileCommandsKeepPurposeValidationAndOldRoutes(t *testing.T) { + for _, route := range []string{"upload", "create", "get", "retrieve", "download", "content"} { + t.Run(route, func(t *testing.T) { + calls := 0 + failure := errors.New("decorated handler failure") + root := fileWorkflowCommandTree(func(ctx context.Context, command *cli.Command) error { + calls++ + wantMarker := map[string]string{"upload": fileUploadCommand, "get": fileGetCommand}[route] + marker, _ := ctx.Value(fileCommandKey{}).(string) + require.Equal(t, wantMarker, marker) + if route == "upload" || route == "create" { + missing := requestflag.GetMissingRequiredFlags(command, nil) + require.Len(t, missing, 1) + require.Equal(t, "purpose", missing[0].Names()[0]) + } else { + require.Equal(t, []string{"file-example"}, command.Args().Slice()) + } + return failure + }) + configureTaskCommands(root) + configureFileCommands(root) + args := []string{"openai", "files", route, "file-example"} + if route == "create" { + args = []string{"openai", "files", route, "--file", "file-example"} + } + require.ErrorIs(t, root.Run(t.Context(), args), failure) + require.Equal(t, 1, calls) + }) + } +} + +func TestFileCommandsPreserveExistingFriendlyRoutes(t *testing.T) { + root := fileWorkflowCommandTree(func(context.Context, *cli.Command) error { return nil }) + files := root.Command("files") + get, download := &cli.Command{Name: "get"}, &cli.Command{Name: "download"} + files.Commands = append(files.Commands, get, download) + configureTaskCommands(root) + configureFileCommands(root) + require.Same(t, get, files.Command("get")) + require.Same(t, download, files.Command("download")) + require.False(t, files.Command("retrieve").Hidden) + require.False(t, files.Command("content").Hidden) + require.NotPanics(t, func() { configureFileCommands(&cli.Command{Name: "openai"}) }) +} diff --git a/pkg/custom/file_results.go b/pkg/custom/file_results.go new file mode 100644 index 00000000..dde210a7 --- /dev/null +++ b/pkg/custom/file_results.go @@ -0,0 +1,115 @@ +package custom + +import ( + "fmt" + "io" + "strings" + + "github.com/openai/openai-cli/internal/jsonview" + "github.com/openai/openai-cli/internal/readable" + "github.com/openai/openai-cli/pkg/transformers" + "github.com/tidwall/gjson" +) + +// Only friendly file routes opt into terminal presentation. Downloads never do. +func showFileResult(value gjson.Result, opts ShowJSONOpts) (bool, error) { + opts.setDefaults() + command, _ := opts.Context.Value(fileCommandKey{}).(string) + if command == "" || opts.OutputKind != OutputResponse || opts.Transform != "" || opts.RawOutput || + resolvedOutputFormat(opts) != "text" || !isTerminal(opts.Stdout) || !isTerminal(opts.Stderr) { + return false, nil + } + if err := opts.Context.Err(); err != nil { + return true, err + } + out := outputWriter{ctx: opts.Context, out: opts.Stdout} + switch { + case command == fileGetCommand && opts.Operation == "(resource) files > (method) retrieve": + metadata, err := transformers.ProjectFileMetadata(opts.Context, value) + if err != nil { + return true, err + } + if err := readable.WriteText(out, "File metadata"); err != nil { + return true, err + } + return true, readable.Write(out, metadata) + case command == fileUploadCommand && opts.Operation == "(resource) files > (method) create": + if !validFileReceipt(value) { + return false, nil + } + return true, writeFileReceipt(out, value, imagePickerParentShell(opts.Context)) + default: + return false, nil + } +} + +func validFileReceipt(value gjson.Result) bool { + if !value.IsObject() || !gjson.Valid(value.Raw) || value.Get("object").Str != "file" { + return false + } + for _, name := range []string{"id", "filename", "purpose"} { + field := value.Get(name) + if field.Type != gjson.String || field.Str == "" { + return false + } + } + if bytes := value.Get("bytes"); bytes.Exists() && bytes.Type != gjson.Null { + if bytes.Type != gjson.Number || strings.IndexFunc(bytes.Raw, func(r rune) bool { return r < '0' || r > '9' }) >= 0 { + return false + } + } + // Fall back to full output for new fields or ambiguous duplicate values. + seen := map[string]bool{} + valid := true + value.ForEach(func(key, field gjson.Result) bool { + switch key.Str { + case "id", "object", "filename", "purpose", "bytes", "created_at", "expires_at", "status", "status_details": + valid = !seen[key.Str] + default: + valid = false + } + seen[key.Str] = true + return valid + }) + return valid +} + +func writeFileReceipt(out io.Writer, value gjson.Result, shell string) error { + // Keep each response value on one line before adding our own layout. + safe := func(text string) string { return readable.Text(jsonview.SanitizeTerminalString(text)) } + filename, id := value.Get("filename").Str, value.Get("id").Str + size := "" + if bytes := value.Get("bytes"); bytes.Type == gjson.Number { + size = " (" + bytes.Raw + " B)" + } + if _, err := fmt.Fprintf(out, "Uploaded %s%s\nID: %s\nPurpose: %s\n", safe(filename), size, safe(id), safe(value.Get("purpose").Str)); err != nil { + return err + } + if status := value.Get("status"); status.Str == "error" { + if err := readable.WriteText(out, "Status: error"); err != nil { + return err + } + } + if details := value.Get("status_details"); details.Exists() && details.Type != gjson.Null && details.String() != "" { + if err := readable.WriteText(out, "Status details: "+safe(details.String())); err != nil { + return err + } + } + if shell == "" || strings.ContainsRune(id, 0) || strings.ContainsRune(filename, 0) || + strings.ContainsAny(filename, `/\`) || filename == "." || filename == ".." { + return nil + } + // Reuse the existing shell quoting contracts, including control characters. + args := []string{"files", "download", id} + if strings.HasPrefix(id, "-") { + args = []string{"files", "download", "--file-id=" + id} + } + if filename == "-" { + filename = "./-" + } + args = append(args, "--output", filename) + if command := formatImagePickerCommand(args, shell); command != "" { + return readable.WriteText(out, "\nDownload it: "+command) + } + return nil +} diff --git a/pkg/custom/file_results_test.go b/pkg/custom/file_results_test.go new file mode 100644 index 00000000..5ae2b7a3 --- /dev/null +++ b/pkg/custom/file_results_test.go @@ -0,0 +1,169 @@ +package custom + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "os/exec" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +const fileReceiptFixture = `{"object":"file","id":"file-example","filename":"upload space.txt","purpose":"user_data","bytes":13}` + +func TestFileResultPreservesNonterminalOutput(t *testing.T) { + for _, route := range []string{"", fileUploadCommand, fileGetCommand, "download"} { + for _, format := range []string{"", "auto", "text", "json", "jsonl", "raw", "yaml", "pretty", "explore"} { + t.Run(route+"/"+format, func(t *testing.T) { + for _, extraction := range []string{"", "id"} { + for _, raw := range []bool{false, true} { + var stdout, stderr bytes.Buffer + opts := ShowJSONOpts{ + Context: context.WithValue(t.Context(), fileCommandKey{}, route), + Operation: "(resource) files > (method) create", OutputKind: OutputResponse, + Format: format, ExplicitFormat: format != "", Transform: extraction, RawOutput: raw, + Stdout: &stdout, Stderr: &stderr, + } + handled, err := showFileResult(gjson.Parse(fileReceiptFixture), opts) + require.NoError(t, err) + require.False(t, handled) + require.Empty(t, stdout.String()) + require.Empty(t, stderr.String()) + } + } + }) + } + } +} + +func TestFileReceiptRequiresSuccessfulFileShape(t *testing.T) { + require.True(t, validFileReceipt(gjson.Parse(fileReceiptFixture))) + for _, raw := range []string{ + `null`, `[]`, `{"object":"batch","id":"x","filename":"x","purpose":"x"}`, + `{"object":"file","id":"","filename":"x","purpose":"x"}`, + `{"object":"file","id":123,"filename":"x","purpose":"x"}`, + `{"object":"file","id":"x","filename":null,"purpose":"x"}`, + `{"object":"file","id":"x","filename":"x"}`, + `{"object":"file","id":"x","filename":"x","purpose":"x"`, + } { + require.False(t, validFileReceipt(gjson.Parse(raw)), raw) + } +} + +func TestFileReceiptValuesAndUnknownShell(t *testing.T) { + for _, tc := range []struct{ bytes, suffix string }{{"13", " (13 B)"}, {"0", " (0 B)"}, {"null", ""}, {"9007199254740993", " (9007199254740993 B)"}} { + value := gjson.Parse(strings.Replace(fileReceiptFixture, `"bytes":13`, `"bytes":`+tc.bytes, 1)) + for _, shell := range []string{"", "unknown-shell"} { + var out bytes.Buffer + require.NoError(t, writeFileReceipt(&out, value, shell)) + require.Equal(t, "Uploaded upload space.txt"+tc.suffix+"\nID: file-example\nPurpose: user_data\n", out.String()) + } + } +} + +func TestFileReceiptFallsBackForInvalidByteCounts(t *testing.T) { + for _, count := range []string{`-1`, `1.5`, `"13"`, `1e3`, `true`, `{}`, `[]`} { + value := gjson.Parse(strings.Replace(fileReceiptFixture, `"bytes":13`, `"bytes":`+count, 1)) + require.False(t, validFileReceipt(value), count) + } + for _, count := range []string{`0`, `13`, `9007199254740993`, `null`} { + value := gjson.Parse(strings.Replace(fileReceiptFixture, `"bytes":13`, `"bytes":`+count, 1)) + require.True(t, validFileReceipt(value), count) + } +} + +func TestFileReceiptEscapesFieldsAndRetainsProcessingFailure(t *testing.T) { + value := gjson.Parse(`{"object":"file","id":"file-\nID: spoof\u001b","filename":"a\r\n\t\u202e.txt","purpose":"user_data\u0007","bytes":0,"status":"error","status_details":"failed\nPurpose: spoof"}`) + var out bytes.Buffer + require.NoError(t, writeFileReceipt(&out, value, "")) + require.Equal(t, "Uploaded a\\r\\n\\t\\u202e.txt (0 B)\nID: file-\\nID: spoof\\u001b\nPurpose: user_data\\u0007\nStatus: error\nStatus details: failed\\nPurpose: spoof\n", out.String()) + for _, control := range []string{"\r", "\t", "\x1b", "\x07", "\u202e"} { + require.NotContains(t, out.String(), control) + } +} + +func TestFileReceiptCommandsPreserveShellArguments(t *testing.T) { + for _, shell := range []string{"bash", "zsh"} { + t.Run(shell, func(t *testing.T) { + binary, err := exec.LookPath(shell) + if err != nil { + t.Skipf("%s is unavailable", shell) + } + for _, tc := range []struct{ id, filename string }{ + {"file-example", "upload space.txt"}, + {"-file-example", "-notes.txt"}, + {"file-example", "-"}, + {"file-'quote", "an owner's $HOME `literal` $(printf BAD) file.txt"}, + {"file-example", "@literal.txt"}, + {"file-example", "line\nwith\tcontrols\x1b.txt"}, + } { + data, err := json.Marshal(map[string]any{"object": "file", "id": tc.id, "filename": tc.filename, "purpose": "user_data", "bytes": 0}) + require.NoError(t, err) + var out bytes.Buffer + require.NoError(t, writeFileReceipt(&out, gjson.ParseBytes(data), shell)) + _, command, found := strings.Cut(out.String(), "\nDownload it: ") + require.True(t, found) + command = strings.TrimSuffix(command, "\n") + require.NotContains(t, command, "\n") + require.NotContains(t, command, "\x1b") + args := []string{"-f", "-c"} + if shell == "bash" { + args = []string{"--noprofile", "--norc", "-c"} + } + process := exec.Command(binary, append(args, "openai() { printf '%s\\0' \"$@\"; }\n"+command)...) + process.Dir = t.TempDir() + process.Env = []string{"LC_ALL=C"} + got, err := process.CombinedOutput() + require.NoError(t, err, string(got)) + id, filename := tc.id, tc.filename + if strings.HasPrefix(id, "-") { + id = "--file-id=" + id + } + if filename == "-" { + filename = "./-" + } + want := []string{"files", "download", id, "--output", filename} + require.Equal(t, strings.Join(want, "\x00")+"\x00", string(got), command) + } + }) + } +} + +func TestFileReceiptPropagatesOutputFailuresAndCancellation(t *testing.T) { + value := gjson.Parse(fileReceiptFixture) + failure := errors.New("synthetic output failure") + for _, tc := range []struct{ returned, want error }{{failure, failure}, {nil, io.ErrShortWrite}} { + out := outputWriter{ctx: t.Context(), out: failOutputWriter{tc.returned}} + require.ErrorIs(t, writeFileReceipt(out, value, "bash"), tc.want) + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + var out bytes.Buffer + require.ErrorIs(t, writeFileReceipt(outputWriter{ctx: ctx, out: &out}, value, "bash"), context.Canceled) + require.Empty(t, out.String()) +} + +func TestFileReceiptOmitsAmbiguousDestinations(t *testing.T) { + for _, tc := range []struct{ id, filename string }{ + {"file-example", "../outside.txt"}, + {"file-example", "/dev/stdout"}, + {"file-example", `folder\file.txt`}, + {"file-example", "."}, + {"file-example", ".."}, + {"file-example", "nul\x00.txt"}, + {"file-\x00example", "safe.txt"}, + } { + data, err := json.Marshal(map[string]any{"object": "file", "id": tc.id, "filename": tc.filename, "purpose": "user_data"}) + require.NoError(t, err) + var out bytes.Buffer + require.NoError(t, writeFileReceipt(&out, gjson.ParseBytes(data), "bash")) + require.Contains(t, out.String(), "Uploaded ") + require.NotContains(t, out.String(), "Download it:") + require.NotContains(t, out.String(), "\x00") + } +} diff --git a/pkg/custom/local_errors.go b/pkg/custom/local_errors.go index 2ed5f37e..1859bbf4 100644 --- a/pkg/custom/local_errors.go +++ b/pkg/custom/local_errors.go @@ -97,6 +97,8 @@ func knownLocalError(command *cli.Command, message string) string { "cannot read from stdin: stdin is already being used for the request body", "cannot read from stdin: stdin was already consumed by piped YAML/JSON input", "Setup help takes no additional arguments.", + "Use either a positional upload path or --file, not both.", + "The upload path must not be empty.", "PowerShell uses normal Tab completion. Type openai images generate and press Enter to open the image picker.", "COMPLETION_STYLE must be set to 'bash', 'zsh', 'pwsh', or 'fish'", "COMPLETION_STYLE must be set to 'bash', 'zsh', 'pwsh', 'fish'": diff --git a/pkg/transformers/project_file_metadata.go b/pkg/transformers/project_file_metadata.go new file mode 100644 index 00000000..211ef814 --- /dev/null +++ b/pkg/transformers/project_file_metadata.go @@ -0,0 +1,51 @@ +package transformers + +import ( + "context" + "strconv" + "strings" + "time" + + "github.com/tidwall/gjson" +) + +// ProjectFileMetadata preserves complete file records and formats known timestamps. +// Machine output never calls this presentation-only projection. +func ProjectFileMetadata(ctx context.Context, value gjson.Result) (gjson.Result, error) { + if err := ctx.Err(); err != nil { + return gjson.Result{}, err + } + if !value.IsObject() || value.Get("object").Str != "file" || !gjson.Valid(value.Raw) { + return value, ctx.Err() + } + var out strings.Builder + out.WriteByte('{') + first := true + value.ForEach(func(key, field gjson.Result) bool { + if ctx.Err() != nil { + return false + } + if !first { + out.WriteByte(',') + } + first = false + out.WriteString(key.Raw) + out.WriteByte(':') + if key.Str == "created_at" || key.Str == "expires_at" { + if seconds, err := strconv.ParseInt(field.Raw, 10, 64); field.Type == gjson.Number && err == nil { + date := time.Unix(seconds, 0).UTC() + if date.Year() >= 0 && date.Year() <= 9999 { + out.WriteString(strconv.Quote(date.Format(time.RFC3339))) + return true + } + } + } + out.WriteString(field.Raw) + return true + }) + if err := ctx.Err(); err != nil { + return gjson.Result{}, err + } + out.WriteByte('}') + return gjson.Parse(out.String()), nil +} diff --git a/pkg/transformers/project_file_metadata_test.go b/pkg/transformers/project_file_metadata_test.go new file mode 100644 index 00000000..d0c1ba25 --- /dev/null +++ b/pkg/transformers/project_file_metadata_test.go @@ -0,0 +1,54 @@ +package transformers + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestProjectFileMetadataUTCAndCompleteFields(t *testing.T) { + input := `{"object":"file","id":"file-full-copyable-id","filename":"upload space.txt","purpose":"user_data","bytes":9007199254740993,"created_at":1700000000,"expires_at":1700003600,"status":"error","status_details":"synthetic failure","future":{"number":9007199254740995,"nullable":null},"unknown_time":1700000000}` + value := gjson.Parse(input) + got, err := ProjectFileMetadata(t.Context(), value) + require.NoError(t, err) + require.Equal(t, `{"object":"file","id":"file-full-copyable-id","filename":"upload space.txt","purpose":"user_data","bytes":9007199254740993,"created_at":"2023-11-14T22:13:20Z","expires_at":"2023-11-14T23:13:20Z","status":"error","status_details":"synthetic failure","future":{"number":9007199254740995,"nullable":null},"unknown_time":1700000000}`, got.Raw) + require.Equal(t, input, value.Raw, "projection must not mutate original machine data") +} + +func TestProjectFileMetadataPreservesInvalidAndNullTimestamps(t *testing.T) { + for _, timestamp := range []string{`null`, `"1700000000"`, `1.5`, `1e3`, `true`, `{}`, `[]`, `9223372036854775808`, `9223372036854775807`, `253402300800`, `-62167219201`} { + input := `{"object":"file","bytes":null,"created_at":` + timestamp + `,"expires_at":` + timestamp + `,"status_details":null}` + got, err := ProjectFileMetadata(t.Context(), gjson.Parse(input)) + require.NoError(t, err) + require.Equal(t, input, got.Raw) + } + for _, tc := range []struct{ timestamp, want string }{{"0", "1970-01-01T00:00:00Z"}, {"-1", "1969-12-31T23:59:59Z"}, {"253402300799", "9999-12-31T23:59:59Z"}} { + got, err := ProjectFileMetadata(t.Context(), gjson.Parse(`{"object":"file","created_at":`+tc.timestamp+`}`)) + require.NoError(t, err) + require.Equal(t, tc.want, got.Get("created_at").Str) + } +} + +func TestProjectFileMetadataPreservesOtherShapesAndDuplicateFields(t *testing.T) { + for _, input := range []string{`null`, `[]`, `"file"`, `{"object":"batch","created_at":1700000000}`, `{"created_at":1700000000}`, `{"object":"file","created_at":1700000000`} { + got, err := ProjectFileMetadata(t.Context(), gjson.Parse(input)) + require.NoError(t, err) + require.Equal(t, input, got.Raw) + } + input := `{"object":"file","future":1,"future":9007199254740993,"created_at":0}` + got, err := ProjectFileMetadata(t.Context(), gjson.Parse(input)) + require.NoError(t, err) + require.Equal(t, `{"object":"file","future":1,"future":9007199254740993,"created_at":"1970-01-01T00:00:00Z"}`, got.Raw) +} + +func TestProjectFileMetadataCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + cancel() + input := gjson.Parse(`{"object":"file","created_at":1700000000}`) + got, err := ProjectFileMetadata(ctx, input) + require.ErrorIs(t, err, context.Canceled) + require.Equal(t, gjson.Result{}, got) + require.Equal(t, `{"object":"file","created_at":1700000000}`, input.Raw) +} diff --git a/scripts/demos/files-workflow/README.md b/scripts/demos/files-workflow/README.md new file mode 100644 index 00000000..dd2e9d0b --- /dev/null +++ b/scripts/demos/files-workflow/README.md @@ -0,0 +1,53 @@ +# Files workflow recording + +This recorder compares real CLI binaries against one synthetic loopback API. +It uses the shared `capture_and_render.sh` lifecycle. +It creates temporary PATH entries named `openai`; it never changes the installed launcher. + +Build the fixture from the repository root: + +```sh +GOMAXPROCS=2 go build -p 2 -o /absolute/task/evidence/files-workflow-demo-api ./scripts/demos/files-workflow +``` + +Build the before and after binaries independently from their stated commits. +Supply full commit IDs and an empty output directory outside the repository. +Put `asciinema`, `agg`, `ffmpeg`, and `ffprobe` on PATH. +Set `GOCACHE` to a writable cache when required. + +```sh +DEMO_API_BINARY=/absolute/task/evidence/files-workflow-demo-api \ + bash scripts/demos/files-workflow/record.sh \ + /absolute/task/evidence/baseline-openai \ + /absolute/task/evidence/candidate-openai \ + BEFORE_COMMIT AFTER_COMMIT \ + /absolute/task/evidence/files-workflow-media +``` + +For development captures, set `DEMO_SOURCE_MANIFEST` to the current source manifest. +The recorder preserves the manifest and both binaries' build information. +Final review media must identify the actual candidate source. + +The before scene uses `create`, `retrieve`, and `content`. +The after scene uses positional `upload`, `get`, and `download`. +Both scenes upload the same 13-byte file with an explicit `user_data` purpose. +Both scenes inspect metadata and download text and binary files. +Each scene downloads through an explicit destination and shell redirection. +Actual `cmp` commands verify every downloaded byte. + +The fixture records each request method, path, and content type. +It validates and records the exact synthetic upload filename, purpose, and bytes. +It rejects unexpected routes, credentials, upload fields, and upload bytes. +It checks response writes, request-body closes, log writes, and shutdown errors. +No live Files API acceptance claim follows from these synthetic requests. + +The recorder preserves request logs, exact files, transcripts, casts, GIFs, PNGs, and binary hashes. +The validator requires six requests per scene and verifies eight downloaded files. +Any command failure stops its scene and fails capture. +The recorder checks fixture shutdown before validation. + +Inspect `before.png`, `after.png`, and `comparison.gif` before sharing. +Check clipping, timestamp clarity, the complete ID, and copyable suggested commands. +The replay uses Bash, 110 columns, 46 rows, and Menlo at 18 pixels. +This is PTY execution with asciinema/agg replay, not native graphical terminal validation. +Keep generated media and executables outside Git. diff --git a/scripts/demos/files-workflow/main.go b/scripts/demos/files-workflow/main.go new file mode 100644 index 00000000..aeda5962 --- /dev/null +++ b/scripts/demos/files-workflow/main.go @@ -0,0 +1,154 @@ +// files-workflow-demo-api accepts only the fixed synthetic Files workflow. +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "os" + "os/signal" + "sync" + "syscall" + "time" +) + +var textFile = []byte("hello files!\n") +var binaryFile = []byte{0, 255, 13, 10, 27, 65, 128, 0, 7, 8, 9, 10} + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run() (result error) { + if len(os.Args) != 3 { + return errors.New("usage: files-workflow-demo-api ADDRESS_FILE REQUEST_LOG") + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return err + } + requests, err := os.OpenFile(os.Args[2], os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + return errors.Join(err, listener.Close()) + } + defer func() { result = errors.Join(result, requests.Close()) }() + var mu sync.Mutex + var handlerErr error + fail := func(err error) { + mu.Lock() + handlerErr = errors.Join(handlerErr, err) + mu.Unlock() + } + metadata := map[string]any{ + "id": "file-example", "object": "file", "bytes": len(textFile), + "created_at": 1700000000, "filename": "upload space.txt", + "purpose": "user_data", "status": "uploaded", + } + server := &http.Server{ReadHeaderTimeout: 5 * time.Second} + server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { fail(r.Body.Close()) }() + record := map[string]any{"method": r.Method, "path": r.URL.Path, "content_type": r.Header.Get("Content-Type")} + var content []byte + if r.Header.Get("Authorization") != "Bearer synthetic-demo-key" { + fail(errors.New("fixture received unexpected credentials")) + http.Error(w, "only the synthetic demo key is accepted", http.StatusForbidden) + return + } + switch { + case r.Method == http.MethodPost && r.URL.Path == "/v1/files": + reader, err := r.MultipartReader() + if err != nil { + fail(err) + http.Error(w, "expected synthetic multipart upload", http.StatusBadRequest) + return + } + parts := make(map[string][]byte) + for { + part, err := reader.NextPart() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + fail(err) + return + } + data, readErr := io.ReadAll(io.LimitReader(part, 1024)) + if err := errors.Join(readErr, part.Close()); err != nil { + fail(err) + return + } + name := part.FormName() + _, duplicate := parts[name] + if duplicate || name != "file" && name != "purpose" || + name == "file" && (part.FileName() != "upload space.txt" || !bytes.Equal(data, textFile)) || + name == "purpose" && string(data) != "user_data" { + fail(errors.New("fixture received an unexpected upload part")) + http.Error(w, "only fixed synthetic upload data is accepted", http.StatusBadRequest) + return + } + parts[name] = data + } + if len(parts) != 2 { + fail(errors.New("fixture upload omitted a required part")) + http.Error(w, "file and purpose are required", http.StatusBadRequest) + return + } + record["filename"], record["bytes"], record["purpose"] = "upload space.txt", parts["file"], string(parts["purpose"]) + case r.Method == http.MethodGet && r.URL.Path == "/v1/files/file-example": + case r.Method == http.MethodGet && r.URL.Path == "/v1/files/file-example/content": + content = textFile + case r.Method == http.MethodGet && r.URL.Path == "/v1/files/file-binary/content": + content = binaryFile + default: + fail(errors.New("fixture received an unexpected route")) + http.Error(w, "only fixed synthetic Files routes are accepted", http.StatusNotFound) + return + } + mu.Lock() + err := json.NewEncoder(requests).Encode(record) + mu.Unlock() + if err != nil { + fail(err) + http.Error(w, "could not record synthetic request", http.StatusInternalServerError) + return + } + if content != nil { + w.Header().Set("Content-Type", "application/octet-stream") + _, err := w.Write(content) + fail(err) + return + } + w.Header().Set("Content-Type", "application/json") + fail(json.NewEncoder(w).Encode(metadata)) + }) + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + shutdownDone := make(chan error, 1) + go func() { + <-ctx.Done() + shutdown, done := context.WithTimeout(context.Background(), 2*time.Second) + defer done() + shutdownDone <- server.Shutdown(shutdown) + }() + if err := os.WriteFile(os.Args[1], []byte("http://"+listener.Addr().String()+"/v1"), 0600); err != nil { + cancel() + return errors.Join(err, listener.Close(), <-shutdownDone) + } + serveErr := server.Serve(listener) + cancel() + shutdownErr := <-shutdownDone + if errors.Is(serveErr, http.ErrServerClosed) { + serveErr = nil + } + mu.Lock() + defer mu.Unlock() + return errors.Join(serveErr, shutdownErr, handlerErr) +} diff --git a/scripts/demos/files-workflow/record.sh b/scripts/demos/files-workflow/record.sh new file mode 100644 index 00000000..49cf3dbd --- /dev/null +++ b/scripts/demos/files-workflow/record.sh @@ -0,0 +1,52 @@ +#!/bin/bash +set -euo pipefail +if [ "$#" -ne 5 ]; then + echo 'usage: record.sh BEFORE_BINARY AFTER_BINARY BEFORE_SHA AFTER_SHA OUTPUT_DIR' >&2 + exit 2 +fi +demo_source="$(cd "$(dirname "$0")" && pwd)" +demo_root="$(cd "$demo_source/../../.." && pwd)" +demo_python="$(command -v python3)" +source "$demo_source/../capture_and_render.sh" +demo_prepare_capture "$demo_root" "$1" "$2" "$3" "$4" "$5" \ + "${DEMO_API_BINARY:-$demo_root/dist/demos/bin/files-workflow-demo-api}" +demo_start_api "$demo_output/requests.jsonl" +cp "$demo_source/scene.sh" "$demo_runtime/scene.sh" +{ + echo 'feature: Files upload, metadata, and download workflow' + echo "comparison base / before commit: $demo_before_sha" + echo "candidate / after commit: $demo_after_sha" + echo 'capture: synthetic loopback API; isolated Bash PTYs; separate temporary homes' + echo 'scope: asciinema/agg replay; no graphical terminal or live API validation' + echo 'render: 110 columns x 46 rows; Menlo 18px; asciinema theme' + echo 'files: generated synthetic text and binary sources; uploads checked by the fixture' + echo 'checks: real command exits; all four downloads compared against original bytes' + demo_capture_metadata + shasum -a 256 "$demo_source/main.go" "$demo_source/record.sh" "$demo_source/scene.sh" "$demo_source/validate.py" + go version -m "$demo_before" + go version -m "$demo_after" +} > "$demo_output/metadata.txt" +if [ -n "${DEMO_SOURCE_MANIFEST:-}" ]; then + cp "$DEMO_SOURCE_MANIFEST" "$demo_output/source-manifest.txt" +fi +demo_window_size=110x46 +demo_render_options=(--renderer resvg --font-family Menlo --font-size 18 --line-height 1.2 \ + --theme asciinema --fps-cap 20 --last-frame-duration 3) +for demo_scene in before after; do + mkdir -p "$demo_runtime/$demo_scene-home" "$demo_output/$demo_scene-files" + "$demo_python" - "$demo_output/$demo_scene-files" <<'PY' +import pathlib, sys +root = pathlib.Path(sys.argv[1]) +(root / 'upload space.txt').write_bytes(b'hello files!\n') +(root / 'source.bin').write_bytes(bytes([0,255,13,10,27,65,128,0,7,8,9,10])) +PY + demo_label='BEFORE: API command names' + if [ "$demo_scene" = after ]; then demo_label='AFTER: upload, get, download'; fi + demo_capture_scene "$demo_scene" 0 "$demo_runtime/$demo_scene" "$demo_api_url" "$demo_label" \ + "HOME=$demo_runtime/$demo_scene-home" NO_COLOR=1 FORCE_COLOR=0 GOMAXPROCS=2 \ + "DEMO_SCENE=$demo_scene" "DEMO_INPUT_DIR=$demo_output/$demo_scene-files" +done +demo_stop_api +"$demo_python" "$demo_source/validate.py" "$demo_output" | tee "$demo_output/validation.txt" +demo_assemble_capture 300 before after +printf 'Recorded Files workflow in %s\n' "$demo_output" diff --git a/scripts/demos/files-workflow/scene.sh b/scripts/demos/files-workflow/scene.sh new file mode 100644 index 00000000..b568c9d6 --- /dev/null +++ b/scripts/demos/files-workflow/scene.sh @@ -0,0 +1,40 @@ +#!/bin/bash +set -euo pipefail +test -t 0 && test -t 1 && test -t 2 || exit 99 +cd "$DEMO_INPUT_DIR" +printf '\033[2J\033[H' +printf '%s\n' "$DEMO_SCENE_LABEL" +printf '%s\n\n' 'Synthetic Files API | exact local filename and bytes' +sleep 0.4 +if [ "$DEMO_SCENE" = before ]; then + printf '%s\n' '$ openai files create --file "upload space.txt" --purpose user_data' + openai files create --file "upload space.txt" --purpose user_data + demo_get=retrieve + demo_download=content +else + printf '%s\n' '$ openai files upload "upload space.txt" --purpose user_data' + openai files upload "upload space.txt" --purpose user_data + demo_get=get + demo_download=download +fi +printf '\n$ openai files %s file-example\n' "$demo_get" +sleep 0.4 +openai files "$demo_get" file-example +printf '\n$ openai files %s file-example --output "downloaded copy.txt"\n' "$demo_download" +openai files "$demo_download" file-example --output "downloaded copy.txt" +printf '$ openai files %s file-example > copy.txt\n' "$demo_download" +openai files "$demo_download" file-example > copy.txt +printf '%s\n' '$ cmp "upload space.txt" "downloaded copy.txt" && cmp "upload space.txt" copy.txt' +cmp "upload space.txt" "downloaded copy.txt" +cmp "upload space.txt" copy.txt +printf '%s\n' 'Text: both downloads match all 13 bytes.' +printf '\n$ openai files %s file-binary --output copy.bin\n' "$demo_download" +openai files "$demo_download" file-binary --output copy.bin +printf '$ openai files %s file-binary > redirected.bin\n' "$demo_download" +openai files "$demo_download" file-binary > redirected.bin +printf '%s\n' '$ cmp source.bin copy.bin && cmp source.bin redirected.bin' +cmp source.bin copy.bin +cmp source.bin redirected.bin +printf '%s\n' 'Binary: both downloads match all 12 bytes.' +printf '\n$ ' +sleep 4 diff --git a/scripts/demos/files-workflow/validate.py b/scripts/demos/files-workflow/validate.py new file mode 100644 index 00000000..c6f27ab3 --- /dev/null +++ b/scripts/demos/files-workflow/validate.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Check synthetic requests, saved bytes, and complete before/after transcripts.""" +import base64 +import json +from pathlib import Path +import sys + + +def check(condition, message): + if not condition: + raise SystemExit(message) + + +def main(): + check(len(sys.argv) == 2, 'usage: validate.py CAPTURE_DIRECTORY') + root = Path(sys.argv[1]) + requests = [json.loads(line) for line in (root / 'requests.jsonl').read_text().splitlines()] + check(len(requests) == 12, 'expected exactly six requests per scene') + expected = [('POST', '/v1/files'), ('GET', '/v1/files/file-example'), + ('GET', '/v1/files/file-example/content'), ('GET', '/v1/files/file-example/content'), + ('GET', '/v1/files/file-binary/content'), ('GET', '/v1/files/file-binary/content')] + check([(r['method'], r['path']) for r in requests] == expected * 2, 'unexpected request sequence') + for request in (requests[0], requests[6]): + check(request['content_type'].startswith('multipart/form-data; boundary='), 'upload was not multipart') + check(request['filename'] == 'upload space.txt', 'multipart filename changed') + check(request['purpose'] == 'user_data', 'explicit purpose changed') + check(base64.b64decode(request['bytes'], validate=True) == b'hello files!\n', 'uploaded bytes changed') + for scene in ['before', 'after']: + files = root / f'{scene}-files' + text = b'hello files!\n' + binary = bytes([0,255,13,10,27,65,128,0,7,8,9,10]) + for name in ['upload space.txt', 'downloaded copy.txt', 'copy.txt']: + check((files / name).read_bytes() == text, f'{scene}: {name} changed') + for name in ['source.bin', 'copy.bin', 'redirected.bin']: + check((files / name).read_bytes() == binary, f'{scene}: {name} changed') + transcript = (root / f'{scene}.txt').read_text() + for required in ['ID: file-example', 'Purpose: user_data', + 'Text: both downloads match all 13 bytes.', + 'Binary: both downloads match all 12 bytes.']: + check(required in transcript, f'{scene}: missing {required}') + before = (root / 'before.txt').read_text() + after = (root / 'after.txt').read_text() + check('Created at: 1700000000' in before, 'baseline timestamp changed') + check('Uploaded upload space.txt (13 B)' in after, 'successful upload receipt missing') + check('2023-11-14' in after, 'readable metadata timestamp missing') + check('Download it: openai files download file-example' in after, 'download suggestion missing') + print('PASS: 12 exact requests; two multipart filenames, purposes, and upload payloads') + print('PASS: eight text/binary downloads match source bytes through destinations and shell redirection') + print('PASS: both workflows exit zero; upload receipt, full ID, and readable timestamp appear after') + + +if __name__ == '__main__': + main() From 8f998118def2f4c8d288690ee6cb3501ce22c36b Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Thu, 8 Oct 2026 01:28:45 -0700 Subject: [PATCH 22/44] [files] preserve failure details and document recovery --- docs/files.md | 73 ++++++++++++++++++++++++++++++++ pkg/custom/file_failures_test.go | 46 ++++++++++++++++++++ pkg/custom/file_results.go | 39 +++++++++++++---- 3 files changed, 149 insertions(+), 9 deletions(-) create mode 100644 pkg/custom/file_failures_test.go diff --git a/docs/files.md b/docs/files.md index 4c2520cd..5b3a20e0 100644 --- a/docs/files.md +++ b/docs/files.md @@ -80,6 +80,8 @@ Download it: openai files download file-example --output 'upload space.txt' The receipt uses the returned filename, size, ID, and purpose. A successful upload does not mean that downstream processing has finished. +When the returned status is `error`, the receipt suggests inspecting the metadata. +An upload can return success while its status reports a processing error. `files get` shows metadata, including the full ID and timestamps in UTC. For example, `created_at: 1700000000` becomes: @@ -146,3 +148,74 @@ openai files upload --help openai files get --help openai files download --help ``` + +## Recover from a failed command + +A command can fail after the API receives an upload. +A missing receipt does not prove that the upload failed. +Do not repeat an upload only to obtain its receipt or more error details. +Malformed HTTP 200 metadata can currently return exit status zero. +Treat unexpected output as unconfirmed, even when the process succeeds. + +| Failure | Next action | +| --- | --- | +| Missing path, extra paths, or a positional path combined with `--file` | Provide one quoted path. Use `openai files upload "upload space.txt" --purpose user_data`. | +| Missing `--purpose` | Supply the purpose required by your workflow. The CLI does not choose it. | +| Missing local file or a directory instead of a file | Check the path relative to your current directory. Pass a readable file. Preserve a real leading `@`. | +| Permission denied | Check access to the source file or destination directory. Choose a location you can access. | +| HTTP 400 or 422 | Correct the rejected parameter, purpose, or file contents before another upload. Consult the Files upload requirements. | +| HTTP 401 or 403 | Use `openai help setup` for credential instructions. Check the selected project and its resource permissions. | +| HTTP 404 | Check the complete file ID and selected project. A deleted, expired, or inaccessible file might be unavailable. | +| HTTP 429 rate limit | Wait before another request. Billing or quota errors require correcting the stated limit. | +| HTTP 409, timeout, connection loss, or HTTP 5xx | Inspect the file before repeating an upload. The API might have received the request. | +| Processing status `error` | Inspect the metadata and status details. Correct the underlying input or workflow before another upload. | +| Missing destination directory | Create the parent directory or choose an existing directory. Then download to an unused filename. | +| Interrupted download, disk full, or write failure | Treat the destination as incomplete. Correct the cause before downloading to an unused filename. | +| Missing or malformed upload result, cancellation, or receipt write failure | Check for an accepted upload before repeating it. An absent receipt is not an upload rollback. | + +If you have the file ID, inspect it without uploading again: + +```sh +openai files get file-example --format json +``` + +If you do not have the ID, inspect recent files in the same project: + +```sh +openai files list --purpose user_data --order desc --limit 20 --max-items 20 --format json +``` + +Use the purpose from your original upload. +Compare the filename, byte count, and creation time. +Filenames are not unique. This short list cannot prove that no upload exists. +Continue through older files when necessary. + +For API details from a read request, use: + +```sh +openai files get file-example --format-error json +``` + +Do not repeat an uncertain upload just to add `--format-error json`. +An upload can succeed even when the terminal or pipe cannot display its result. +The CLI does not undo an accepted upload when output fails. + +Downloads can leave partial files after failure or interruption. +An existing `--output` destination can be truncated when downloading begins. +Shell redirection can truncate its destination before the CLI starts, even when the API request fails. +Choose an unused destination when recovering: + +```sh +openai files download file-example --output "downloaded retry.bin" +``` + +The parent directory must already exist. +Check the command's exit status before using the download. +If you have an expected byte count or checksum, compare it with the downloaded file. +The command does not resume a partial download. + +Read requests can retry transient failures. The CLI does not automatically replay streamed file uploads. +Cancellation stops local work; it does not prove that the API discarded the upload. +A valid File response confirms upload acceptance. Its processing status describes later processing. + +Recovery for API errors follows the [official error guidance](https://developers.openai.com/api/docs/guides/error-codes). diff --git a/pkg/custom/file_failures_test.go b/pkg/custom/file_failures_test.go new file mode 100644 index 00000000..5d0a9c1b --- /dev/null +++ b/pkg/custom/file_failures_test.go @@ -0,0 +1,46 @@ +package custom + +import ( + "bytes" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestFileReceiptPreservesUnfamiliarProcessingStatus(t *testing.T) { + for _, status := range []string{`"failed"`, `"pending"`, `""`, `false`, `1`, `{ "error": "synthetic failure" }`, `[]`} { + value := gjson.Parse(strings.TrimSuffix(fileReceiptFixture, "}") + `,"status":` + status + "}") + require.False(t, validFileReceipt(value), "status %s must retain full response presentation", status) + } + for _, status := range []string{`"uploaded"`, `"processed"`, `"error"`, `null`} { + value := gjson.Parse(strings.TrimSuffix(fileReceiptFixture, "}") + `,"status":` + status + "}") + require.True(t, validFileReceipt(value), status) + } +} + +func TestFileReceiptProcessingFailureSuggestsInspection(t *testing.T) { + value := gjson.Parse(strings.TrimSuffix(fileReceiptFixture, "}") + `,"status":"error","status_details":"synthetic processing failure"}`) + var out bytes.Buffer + require.NoError(t, writeFileReceipt(&out, value, "bash")) + require.Contains(t, out.String(), "Uploaded upload space.txt (13 B)") + require.Contains(t, out.String(), "Status: error") + require.Contains(t, out.String(), "Status details: synthetic processing failure") + require.Contains(t, out.String(), "Inspect it: openai files get file-example --format json") + require.NotContains(t, out.String(), "Download it:") +} + +func TestFileReceiptRejectsInvalidUTF8Fields(t *testing.T) { + for _, field := range []string{"id", "filename", "purpose"} { + raw := `{"object":"file","id":"file-example","filename":"upload.txt","purpose":"user_data"}` + old := map[string]string{"id": "file-example", "filename": "upload.txt", "purpose": "user_data"}[field] + value := gjson.Parse(strings.Replace(raw, old, "bad\xffvalue", 1)) + require.False(t, validFileReceipt(value), field) + if field != "purpose" { + var out bytes.Buffer + require.NoError(t, writeFileReceipt(&out, value, "bash")) + require.NotContains(t, out.String(), "Download it:", field) + } + } +} diff --git a/pkg/custom/file_results.go b/pkg/custom/file_results.go index dde210a7..3aed442e 100644 --- a/pkg/custom/file_results.go +++ b/pkg/custom/file_results.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "strings" + "unicode/utf8" "github.com/openai/openai-cli/internal/jsonview" "github.com/openai/openai-cli/internal/readable" @@ -49,7 +50,17 @@ func validFileReceipt(value gjson.Result) bool { } for _, name := range []string{"id", "filename", "purpose"} { field := value.Get(name) - if field.Type != gjson.String || field.Str == "" { + if field.Type != gjson.String || field.Str == "" || !utf8.ValidString(field.Str) { + return false + } + } + if status := value.Get("status"); status.Exists() && status.Type != gjson.Null { + if status.Type != gjson.String { + return false + } + switch status.Str { + case "uploaded", "processed", "error": + default: return false } } @@ -95,21 +106,31 @@ func writeFileReceipt(out io.Writer, value gjson.Result, shell string) error { return err } } - if shell == "" || strings.ContainsRune(id, 0) || strings.ContainsRune(filename, 0) || - strings.ContainsAny(filename, `/\`) || filename == "." || filename == ".." { + if shell == "" || strings.ContainsRune(id, 0) || !utf8.ValidString(id) { return nil } + verb, label := "download", "Download it: " + if value.Get("status").Str == "error" { + verb, label = "get", "Inspect it: " + } // Reuse the existing shell quoting contracts, including control characters. - args := []string{"files", "download", id} + args := []string{"files", verb, id} if strings.HasPrefix(id, "-") { - args = []string{"files", "download", "--file-id=" + id} + args = []string{"files", verb, "--file-id=" + id} } - if filename == "-" { - filename = "./-" + if verb == "get" { + args = append(args, "--format", "json") + } else { + if !utf8.ValidString(filename) || strings.ContainsRune(filename, 0) || strings.ContainsAny(filename, `/\`) || filename == "." || filename == ".." { + return nil + } + if filename == "-" { + filename = "./-" + } + args = append(args, "--output", filename) } - args = append(args, "--output", filename) if command := formatImagePickerCommand(args, shell); command != "" { - return readable.WriteText(out, "\nDownload it: "+command) + return readable.WriteText(out, "\n"+label+command) } return nil } From 17fccc9eadc673f34c80c3d418fb8df899f65788 Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Thu, 8 Oct 2026 01:47:15 -0700 Subject: [PATCH 23/44] [files] improve workflow discovery and honor quiet output --- pkg/custom/file_commands.go | 10 ++++++++++ pkg/custom/file_commands_test.go | 18 ++++++++++++++++++ pkg/custom/file_results.go | 4 ++++ 3 files changed, 32 insertions(+) diff --git a/pkg/custom/file_commands.go b/pkg/custom/file_commands.go index 7b4c3555..bf46420d 100644 --- a/pkg/custom/file_commands.go +++ b/pkg/custom/file_commands.go @@ -23,6 +23,8 @@ func configureFileCommands(root *cli.Command) { if files == nil { return } + files.Usage = "Upload, inspect, and download files." + files.Description = "Use upload for a local file, get for metadata, and download for contents.\nExisting create, retrieve, and content commands remain available." if upload := files.Command("upload"); upload != nil { upload.Usage = "Upload a local file with an explicit purpose." upload.UsageText = clihelp.Invocation(root.Name, os.Args) + " files upload [PATH | --file PATH] --purpose PURPOSE [options]" @@ -73,4 +75,12 @@ func configureFileCommands(root *cli.Command) { files.Commands = append(files.Commands, command) markCompatibilityCommand(source) } + for name, rank := range map[string]int{"upload": 10, "get": 20, "download": 30} { + if command := files.Command(name); command != nil { + if command.Metadata == nil { + command.Metadata = map[string]any{} + } + command.Metadata["help-command-rank"] = rank + } + } } diff --git a/pkg/custom/file_commands_test.go b/pkg/custom/file_commands_test.go index 5ed7b2df..cc31d7a2 100644 --- a/pkg/custom/file_commands_test.go +++ b/pkg/custom/file_commands_test.go @@ -7,6 +7,7 @@ import ( "reflect" "testing" + "github.com/openai/openai-cli/internal/clihelp" "github.com/openai/openai-cli/internal/requestflag" "github.com/stretchr/testify/require" "github.com/urfave/cli/v3" @@ -160,3 +161,20 @@ func TestFileCommandsPreserveExistingFriendlyRoutes(t *testing.T) { require.False(t, files.Command("content").Hidden) require.NotPanics(t, func() { configureFileCommands(&cli.Command{Name: "openai"}) }) } + +func TestFileCommandsMakeWorkflowDiscoverable(t *testing.T) { + root := fileWorkflowCommandTree(func(context.Context, *cli.Command) error { return nil }) + files := root.Command("files") + files.Commands = append(files.Commands, &cli.Command{Name: "list"}, &cli.Command{Name: "delete"}) + configureTaskCommands(root) + configureFileCommands(root) + require.Equal(t, "Upload, inspect, and download files.", files.Usage) + var names []string + for _, command := range clihelp.VisibleCommands(files) { + names = append(names, command.Name) + } + require.Equal(t, []string{"upload", "get", "download", "list", "delete"}, names) + for _, name := range []string{"create", "retrieve", "content"} { + require.NotNil(t, files.Command(name)) + } +} diff --git a/pkg/custom/file_results.go b/pkg/custom/file_results.go index 3aed442e..ad9a3935 100644 --- a/pkg/custom/file_results.go +++ b/pkg/custom/file_results.go @@ -35,6 +35,10 @@ func showFileResult(value gjson.Result, opts ShowJSONOpts) (bool, error) { } return true, readable.Write(out, metadata) case command == fileUploadCommand && opts.Operation == "(resource) files > (method) create": + if !outputDiagnosticsAllowed(opts.Context) { + // Quiet suppresses the receipt, while ordinary rendering keeps data. + return false, nil + } if !validFileReceipt(value) { return false, nil } From ee41a9afba901f115262831cccf5ca7f75b971f3 Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Thu, 8 Oct 2026 02:13:23 -0700 Subject: [PATCH 24/44] [files] explain the workflow in complete help --- pkg/custom/file_commands.go | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/pkg/custom/file_commands.go b/pkg/custom/file_commands.go index bf46420d..7179d860 100644 --- a/pkg/custom/file_commands.go +++ b/pkg/custom/file_commands.go @@ -25,10 +25,32 @@ func configureFileCommands(root *cli.Command) { } files.Usage = "Upload, inspect, and download files." files.Description = "Use upload for a local file, get for metadata, and download for contents.\nExisting create, retrieve, and content commands remain available." + if files.Metadata == nil { + files.Metadata = map[string]any{} + } + files.Metadata["help-content"] = clihelp.Content{ + Description: files.Description, + Examples: []clihelp.Example{ + {Description: "Upload a local file:", Command: `files upload "upload space.txt" --purpose user_data`}, + {Description: "Inspect its metadata:", Command: `files get file-example`}, + {Description: "Save its contents:", Command: `files download file-example --output "downloaded copy.txt"`}, + }, + } if upload := files.Command("upload"); upload != nil { + if upload.Metadata == nil { + upload.Metadata = map[string]any{} + } upload.Usage = "Upload a local file with an explicit purpose." upload.UsageText = clihelp.Invocation(root.Name, os.Args) + " files upload [PATH | --file PATH] --purpose PURPOSE [options]" upload.Description = "Pass a plain local path. Paths with spaces need quotes.\nA leading @ is part of the filename; do not add @ to a plain path.\nUse --purpose PURPOSE -- -name for a filename starting with a dash.\nThe API validates file types and purposes. Upload success does not mean processing is complete." + upload.Metadata["help-content"] = clihelp.Content{ + InputNote: "A path is required: use PATH or --file PATH. Choose --purpose explicitly. " + + "The existing piped JSON/YAML keys are file and purpose.", + Description: upload.Description, + Examples: []clihelp.Example{ + {Description: "Upload an existing file:", Command: `files upload "upload space.txt" --purpose user_data`}, + }, + } upload.Arguments = []cli.Argument{&cli.StringArgs{Name: "path", Max: 1}} next := upload.Action upload.Action = func(ctx context.Context, command *cli.Command) error { @@ -50,7 +72,7 @@ func configureFileCommands(root *cli.Command) { } } for _, route := range []struct{ name, source, usage string }{ - {"get", "retrieve", "Show file metadata, including its ID and creation time."}, + {"get", "retrieve", "Inspect file metadata."}, {"download", "content", "Download file contents to a path or stdout."}, } { if files.Command(route.name) != nil { From e7e86d891c66e562046f56aeb6f0924d87ea57af Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Thu, 8 Oct 2026 02:27:47 -0700 Subject: [PATCH 25/44] [files] complete positional paths and preserve literal filenames --- cmd/openai/files_workflow_test.go | 48 ++++++ internal/autocomplete/autocomplete.go | 31 +++- internal/autocomplete/autocomplete_test.go | 11 +- .../autocomplete/literal_file_paths_test.go | 55 ++++++ internal/autocomplete/positional_file_test.go | 158 ++++++++++++++++++ .../shellscripts/bash_autocomplete.bash | 2 +- .../shellscripts/fish_autocomplete.fish | 2 +- .../shellscripts/pwsh_autocomplete.ps1 | 2 +- .../shellscripts/zsh_autocomplete.zsh | 2 +- pkg/custom/file_commands.go | 1 + 10 files changed, 304 insertions(+), 8 deletions(-) create mode 100644 internal/autocomplete/literal_file_paths_test.go create mode 100644 internal/autocomplete/positional_file_test.go diff --git a/cmd/openai/files_workflow_test.go b/cmd/openai/files_workflow_test.go index 8abc81f9..3e938923 100644 --- a/cmd/openai/files_workflow_test.go +++ b/cmd/openai/files_workflow_test.go @@ -386,3 +386,51 @@ func TestMainFilesWorkflowHelpAndCompletionStayLocal(t *testing.T) { require.Equal(t, mainDispatchResult{code: 10}, got, "%s file completion", style) } } + +func TestMainFilesWorkflowHelpExplainsPaths(t *testing.T) { + got := runMainDispatch(t, "bash", "openai", "files", "upload", "--help") + require.Zero(t, got.code, "%+v", got) + require.Empty(t, got.stderr) + text := strings.Join(strings.Fields(got.stdout), " ") + require.Contains(t, text, "With upload, PATH can replace --file PATH.") + require.Contains(t, text, "A leading @ is literal.") +} + +func TestMainFilesWorkflowPositionalCompletion(t *testing.T) { + env := []string{"OPENAI_BASE_URL=invalid-files-completion-url", "OPENAI_CLI_COMPLETION_FILE_VALUES=1"} + for _, style := range []string{"bash", "zsh", "fish", "pwsh"} { + for _, args := range [][]string{ + {"files", "upload", ""}, + {"files", "upload", "upload sp"}, + {"files", "upload", "@literal"}, + {"files", "upload", "--purpose", "user_data", ""}, + {"files", "upload", "--purpose", "user_data", "--", "-literal"}, + } { + got := runMainDispatchWithEnv(t, style, env, mainCompletionArgs(style, args...)...) + require.Equal(t, mainDispatchResult{code: 10}, got, "%s %q", style, args) + } + for _, args := range [][]string{ + {"files", "upload", "first.txt", ""}, + {"files", "upload", "--file", "first.txt", ""}, + {"files", "upload", "--file=first.txt", ""}, + {"files", "upload", "--purpose", ""}, + } { + got := runMainDispatchWithEnv(t, style, env, mainCompletionArgs(style, args...)...) + require.Equal(t, mainDispatchResult{code: 11}, got, "%s %q", style, args) + } + } +} + +func TestMainFilesWorkflowHelpAfterOperands(t *testing.T) { + for _, args := range [][]string{ + {"files", "upload", "missing.txt", "--purpose", "user_data", "--help"}, + {"files", "get", "file-example", "--help"}, + {"files", "download", "file-example", "--output", "absent/target.txt", "--help"}, + } { + got := runMainDispatchWithEnv(t, "bash", []string{"OPENAI_BASE_URL=invalid-files-help-url"}, append([]string{"openai"}, args...)...) + require.Zero(t, got.code, "%+v", got) + require.Empty(t, got.stderr) + require.Contains(t, got.stdout, "SYNOPSIS:") + require.Contains(t, got.stdout, "openai files "+args[1]) + } +} diff --git a/internal/autocomplete/autocomplete.go b/internal/autocomplete/autocomplete.go index 8944f77b..f0844a18 100644 --- a/internal/autocomplete/autocomplete.go +++ b/internal/autocomplete/autocomplete.go @@ -272,6 +272,8 @@ func getAllPossibleCompletions(completionStyle CompletionStyle, root *cli.Comman localHelp, _ = help.Metadata["help-topic-command"].(bool) } helpTopics, literal := false, false + positionalCount := 0 + var usedFlags []cli.Flag i := 0 for i < len(preceding) { arg := preceding[i] @@ -302,7 +304,9 @@ func getAllPossibleCompletions(completionStyle CompletionStyle, root *cli.Comman } if flag == nil { return CompletionResult{Behavior: ShellCompletionBehaviorNoComplete} - } else if docFlag, ok := (*flag).(cli.DocGenerationFlag); ok && docFlag.TakesValue() && !assigned { + } + usedFlags = append(usedFlags, *flag) + if docFlag, ok := (*flag).(cli.DocGenerationFlag); ok && docFlag.TakesValue() && !assigned { if i == len(preceding)-1 { return flagValueCompletion(*flag) } @@ -311,6 +315,12 @@ func getAllPossibleCompletions(completionStyle CompletionStyle, root *cli.Comman i++ } } else { + fileFlag, _ := cmd.Metadata["completion-positional-file"].(string) + if fileFlag != "" && !helpTopics && (literal || arg == "") { + positionalCount++ + i++ + continue + } if arg == "" { i++ continue @@ -330,6 +340,7 @@ func getAllPossibleCompletions(completionStyle CompletionStyle, root *cli.Comman } if child != nil { cmd = child + positionalCount = 0 lineage = append(lineage, child) if !helpTopics { flags = completionFlags(lineage) @@ -337,6 +348,8 @@ func getAllPossibleCompletions(completionStyle CompletionStyle, root *cli.Comman } else if helpTopics || len(cmd.Commands) > 0 { // A failed group traversal must not offer commands from its parent. return CompletionResult{Behavior: ShellCompletionBehaviorNoComplete} + } else { + positionalCount++ } i++ } @@ -369,6 +382,22 @@ func getAllPossibleCompletions(completionStyle CompletionStyle, root *cli.Comman } } + // Metadata opts one positional path into its existing alternate file flag. + // Keep flag-value completion above and help-topic traversal unchanged. + fileFlag, _ := cmd.Metadata["completion-positional-file"].(string) + if fileFlag != "" && !helpTopics && (literal || !isFlag(current)) { + if positionalCount == 0 { + if flag := findFlag(flags, fileFlag); flag != nil && !slices.Contains(usedFlags, *flag) { + result := flagValueCompletion(*flag) + if result.Behavior == ShellCompletionBehaviorFile { + result.requiresFileValueSupport = true + return result + } + } + } + return CompletionResult{Behavior: ShellCompletionBehaviorNoComplete} + } + // Keep compatibility aliases out of discovery unless a colon requests them. colonPrefix := strings.Contains(current, ":") children := completionCommands(cmd) diff --git a/internal/autocomplete/autocomplete_test.go b/internal/autocomplete/autocomplete_test.go index 3a5c616e..03ad2039 100644 --- a/internal/autocomplete/autocomplete_test.go +++ b/internal/autocomplete/autocomplete_test.go @@ -195,6 +195,9 @@ if ! type mapfile >/dev/null 2>&1; then fi openai() { + for argument in "$@"; do + if [[ "$argument" == --input ]]; then return 11; fi + done return 10 } @@ -202,7 +205,7 @@ openai() { printf 'spec:%s\n' "$(complete -p openai)" -COMP_WORDS=(openai files create --file '@$(') +COMP_WORDS=(openai responses create --input '@$(') COMP_CWORD=4 __openai_bash_autocomplete printf 'forced:%s\n' "${COMPREPLY[0]}" @@ -391,7 +394,8 @@ sed() { } openai() { - return 10 + # Generic JSON references are not literal binary-file values. + return 11 } ` + completionScript + ` @@ -405,7 +409,8 @@ for completion in "${COMPREPLY[@]}"; do done ` - args := append([]string{"-c", probe, "bash-completion-probe", directory}, tt.words...) + words := append([]string{"openai", "responses", "create", "--input"}, tt.words[2:]...) + args := append([]string{"-c", probe, "bash-completion-probe", directory}, words...) out, err := exec.Command(bash, args...).CombinedOutput() if !assert.NoError(t, err, string(out)) { return diff --git a/internal/autocomplete/literal_file_paths_test.go b/internal/autocomplete/literal_file_paths_test.go new file mode 100644 index 00000000..0d51495f --- /dev/null +++ b/internal/autocomplete/literal_file_paths_test.go @@ -0,0 +1,55 @@ +package autocomplete + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" +) + +func TestBashLiteralFileCompletionKeepsLeadingAt(t *testing.T) { + bash, err := exec.LookPath("bash") + if err != nil { + t.Skip("bash is not available") + } + script, err := shellCompletions[CompletionStyleBash](&cli.Command{}, "openai") + require.NoError(t, err) + for _, words := range [][]string{ + {"openai", "files", "upload", "@"}, + {"openai", "files", "upload", "--file", "@"}, + {"openai", "files", "upload", "--file=@"}, + } { + directory := t.TempDir() + for _, name := range []string{"plain.txt", "@literal.txt", "@literal space.txt"} { + require.NoError(t, os.WriteFile(filepath.Join(directory, name), nil, 0o600)) + } + probe := ` +cd "$1" || exit 1 +shift +openai() { + local argument + for argument in "$@"; do + case "$argument" in --file=*) printf '%s\n' '--file=';; esac + done + return 10 +} +` + script + ` +COMP_WORDS=("$@") +COMP_CWORD=$((${#COMP_WORDS[@]} - 1)) +__openai_bash_autocomplete +printf '%s\n' "${COMPREPLY[@]}" +` + out, err := exec.Command(bash, append([]string{"--noprofile", "--norc", "-c", probe, "literal-file-probe", directory}, words...)...).CombinedOutput() + require.NoError(t, err, string(out)) + prefix := "" + if strings.HasPrefix(words[len(words)-1], "--file=") { + prefix = "--file=" + } + require.ElementsMatch(t, []string{prefix + "@literal.txt", prefix + "@literal space.txt"}, strings.Split(strings.TrimSuffix(string(out), "\n"), "\n")) + require.NotContains(t, string(out), "@plain.txt") + } +} diff --git a/internal/autocomplete/positional_file_test.go b/internal/autocomplete/positional_file_test.go new file mode 100644 index 00000000..83d2bb85 --- /dev/null +++ b/internal/autocomplete/positional_file_test.go @@ -0,0 +1,158 @@ +package autocomplete + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/openai/openai-cli/internal/clihelp" + "github.com/openai/openai-cli/internal/requestflag" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" +) + +func positionalFileCompletionTree(t *testing.T) *cli.Command { + t.Helper() + root := &cli.Command{Name: "openai", Flags: []cli.Flag{ + &cli.StringFlag{Name: "project"}, + &cli.StringFlag{Name: "file", TakesFile: true}, + }, Commands: []*cli.Command{ + {Name: "files", Commands: []*cli.Command{ + {Name: "upload", Metadata: map[string]any{"completion-positional-file": "file"}, Flags: []cli.Flag{ + &requestflag.Flag[string]{Name: "file", Aliases: []string{"f"}, FileInput: true}, + &requestflag.Flag[string]{Name: "purpose", Aliases: []string{"p"}}, + }}, + {Name: "create", Flags: []cli.Flag{&requestflag.Flag[string]{Name: "file", FileInput: true}}}, + {Name: "get", Flags: []cli.Flag{&requestflag.Flag[string]{Name: "file-id"}}}, + }}, + }} + _, _, err := clihelp.Configure(root, []string{"openai"}) + require.NoError(t, err) + return root +} + +func TestPositionalFileCompletionOnlyFirstEligiblePath(t *testing.T) { + for _, style := range []CompletionStyle{CompletionStyleBash, CompletionStyleZsh, CompletionStyleFish, CompletionStylePowershell} { + for _, tc := range []struct { + args []string + want ShellCompletionBehavior + }{ + {[]string{"files", "upload", ""}, ShellCompletionBehaviorFile}, + {[]string{"files", "upload", "upload sp"}, ShellCompletionBehaviorFile}, + {[]string{"files", "upload", "@literal"}, ShellCompletionBehaviorFile}, + {[]string{"files", "upload", "a=b"}, ShellCompletionBehaviorFile}, + {[]string{"files", "upload", "--purpose", "user_data", ""}, ShellCompletionBehaviorFile}, + {[]string{"files", "upload", "--purpose=user_data", ""}, ShellCompletionBehaviorFile}, + {[]string{"--project", "help", "files", "upload", ""}, ShellCompletionBehaviorFile}, + {[]string{"files", "--project", "upload", "upload", ""}, ShellCompletionBehaviorFile}, + {[]string{"--file", "root-only.txt", "files", "upload", ""}, ShellCompletionBehaviorFile}, + {[]string{"files", "upload", "--", "-literal"}, ShellCompletionBehaviorFile}, + {[]string{"files", "upload", "--", "--file=x"}, ShellCompletionBehaviorFile}, + {[]string{"files", "upload", "--", ""}, ShellCompletionBehaviorFile}, + {[]string{"files", "upload", "first.txt", ""}, ShellCompletionBehaviorNoComplete}, + {[]string{"files", "upload", "", ""}, ShellCompletionBehaviorNoComplete}, + {[]string{"files", "upload", "first.txt", "second.txt", ""}, ShellCompletionBehaviorNoComplete}, + {[]string{"files", "upload", "first.txt", "--purpose", "user_data", ""}, ShellCompletionBehaviorNoComplete}, + {[]string{"files", "upload", "--", "first.txt", ""}, ShellCompletionBehaviorNoComplete}, + {[]string{"files", "upload", "--", "--file=x", ""}, ShellCompletionBehaviorNoComplete}, + {[]string{"files", "upload", "--file", "chosen.txt", ""}, ShellCompletionBehaviorNoComplete}, + {[]string{"files", "upload", "--file=chosen.txt", ""}, ShellCompletionBehaviorNoComplete}, + {[]string{"files", "upload", "-f", "chosen.txt", ""}, ShellCompletionBehaviorNoComplete}, + {[]string{"files", "upload", "-f=chosen.txt", ""}, ShellCompletionBehaviorNoComplete}, + {[]string{"files", "upload", "--file=", ""}, ShellCompletionBehaviorNoComplete}, + {[]string{"files", "upload", "--file", "", ""}, ShellCompletionBehaviorNoComplete}, + {[]string{"files", "upload", "--file", "--", ""}, ShellCompletionBehaviorNoComplete}, + {[]string{"files", "upload", "--file=one", "--file=two", ""}, ShellCompletionBehaviorNoComplete}, + {[]string{"files", "upload", "--unknown", ""}, ShellCompletionBehaviorNoComplete}, + {[]string{"files", "missing", ""}, ShellCompletionBehaviorNoComplete}, + {[]string{"missing", "files", "upload", ""}, ShellCompletionBehaviorNoComplete}, + } { + t.Run(string(style)+"/"+strings.Join(tc.args, "|"), func(t *testing.T) { + got := GetCompletions(style, positionalFileCompletionTree(t), tc.args) + require.Equal(t, tc.want, got.Behavior) + require.Empty(t, got.Completions) + require.Empty(t, got.FileValuePrefix) + if tc.want == ShellCompletionBehaviorFile { + require.True(t, got.requiresFileValueSupport) + } + }) + } + } +} + +func TestPositionalFileCompletionPreservesOtherCompletion(t *testing.T) { + for _, tc := range []struct { + args []string + want ShellCompletionBehavior + prefix string + names []string + }{ + {args: []string{"files", "upload", "--p"}, names: []string{"--purpose", "--project"}}, + {args: []string{"files", "upload", "first.txt", "--p"}, names: []string{"--purpose", "--project"}}, + {args: []string{"files", "upload", "--purpose", ""}, want: ShellCompletionBehaviorNoComplete}, + {args: []string{"files", "upload", "first.txt", "--purpose", ""}, want: ShellCompletionBehaviorNoComplete}, + {args: []string{"files", "upload", "--file", ""}, want: ShellCompletionBehaviorFile}, + {args: []string{"files", "upload", "first.txt", "--file", ""}, want: ShellCompletionBehaviorFile}, + {args: []string{"files", "upload", "--file", "--file=literal"}, want: ShellCompletionBehaviorFile}, + {args: []string{"files", "upload", "--file=assets/a"}, want: ShellCompletionBehaviorFile, prefix: "--file="}, + {args: []string{"files", "upload", "first.txt", "-f=assets/a"}, want: ShellCompletionBehaviorFile, prefix: "-f="}, + {args: []string{"files", "upload", "--purpose=assets/a"}, want: ShellCompletionBehaviorNoComplete}, + {args: []string{"files", "create", ""}}, + {args: []string{"files", "create", "--file", ""}, want: ShellCompletionBehaviorFile}, + {args: []string{"files", "get", "file-example", ""}}, + {args: []string{"help", "files", "upload", ""}}, + {args: []string{"files", "help", "upload", ""}}, + {args: []string{"help", "--all", "files", "upload", ""}}, + {args: []string{"help", "--all=false", "files", "upload", ""}}, + {args: []string{"help", "--all=bad", "files", "upload", ""}, want: ShellCompletionBehaviorNoComplete}, + } { + t.Run(strings.Join(tc.args, "|"), func(t *testing.T) { + got := GetCompletions(CompletionStyleBash, positionalFileCompletionTree(t), tc.args) + require.Equal(t, tc.want, got.Behavior) + require.Equal(t, tc.prefix, got.FileValuePrefix) + require.ElementsMatch(t, tc.names, completionNames(got.Completions)) + }) + } +} + +func TestPositionalFileCompletionMetadataRequiresFileFlag(t *testing.T) { + for _, metadata := range []any{nil, true, "", "missing", "purpose"} { + root := positionalFileCompletionTree(t) + upload := root.Command("files").Command("upload") + upload.Metadata["completion-positional-file"] = metadata + got := GetCompletions(CompletionStyleBash, root, []string{"files", "upload", ""}) + require.NotEqual(t, ShellCompletionBehaviorFile, got.Behavior, "%v", metadata) + } +} + +func TestPositionalFileCompletionProtocolCapabilityGate(t *testing.T) { + for _, style := range []string{"bash", "zsh", "fish", "pwsh"} { + for _, capability := range []string{"", "other-adapter", "1"} { + t.Run(style+"/"+capability, func(t *testing.T) { + t.Setenv("COMPLETION_STYLE", style) + t.Setenv("OPENAI_CLI_COMPLETION_FILE_VALUES", capability) + root := positionalFileCompletionTree(t) + var output bytes.Buffer + root.Writer = &output + root.SkipFlagParsing = true + root.ExitErrHandler = func(context.Context, *cli.Command, error) {} + root.Commands = append(root.Commands, &cli.Command{Name: "__complete", Hidden: true, SkipFlagParsing: true, Action: ExecuteShellCompletion}) + args := []string{"openai", "__complete"} + if style != "zsh" { + args = append(args, "--") + } + args = append(args, "files", "upload", "a=b") + err := root.Run(t.Context(), args) + var exit cli.ExitCoder + require.ErrorAs(t, err, &exit) + want := ShellCompletionBehaviorNoComplete + if capability == "1" { + want = ShellCompletionBehaviorFile + } + require.Equal(t, int(want), exit.ExitCode()) + require.Empty(t, output.String()) + }) + } + } +} diff --git a/internal/autocomplete/shellscripts/bash_autocomplete.bash b/internal/autocomplete/shellscripts/bash_autocomplete.bash index b5b319b0..ad4c5fbf 100755 --- a/internal/autocomplete/shellscripts/bash_autocomplete.bash +++ b/internal/autocomplete/shellscripts/bash_autocomplete.bash @@ -87,7 +87,7 @@ ____APPNAME___bash_autocomplete() { local prefix="" local file_part="$cur" local force_file_completion=false - if [[ "$last_token" =~ (.*)@(file://|data://)?(.*)$ ]]; then + if [[ "$exit_code" -ne 10 && "$last_token" =~ (.*)@(file://|data://)?(.*)$ ]]; then local before_at="${BASH_REMATCH[1]}" local protocol="${BASH_REMATCH[2]}" file_part="${BASH_REMATCH[3]}" diff --git a/internal/autocomplete/shellscripts/fish_autocomplete.fish b/internal/autocomplete/shellscripts/fish_autocomplete.fish index ddc16ce6..27b7fc2c 100644 --- a/internal/autocomplete/shellscripts/fish_autocomplete.fish +++ b/internal/autocomplete/shellscripts/fish_autocomplete.fish @@ -16,7 +16,7 @@ function ____APPNAME___fish_autocomplete set -l file_part "$current" set -l force_file_completion 0 - if string match -gqr '^(?.*)@(?file://|data://)?(?.*)$' -- $current + if test $exit_code -ne 10; and string match -gqr '^(?.*)@(?file://|data://)?(?.*)$' -- $current if string match -qr '^[\'"]' -- $before # Ensures we don't insert an extra quote when the user is building an argument in quotes set before (string sub -s 2 -- $before) diff --git a/internal/autocomplete/shellscripts/pwsh_autocomplete.ps1 b/internal/autocomplete/shellscripts/pwsh_autocomplete.ps1 index 7b7f269c..100f1c18 100644 --- a/internal/autocomplete/shellscripts/pwsh_autocomplete.ps1 +++ b/internal/autocomplete/shellscripts/pwsh_autocomplete.ps1 @@ -50,7 +50,7 @@ Register-ArgumentCompleter -Native -CommandName __APPNAME__ -ScriptBlock { $forceFileCompletion = $false - if ($wordContent -match '^(.*)@(file://|data://)?(.*)$') { + if ($exitCode -ne 10 -and $wordContent -match '^(.*)@(file://|data://)?(.*)$') { $prefix = $leadingQuote + $Matches[1] + '@' + $Matches[2] $filePart = $Matches[3] $forceFileCompletion = $true diff --git a/internal/autocomplete/shellscripts/zsh_autocomplete.zsh b/internal/autocomplete/shellscripts/zsh_autocomplete.zsh index bd4a9b75..1ddf87bb 100644 --- a/internal/autocomplete/shellscripts/zsh_autocomplete.zsh +++ b/internal/autocomplete/shellscripts/zsh_autocomplete.zsh @@ -14,7 +14,7 @@ ____APPNAME___zsh_autocomplete() { # Patterns can appear anywhere in the word (e.g., inside quotes: 'my file is @file://path') local cur="${words[CURRENT]}" - if [[ "$cur" = *'@'* ]]; then + if [[ "$exit_code" -ne 10 && "$cur" = *'@'* ]]; then # Extract everything after the last @ local after_last_at="${cur##*@}" diff --git a/pkg/custom/file_commands.go b/pkg/custom/file_commands.go index 7179d860..36bd7e7c 100644 --- a/pkg/custom/file_commands.go +++ b/pkg/custom/file_commands.go @@ -40,6 +40,7 @@ func configureFileCommands(root *cli.Command) { if upload.Metadata == nil { upload.Metadata = map[string]any{} } + upload.Metadata["completion-positional-file"] = "file" upload.Usage = "Upload a local file with an explicit purpose." upload.UsageText = clihelp.Invocation(root.Name, os.Args) + " files upload [PATH | --file PATH] --purpose PURPOSE [options]" upload.Description = "Pass a plain local path. Paths with spaces need quotes.\nA leading @ is part of the filename; do not add @ to a plain path.\nUse --purpose PURPOSE -- -name for a filename starting with a dash.\nThe API validates file types and purposes. Upload success does not mean processing is complete." From 62a8cb1af6a3dc159db33ad21f2b966b458e58a3 Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Thu, 8 Oct 2026 02:39:13 -0700 Subject: [PATCH 26/44] [files] preserve executable identity in recovery commands --- pkg/custom/file_commands.go | 16 ++++++++++++++++ pkg/custom/file_failures_test.go | 4 ++-- pkg/custom/file_results.go | 33 ++++++++++++++++++++++++++++++-- pkg/custom/file_results_test.go | 26 +++++++++++++++++++------ 4 files changed, 69 insertions(+), 10 deletions(-) diff --git a/pkg/custom/file_commands.go b/pkg/custom/file_commands.go index 36bd7e7c..b39401a3 100644 --- a/pkg/custom/file_commands.go +++ b/pkg/custom/file_commands.go @@ -5,12 +5,20 @@ import ( "errors" "fmt" "os" + "path/filepath" + "strings" "github.com/openai/openai-cli/internal/clihelp" "github.com/urfave/cli/v3" ) type fileCommandKey struct{} +type fileInvocationKey struct{} + +type fileInvocation struct { + display string + executable string +} const ( fileUploadCommand = "upload" @@ -69,6 +77,14 @@ func configureFileCommands(root *cli.Command) { return err } } + invocation := fileInvocation{display: errorHelpInvocation(command.Root())} + if len(os.Args) > 0 { + invocation.executable = os.Args[0] + if strings.TrimSuffix(filepath.Base(os.Args[0]), ".exe") != command.Root().Name { + invocation.display = os.Args[0] + } + } + ctx = context.WithValue(ctx, fileInvocationKey{}, invocation) return next(context.WithValue(ctx, fileCommandKey{}, fileUploadCommand), command) } } diff --git a/pkg/custom/file_failures_test.go b/pkg/custom/file_failures_test.go index 5d0a9c1b..dfe2b29b 100644 --- a/pkg/custom/file_failures_test.go +++ b/pkg/custom/file_failures_test.go @@ -23,7 +23,7 @@ func TestFileReceiptPreservesUnfamiliarProcessingStatus(t *testing.T) { func TestFileReceiptProcessingFailureSuggestsInspection(t *testing.T) { value := gjson.Parse(strings.TrimSuffix(fileReceiptFixture, "}") + `,"status":"error","status_details":"synthetic processing failure"}`) var out bytes.Buffer - require.NoError(t, writeFileReceipt(&out, value, "bash")) + require.NoError(t, writeFileReceipt(&out, value, "bash", fileInvocation{display: "openai"})) require.Contains(t, out.String(), "Uploaded upload space.txt (13 B)") require.Contains(t, out.String(), "Status: error") require.Contains(t, out.String(), "Status details: synthetic processing failure") @@ -39,7 +39,7 @@ func TestFileReceiptRejectsInvalidUTF8Fields(t *testing.T) { require.False(t, validFileReceipt(value), field) if field != "purpose" { var out bytes.Buffer - require.NoError(t, writeFileReceipt(&out, value, "bash")) + require.NoError(t, writeFileReceipt(&out, value, "bash", fileInvocation{display: "openai"})) require.NotContains(t, out.String(), "Download it:", field) } } diff --git a/pkg/custom/file_results.go b/pkg/custom/file_results.go index ad9a3935..03e7784b 100644 --- a/pkg/custom/file_results.go +++ b/pkg/custom/file_results.go @@ -42,7 +42,8 @@ func showFileResult(value gjson.Result, opts ShowJSONOpts) (bool, error) { if !validFileReceipt(value) { return false, nil } - return true, writeFileReceipt(out, value, imagePickerParentShell(opts.Context)) + invocation, _ := opts.Context.Value(fileInvocationKey{}).(fileInvocation) + return true, writeFileReceipt(out, value, imagePickerParentShell(opts.Context), invocation) default: return false, nil } @@ -89,7 +90,7 @@ func validFileReceipt(value gjson.Result) bool { return valid } -func writeFileReceipt(out io.Writer, value gjson.Result, shell string) error { +func writeFileReceipt(out io.Writer, value gjson.Result, shell string, invocation fileInvocation) error { // Keep each response value on one line before adding our own layout. safe := func(text string) string { return readable.Text(jsonview.SanitizeTerminalString(text)) } filename, id := value.Get("filename").Str, value.Get("id").Str @@ -134,7 +135,35 @@ func writeFileReceipt(out io.Writer, value gjson.Result, shell string) error { args = append(args, "--output", filename) } if command := formatImagePickerCommand(args, shell); command != "" { + prefix := fileReceiptInvocation(invocation, shell) + if prefix == "" { + return nil + } + command = prefix + strings.TrimPrefix(command, "openai") return readable.WriteText(out, "\n"+label+command) } return nil } + +// Keep help's executable selection, using the actual shell for quoted paths. +func fileReceiptInvocation(invocation fileInvocation, shell string) string { + if !utf8.ValidString(invocation.executable) { + return "" + } + if invocation.display == "" { + return "openai" + } + plain, _ := imagePickerQuoteProperties(invocation.display) + if plain || strings.HasPrefix(invocation.display, "go run ") || invocation.executable == "" { + return invocation.display + } + quote := imagePickerShellQuoter(shell) + if quote == nil { + return "" + } + command := quote(invocation.executable) + if shell == "pwsh" { + command = "& " + command + } + return command +} diff --git a/pkg/custom/file_results_test.go b/pkg/custom/file_results_test.go index 5ae2b7a3..371fd065 100644 --- a/pkg/custom/file_results_test.go +++ b/pkg/custom/file_results_test.go @@ -60,7 +60,7 @@ func TestFileReceiptValuesAndUnknownShell(t *testing.T) { value := gjson.Parse(strings.Replace(fileReceiptFixture, `"bytes":13`, `"bytes":`+tc.bytes, 1)) for _, shell := range []string{"", "unknown-shell"} { var out bytes.Buffer - require.NoError(t, writeFileReceipt(&out, value, shell)) + require.NoError(t, writeFileReceipt(&out, value, shell, fileInvocation{display: "openai"})) require.Equal(t, "Uploaded upload space.txt"+tc.suffix+"\nID: file-example\nPurpose: user_data\n", out.String()) } } @@ -80,7 +80,7 @@ func TestFileReceiptFallsBackForInvalidByteCounts(t *testing.T) { func TestFileReceiptEscapesFieldsAndRetainsProcessingFailure(t *testing.T) { value := gjson.Parse(`{"object":"file","id":"file-\nID: spoof\u001b","filename":"a\r\n\t\u202e.txt","purpose":"user_data\u0007","bytes":0,"status":"error","status_details":"failed\nPurpose: spoof"}`) var out bytes.Buffer - require.NoError(t, writeFileReceipt(&out, value, "")) + require.NoError(t, writeFileReceipt(&out, value, "", fileInvocation{display: "openai"})) require.Equal(t, "Uploaded a\\r\\n\\t\\u202e.txt (0 B)\nID: file-\\nID: spoof\\u001b\nPurpose: user_data\\u0007\nStatus: error\nStatus details: failed\\nPurpose: spoof\n", out.String()) for _, control := range []string{"\r", "\t", "\x1b", "\x07", "\u202e"} { require.NotContains(t, out.String(), control) @@ -105,7 +105,7 @@ func TestFileReceiptCommandsPreserveShellArguments(t *testing.T) { data, err := json.Marshal(map[string]any{"object": "file", "id": tc.id, "filename": tc.filename, "purpose": "user_data", "bytes": 0}) require.NoError(t, err) var out bytes.Buffer - require.NoError(t, writeFileReceipt(&out, gjson.ParseBytes(data), shell)) + require.NoError(t, writeFileReceipt(&out, gjson.ParseBytes(data), shell, fileInvocation{display: "openai"})) _, command, found := strings.Cut(out.String(), "\nDownload it: ") require.True(t, found) command = strings.TrimSuffix(command, "\n") @@ -139,12 +139,12 @@ func TestFileReceiptPropagatesOutputFailuresAndCancellation(t *testing.T) { failure := errors.New("synthetic output failure") for _, tc := range []struct{ returned, want error }{{failure, failure}, {nil, io.ErrShortWrite}} { out := outputWriter{ctx: t.Context(), out: failOutputWriter{tc.returned}} - require.ErrorIs(t, writeFileReceipt(out, value, "bash"), tc.want) + require.ErrorIs(t, writeFileReceipt(out, value, "bash", fileInvocation{display: "openai"}), tc.want) } ctx, cancel := context.WithCancel(t.Context()) cancel() var out bytes.Buffer - require.ErrorIs(t, writeFileReceipt(outputWriter{ctx: ctx, out: &out}, value, "bash"), context.Canceled) + require.ErrorIs(t, writeFileReceipt(outputWriter{ctx: ctx, out: &out}, value, "bash", fileInvocation{display: "openai"}), context.Canceled) require.Empty(t, out.String()) } @@ -161,9 +161,23 @@ func TestFileReceiptOmitsAmbiguousDestinations(t *testing.T) { data, err := json.Marshal(map[string]any{"object": "file", "id": tc.id, "filename": tc.filename, "purpose": "user_data"}) require.NoError(t, err) var out bytes.Buffer - require.NoError(t, writeFileReceipt(&out, gjson.ParseBytes(data), "bash")) + require.NoError(t, writeFileReceipt(&out, gjson.ParseBytes(data), "bash", fileInvocation{display: "openai"})) require.Contains(t, out.String(), "Uploaded ") require.NotContains(t, out.String(), "Download it:") require.NotContains(t, out.String(), "\x00") } } + +func TestFileReceiptPreservesSafeExecutableInvocation(t *testing.T) { + invocation := fileInvocation{display: `'/tmp/CLI'\''s folder/openai'`, executable: "/tmp/CLI's folder/openai"} + for _, status := range []string{"uploaded", "error"} { + value := gjson.Parse(strings.TrimSuffix(fileReceiptFixture, "}") + `,"status":"` + status + `"}`) + var out bytes.Buffer + require.NoError(t, writeFileReceipt(&out, value, "bash", invocation)) + if status == "error" { + require.Contains(t, out.String(), "Inspect it: "+invocation.display+" files get file-example --format json") + } else { + require.Contains(t, out.String(), "Download it: "+invocation.display+" files download file-example") + } + } +} From 7b86e622106ce131997977cab49a4f89171b35ba Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Thu, 8 Oct 2026 03:05:08 -0700 Subject: [PATCH 27/44] [files] preserve literal executable paths in receipt commands --- docs/files.md | 15 ++++ pkg/custom/file_commands.go | 7 +- pkg/custom/file_invocation_unix_test.go | 96 +++++++++++++++++++++++++ pkg/custom/file_results.go | 2 +- pkg/custom/file_results_test.go | 18 +++++ 5 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 pkg/custom/file_invocation_unix_test.go diff --git a/docs/files.md b/docs/files.md index 5b3a20e0..b010798c 100644 --- a/docs/files.md +++ b/docs/files.md @@ -26,6 +26,17 @@ openai files download file-example > copy.txt Replace `file-example` with the full ID from your upload response. +## Find commands and complete paths + +Use `openai files --help` to see upload, get, and download in workflow order. +Each command's `--help` explains its inputs and shows an example. +Help also works after a path or file ID without sending an API request. + +The existing Bash, Zsh, fish, and PowerShell completion adapters support the first upload path. +[Reload an already loaded completion adapter](shell-completion.md) after updating the CLI. +The adapter completes literal filenames, including a leading `@`. +In PowerShell, quote a leading `@` or use `./@name` to avoid shell splatting. + ## Upload paths and purpose Upload takes a plain local path. Quote paths that contain spaces or shell characters. @@ -79,6 +90,8 @@ Download it: openai files download file-example --output 'upload space.txt' ``` The receipt uses the returned filename, size, ID, and purpose. +The CLI omits the size when the API does not supply it. +The download suggestion appears for supported shells and unambiguous returned filenames. A successful upload does not mean that downstream processing has finished. When the returned status is `error`, the receipt suggests inspecting the metadata. An upload can return success while its status reports a processing error. @@ -139,6 +152,8 @@ openai files get file-example --transform filename --raw-output ``` Interactive receipts do not appear in JSON, extraction, or piped upload output. +`--quiet` suppresses upload receipts and retains the selected metadata output. +The receipt's suggested command preserves the executable used for the upload. See [reading command results](readable-output.md) for output formats. Inspect command help without making an API request: diff --git a/pkg/custom/file_commands.go b/pkg/custom/file_commands.go index b39401a3..7a54fde4 100644 --- a/pkg/custom/file_commands.go +++ b/pkg/custom/file_commands.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "strings" + "unicode" "github.com/openai/openai-cli/internal/clihelp" "github.com/urfave/cli/v3" @@ -18,6 +19,7 @@ type fileInvocationKey struct{} type fileInvocation struct { display string executable string + goRun bool } const ( @@ -78,10 +80,13 @@ func configureFileCommands(root *cli.Command) { } } invocation := fileInvocation{display: errorHelpInvocation(command.Root())} + invocation.goRun = invocation.display == "go run ./cmd/"+command.Root().Name if len(os.Args) > 0 { invocation.executable = os.Args[0] - if strings.TrimSuffix(filepath.Base(os.Args[0]), ".exe") != command.Root().Name { + if strings.TrimSuffix(filepath.Base(os.Args[0]), ".exe") != command.Root().Name || + strings.IndexFunc(os.Args[0], unicode.IsControl) >= 0 { invocation.display = os.Args[0] + invocation.goRun = false } } ctx = context.WithValue(ctx, fileInvocationKey{}, invocation) diff --git a/pkg/custom/file_invocation_unix_test.go b/pkg/custom/file_invocation_unix_test.go new file mode 100644 index 00000000..d2bf3ab0 --- /dev/null +++ b/pkg/custom/file_invocation_unix_test.go @@ -0,0 +1,96 @@ +//go:build !windows + +package custom + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/openai/openai-cli/internal/clihelp" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +// This exercises Files' invocation capture and receipt writer without a PTY. +// The action substitutes only a successful synthetic API result. +func fileReceiptForExecutable(t *testing.T, executable, shell string) string { + t.Helper() + previous := os.Args + os.Args = []string{executable} + t.Cleanup(func() { os.Args = previous }) + var receipt bytes.Buffer + root := &cli.Command{Name: "openai", Commands: []*cli.Command{{Name: "files", Commands: []*cli.Command{ + {Name: "upload", Flags: []cli.Flag{&cli.StringFlag{Name: "file"}, &cli.StringFlag{Name: "purpose"}}, Action: func(ctx context.Context, _ *cli.Command) error { + invocation, _ := ctx.Value(fileInvocationKey{}).(fileInvocation) + return writeFileReceipt(&receipt, gjson.Parse(fileReceiptFixture), shell, invocation) + }}, + }}}} + _, _, err := clihelp.Configure(root, os.Args) + require.NoError(t, err) + configureFileCommands(root) + require.NoError(t, root.Run(t.Context(), []string{executable, "files", "upload", "--file", "synthetic.txt", "--purpose", "user_data"})) + _, command, found := strings.Cut(receipt.String(), "\nDownload it: ") + require.True(t, found, receipt.String()) + return strings.TrimSuffix(command, "\n") +} + +func TestFileReceiptExecutableIdentity(t *testing.T) { + for _, shell := range []string{"bash", "zsh", "fish", "pwsh"} { + binary, err := exec.LookPath(shell) + if err != nil { + t.Run(shell, func(t *testing.T) { t.Skipf("%s is unavailable", shell) }) + continue + } + for _, mode := range []string{"spaces", "apostrophe", "go-run-prefix", "control-directory"} { + t.Run(shell+"/"+mode, func(t *testing.T) { + dir := t.TempDir() + bin := filepath.Join(dir, "bin") + require.NoError(t, os.Mkdir(bin, 0o700)) + t.Setenv("PATH", bin+":/usr/bin:/bin") + for _, wrong := range []string{"openai", "go"} { + require.NoError(t, os.WriteFile(filepath.Join(bin, wrong), []byte("#!/bin/sh\nprintf WRONG\n"), 0o700)) + } + executable := filepath.Join(dir, "CLI space", "openai") + switch mode { + case "apostrophe": + executable = filepath.Join(dir, "CLI's files", "openai") + case "go-run-prefix": + executable = "go run synthetic tool" + case "control-directory": + executable = filepath.Join(dir, "CLI\nfiles", "openai") + } + path := executable + if !filepath.IsAbs(path) { + path = filepath.Join(bin, path) + } + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) + require.NoError(t, os.WriteFile(path, []byte("#!/bin/sh\nprintf CORRECT\n"), 0o700)) + command := fileReceiptForExecutable(t, executable, shell) + args := []string{"--noprofile", "--norc", "-c", command} + switch shell { + case "zsh": + args = []string{"-f", "-c", command} + case "fish": + args = []string{"--no-config", "-c", command} + case "pwsh": + args = []string{"-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command} + } + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + process := exec.CommandContext(ctx, binary, args...) + process.Dir = dir + out, err := process.CombinedOutput() + require.NoError(t, err, string(out)) + t.Logf("copied command=%q output=%q", command, out) + require.Equal(t, "CORRECT", string(out), "copied receipt selected another executable") + }) + } + } +} diff --git a/pkg/custom/file_results.go b/pkg/custom/file_results.go index 03e7784b..24af96b7 100644 --- a/pkg/custom/file_results.go +++ b/pkg/custom/file_results.go @@ -154,7 +154,7 @@ func fileReceiptInvocation(invocation fileInvocation, shell string) string { return "openai" } plain, _ := imagePickerQuoteProperties(invocation.display) - if plain || strings.HasPrefix(invocation.display, "go run ") || invocation.executable == "" { + if plain || invocation.goRun || invocation.executable == "" { return invocation.display } quote := imagePickerShellQuoter(shell) diff --git a/pkg/custom/file_results_test.go b/pkg/custom/file_results_test.go index 371fd065..7fcefe0f 100644 --- a/pkg/custom/file_results_test.go +++ b/pkg/custom/file_results_test.go @@ -181,3 +181,21 @@ func TestFileReceiptPreservesSafeExecutableInvocation(t *testing.T) { } } } + +func TestFileReceiptQuotesExecutableNamedLikeGoRun(t *testing.T) { + name := "go run synthetic tool" + invocation := fileInvocation{display: name, executable: name} + for _, shell := range []string{"bash", "zsh", "fish", "pwsh"} { + t.Run(shell, func(t *testing.T) { + quoted := imagePickerShellQuoter(shell)(name) + if shell == "pwsh" { + quoted = "& " + quoted + } + require.Equal(t, quoted, fileReceiptInvocation(invocation, shell)) + }) + } + // Only the help-selected development invocation may remain shell syntax. + require.Equal(t, "go run ./cmd/openai", fileReceiptInvocation(fileInvocation{ + display: "go run ./cmd/openai", executable: "/tmp/go-build123/b001/exe/openai", goRun: true, + }, "bash")) +} From 699b5d334414bfac0862ae61fede27210a096365 Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Thu, 8 Oct 2026 02:51:05 -0700 Subject: [PATCH 28/44] [files] record workflow discovery alongside command demos --- scripts/demos/files-workflow/README.md | 30 ++++++++++ .../demos/files-workflow/discovery-scene.sh | 9 +++ .../demos/files-workflow/record-discovery.sh | 55 +++++++++++++++++++ 3 files changed, 94 insertions(+) create mode 100755 scripts/demos/files-workflow/discovery-scene.sh create mode 100755 scripts/demos/files-workflow/record-discovery.sh diff --git a/scripts/demos/files-workflow/README.md b/scripts/demos/files-workflow/README.md index dd2e9d0b..2d41dc7e 100644 --- a/scripts/demos/files-workflow/README.md +++ b/scripts/demos/files-workflow/README.md @@ -51,3 +51,33 @@ Check clipping, timestamp clarity, the complete ID, and copyable suggested comma The replay uses Bash, 110 columns, 46 rows, and Menlo at 18 pixels. This is PTY execution with asciinema/agg replay, not native graphical terminal validation. Keep generated media and executables outside Git. + +## Command discovery recording + +Record Files help separately from the upload and download workflow. +This recorder uses the same shared capture lifecycle and synthetic fixture. +Both scenes run `openai files --help` and must make zero API requests. +The existing workflow validator still requires its original twelve requests. + +Wait for the coordinator's terminal slot before running: + +```sh +DEMO_API_BINARY=/absolute/task/evidence/files-workflow-demo-api \ +DEMO_EVIDENCE_ROLE=public-candidate \ + bash scripts/demos/files-workflow/record-discovery.sh \ + --run-authorized-pty-slot \ + /absolute/task/evidence/baseline-openai \ + /absolute/task/evidence/candidate-openai \ + BEFORE_COMMIT AFTER_COMMIT \ + /absolute/task/evidence/files-discovery-media +``` + +Use `DEMO_EVIDENCE_ROLE=combined-integration` for an integration binary containing unpublished dependency changes. +Do not describe that recording as evidence from the public PR candidate. +Set `DEMO_SOURCE_MANIFEST` when the recording needs an explicit source manifest. + +The discovery replay uses Bash, 110 columns, 54 rows, and Menlo at 18 pixels. +It captures the complete Files group help with its three examples and workflow order. +The recorder produces separate Before and After screenshots and a comparison GIF. +Inspect all three assets for clipping and readable command examples. +The original workflow GIF remains a separate artifact. diff --git a/scripts/demos/files-workflow/discovery-scene.sh b/scripts/demos/files-workflow/discovery-scene.sh new file mode 100755 index 00000000..f0701a01 --- /dev/null +++ b/scripts/demos/files-workflow/discovery-scene.sh @@ -0,0 +1,9 @@ +#!/bin/bash +set -euo pipefail +test -t 0 && test -t 1 && test -t 2 || exit 99 +printf '\033[2J\033[H' +printf '%s\n\n' "$DEMO_SCENE_LABEL" +printf '%s\n' '$ openai files --help' +openai files --help +printf '\n$ ' +sleep 4 diff --git a/scripts/demos/files-workflow/record-discovery.sh b/scripts/demos/files-workflow/record-discovery.sh new file mode 100755 index 00000000..f3f1189e --- /dev/null +++ b/scripts/demos/files-workflow/record-discovery.sh @@ -0,0 +1,55 @@ +#!/bin/bash +set -euo pipefail +if [ "$#" -ne 6 ] || [ "$1" != --run-authorized-pty-slot ]; then + echo 'usage: record-discovery.sh --run-authorized-pty-slot BEFORE_BINARY AFTER_BINARY BEFORE_SHA AFTER_SHA OUTPUT_DIR' >&2 + exit 2 +fi +shift +record_source="$(cd "$(dirname "$0")" && pwd)" +record_repo="$(cd "$record_source/../../.." && pwd)" +source "$record_repo/scripts/demos/capture_and_render.sh" +demo_prepare_capture "$record_repo" "$1" "$2" "$3" "$4" "$5" "${DEMO_API_BINARY:-$record_repo/dist/demos/bin/files-workflow-demo-api}" +demo_start_api "$demo_output/requests.jsonl" +cp "$record_source/discovery-scene.sh" "$demo_runtime/scene.sh" +{ + echo 'feature: Files workflow discovery' + echo "evidence role: ${DEMO_EVIDENCE_ROLE:-unlabeled; do not publish}" + echo "comparison base / before commit: $demo_before_sha" + echo "candidate / after commit: $demo_after_sha" + echo 'capture: isolated Bash PTYs; offline command help; zero API requests' + echo 'scope: asciinema/agg replay; no graphical terminal validation' + echo 'render: 110 columns x 54 rows; Menlo 18px; asciinema theme' + demo_capture_metadata + shasum -a 256 "$record_source/discovery-scene.sh" "$record_source/record-discovery.sh" + go version -m "$demo_before" + go version -m "$demo_after" +} > "$demo_output/metadata.txt" +if [ -n "${DEMO_SOURCE_MANIFEST:-}" ]; then + cp "$DEMO_SOURCE_MANIFEST" "$demo_output/source-manifest.txt" +fi +demo_window_size=110x54 +demo_render_options=(--renderer resvg --font-family Menlo --font-size 18 --line-height 1.2 \ + --theme asciinema --fps-cap 20 --last-frame-duration 3) +for demo_scene in before after; do + mkdir -p "$demo_runtime/$demo_scene-home" + demo_label='BEFORE: files help' + if [ "$demo_scene" = after ]; then demo_label='AFTER: discover upload, get, and download'; fi + demo_capture_scene "$demo_scene" 0 "$demo_runtime/$demo_scene" "$demo_api_url" "$demo_label" \ + "HOME=$demo_runtime/$demo_scene-home" NO_COLOR=1 FORCE_COLOR=0 GOMAXPROCS=2 +done +demo_stop_api +test ! -s "$demo_output/requests.jsonl" +python3 - "$demo_output" <<'PY' +from pathlib import Path +import sys +root=Path(sys.argv[1]); after=(root/'after.txt').read_text() +required=['Upload, inspect, and download files.', 'upload', 'get', 'download', 'Existing create, retrieve, and content', 'files upload "upload space.txt" --purpose user_data', 'files get file-example', 'files download file-example --output "downloaded copy.txt"'] +for value in required: + if value not in after: raise SystemExit('Missing discovery output: '+value) +actions=after.split('ACTIONS',1)[1].split('Command help:',1)[0] +positions=[actions.index(' '+name+' ') for name in ['upload','get','download','list','delete']] +if positions != sorted(positions): raise SystemExit('Workflow commands are not in the expected order') +(root/'validation.txt').write_text('PASS: both help commands exit zero; no API requests; all three runnable examples; workflow ordering.\n') +PY +demo_assemble_capture 300 before after +printf 'Recorded Files discovery in %s\n' "$demo_output" From 217f571d5eee5147507da82cc952840e8a3a66c4 Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Thu, 8 Oct 2026 02:58:31 -0700 Subject: [PATCH 29/44] [files] allow wrapped help prose in discovery captures --- scripts/demos/files-workflow/record-discovery.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/demos/files-workflow/record-discovery.sh b/scripts/demos/files-workflow/record-discovery.sh index f3f1189e..31855886 100755 --- a/scripts/demos/files-workflow/record-discovery.sh +++ b/scripts/demos/files-workflow/record-discovery.sh @@ -43,9 +43,11 @@ python3 - "$demo_output" <<'PY' from pathlib import Path import sys root=Path(sys.argv[1]); after=(root/'after.txt').read_text() -required=['Upload, inspect, and download files.', 'upload', 'get', 'download', 'Existing create, retrieve, and content', 'files upload "upload space.txt" --purpose user_data', 'files get file-example', 'files download file-example --output "downloaded copy.txt"'] +required=['Upload, inspect, and download files.', 'upload', 'get', 'download', 'files upload "upload space.txt" --purpose user_data', 'files get file-example', 'files download file-example --output "downloaded copy.txt"'] for value in required: if value not in after: raise SystemExit('Missing discovery output: '+value) +if 'Existing create, retrieve, and content commands remain available.' not in ' '.join(after.split()): + raise SystemExit('Missing compatibility guidance') actions=after.split('ACTIONS',1)[1].split('Command help:',1)[0] positions=[actions.index(' '+name+' ') for name in ['upload','get','download','list','delete']] if positions != sorted(positions): raise SystemExit('Workflow commands are not in the expected order') From c9c3b0b0be7cfaf6f2e7b85f5d86187ff950521b Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Thu, 8 Oct 2026 03:11:47 -0700 Subject: [PATCH 30/44] [files] keep workflow examples complete after help integration --- cmd/openai/files_workflow_test.go | 22 +++++++++++++++-- docs/files.md | 4 +++- internal/clihelp/command_help.go | 2 +- internal/clihelp/file_examples_test.go | 33 ++++++++++++++++++++++++++ pkg/custom/file_commands.go | 13 +++++++--- 5 files changed, 67 insertions(+), 7 deletions(-) create mode 100644 internal/clihelp/file_examples_test.go diff --git a/cmd/openai/files_workflow_test.go b/cmd/openai/files_workflow_test.go index 3e938923..1a6fc7bb 100644 --- a/cmd/openai/files_workflow_test.go +++ b/cmd/openai/files_workflow_test.go @@ -392,8 +392,26 @@ func TestMainFilesWorkflowHelpExplainsPaths(t *testing.T) { require.Zero(t, got.code, "%+v", got) require.Empty(t, got.stderr) text := strings.Join(strings.Fields(got.stdout), " ") - require.Contains(t, text, "With upload, PATH can replace --file PATH.") - require.Contains(t, text, "A leading @ is literal.") + require.Contains(t, text, "A path is required: use PATH or --file PATH.") + require.Contains(t, text, "Choose --purpose explicitly.") + require.Contains(t, text, "A leading @ is part of the filename; do not add @ to a plain path.") + require.NotContains(t, text, "example.txt is the path") +} + +func TestMainFilesWorkflowHelpIncludesExamples(t *testing.T) { + for _, tc := range []struct{ command, example string }{ + {"upload", `openai files upload "upload space.txt" --purpose user_data`}, + {"get", "openai files get file-example"}, + {"download", `openai files download file-example --output "downloaded copy.txt"`}, + } { + t.Run(tc.command, func(t *testing.T) { + got := runMainDispatchWithEnv(t, "bash", []string{"OPENAI_BASE_URL=invalid-files-help-url"}, "openai", "files", tc.command, "--help") + require.Zero(t, got.code, "%+v", got) + require.Empty(t, got.stderr) + require.Contains(t, got.stdout, "EXAMPLES:") + require.Contains(t, got.stdout, tc.example) + }) + } } func TestMainFilesWorkflowPositionalCompletion(t *testing.T) { diff --git a/docs/files.md b/docs/files.md index b010798c..1adb1166 100644 --- a/docs/files.md +++ b/docs/files.md @@ -70,7 +70,9 @@ Alternatively, place options before `--`. The CLI treats the following path as l openai files upload --purpose user_data -- "-notes.txt" ``` -Use `./-` for a file named `-`. The existing `--file -` behavior reads file contents from stdin. +Use `./-` for a file named `-`. The existing `--file -` sentinel requests stdin. +Current piped input can be consumed first as JSON/YAML and rejected before the upload starts. +Use a local file for binary uploads until the shared stdin handling changes. Supply `--purpose` for each upload. The CLI does not select a purpose for you. The example uses `user_data`; select the purpose required by your API workflow. diff --git a/internal/clihelp/command_help.go b/internal/clihelp/command_help.go index 14fc4226..469dfa57 100644 --- a/internal/clihelp/command_help.go +++ b/internal/clihelp/command_help.go @@ -92,7 +92,7 @@ func commandHelpAtWidth(command *cli.Command, invocation, path string, width int out.WriteString(wrapDescription(example.Description, " ", width)) fmt.Fprintf(&out, " %s %s\n", invocation, example.Command) } - if path == "files create" || path == "files upload" { + if !supplied && (path == "files create" || path == "files upload") { out.WriteString(wrapDescription("example.txt is the path to your existing file. Replace it with your file's path.", " ", width)) } } diff --git a/internal/clihelp/file_examples_test.go b/internal/clihelp/file_examples_test.go new file mode 100644 index 00000000..e8c54ab3 --- /dev/null +++ b/internal/clihelp/file_examples_test.go @@ -0,0 +1,33 @@ +package clihelp + +import ( + "fmt" + "strings" + "testing" + + "github.com/urfave/cli/v3" +) + +func TestFilesHelpPreservesDefaultAndSuppliedExamples(t *testing.T) { + for _, path := range []string{"files create", "files upload"} { + for _, width := range []int{40, 80} { + t.Run(fmt.Sprintf("%s/%d", path, width), func(t *testing.T) { + command := &cli.Command{Name: strings.TrimPrefix(path, "files "), Usage: "Upload a file."} + fallback := strings.Join(strings.Fields(commandHelpAtWidth(command, "openai", path, width)), " ") + if !strings.Contains(fallback, "--file ./example.txt") || !strings.Contains(fallback, "example.txt is the path") { + t.Fatalf("default example or explanation disappeared: %s", fallback) + } + command.Metadata = map[string]any{"help-content": Content{Examples: []Example{ + {Description: "Upload an existing file:", Command: `files upload "upload space.txt" --purpose user_data`}, + }}} + owned := commandHelpAtWidth(command, "openai", path, width) + if !strings.Contains(owned, `openai files upload "upload space.txt" --purpose user_data`) { + t.Fatalf("supplied executable example disappeared: %s", owned) + } + if strings.Contains(owned, "example.txt") { + t.Fatalf("default explanation leaked into supplied content: %s", owned) + } + }) + } + } +} diff --git a/pkg/custom/file_commands.go b/pkg/custom/file_commands.go index 7a54fde4..294bd847 100644 --- a/pkg/custom/file_commands.go +++ b/pkg/custom/file_commands.go @@ -93,9 +93,9 @@ func configureFileCommands(root *cli.Command) { return next(context.WithValue(ctx, fileCommandKey{}, fileUploadCommand), command) } } - for _, route := range []struct{ name, source, usage string }{ - {"get", "retrieve", "Inspect file metadata."}, - {"download", "content", "Download file contents to a path or stdout."}, + for _, route := range []struct{ name, source, usage, example string }{ + {"get", "retrieve", "Inspect file metadata.", "files get file-example"}, + {"download", "content", "Download file contents to a path or stdout.", `files download file-example --output "downloaded copy.txt"`}, } { if files.Command(route.name) != nil { continue @@ -116,6 +116,13 @@ func configureFileCommands(root *cli.Command) { } else { command.Description = "Use --output PATH to save the contents, or redirect stdout to a file.\nUse --output - to force stdout. Existing download behavior also applies in a terminal." } + if command.Metadata == nil { + command.Metadata = map[string]any{} + } + command.Metadata["help-content"] = clihelp.Content{ + Description: command.Description, + Examples: []clihelp.Example{{Description: route.usage, Command: route.example}}, + } files.Commands = append(files.Commands, command) markCompatibilityCommand(source) } From 409d12f04de5a4cd93bf3baff9e73d1c03fce284 Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Thu, 8 Oct 2026 03:36:09 -0700 Subject: [PATCH 31/44] [files] update help coverage for positional uploads --- cmd/openai/gcloud_help_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/openai/gcloud_help_test.go b/cmd/openai/gcloud_help_test.go index fd20d5b2..e22c9056 100644 --- a/cmd/openai/gcloud_help_test.go +++ b/cmd/openai/gcloud_help_test.go @@ -54,7 +54,8 @@ func TestMainGcloudHelpConcreteSyntaxAndLabels(t *testing.T) { want []string }{ {[]string{"models", "retrieve"}, []string{"[MODEL | --model MODEL]", "Model ID.", "JSON/YAML keys: model."}}, - {[]string{"files", "upload"}, []string{"[--file PATH]", "[--purpose TEXT]", "Required request inputs: --file, --purpose."}}, + {[]string{"files", "upload"}, []string{"[PATH | --file PATH] --purpose PURPOSE [options]", "A path is required: use PATH or --file PATH.", "Choose --purpose explicitly.", "The existing piped JSON/YAML keys are file and purpose."}}, + {[]string{"files", "create"}, []string{"[--file PATH]", "[--purpose TEXT]", "Required request inputs: --file, --purpose."}}, {[]string{"images", "generate"}, []string{"--size SIZE", "--quality QUALITY", "--background BACKGROUND", "--output-format FORMAT", "--inline MODE"}}, } { got := runMainDispatch(t, "bash", append(append([]string{"openai"}, tc.path...), "--help")...) From 1f6c0a8bb87eff90526b39b27152aab411ac28ad Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Thu, 8 Oct 2026 09:46:53 -0700 Subject: [PATCH 32/44] Name help presentation tests for their behavior --- .../{gcloud_help_test.go => help_presentation_test.go} | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) rename cmd/openai/{gcloud_help_test.go => help_presentation_test.go} (95%) diff --git a/cmd/openai/gcloud_help_test.go b/cmd/openai/help_presentation_test.go similarity index 95% rename from cmd/openai/gcloud_help_test.go rename to cmd/openai/help_presentation_test.go index fd20d5b2..4e1660e3 100644 --- a/cmd/openai/gcloud_help_test.go +++ b/cmd/openai/help_presentation_test.go @@ -19,7 +19,7 @@ func helpSection(text, start, end string) string { return strings.TrimSpace(section) } -func TestMainGcloudHelpPreservesDetailsAfterShortName(t *testing.T) { +func TestMainHelpPresentationPreservesDetailsAfterShortName(t *testing.T) { got := runMainDispatch(t, "bash", "openai", "responses", "create", "--help") if got.code != 0 || got.stderr != "" { t.Fatalf("help failed: %+v", got) @@ -48,7 +48,7 @@ func TestMainGcloudHelpPreservesDetailsAfterShortName(t *testing.T) { } } -func TestMainGcloudHelpConcreteSyntaxAndLabels(t *testing.T) { +func TestMainHelpPresentationConcreteSyntaxAndLabels(t *testing.T) { for _, tc := range []struct { path []string want []string @@ -73,7 +73,7 @@ func TestMainGcloudHelpConcreteSyntaxAndLabels(t *testing.T) { } } -func TestMainGcloudHelpCompactGlobalsAndLocalRestrictions(t *testing.T) { +func TestMainHelpPresentationCompactGlobalsAndLocalRestrictions(t *testing.T) { root := runMainDispatch(t, "bash", "openai", "--help") for _, path := range [][]string{{"models", "list"}, {"images", "preview"}, {"images", "inline", "on"}} { got := runMainDispatch(t, "bash", append(append([]string{"openai"}, path...), "--help")...) @@ -106,7 +106,7 @@ func TestMainGcloudHelpCompactGlobalsAndLocalRestrictions(t *testing.T) { } } -func TestMainGcloudHelpDocumentedInputSourcesExecute(t *testing.T) { +func TestMainHelpPresentationDocumentedInputSourcesExecute(t *testing.T) { requests := make(chan string, 12) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests <- r.Method + " " + r.URL.Path From 64ed67577a143aee79078d4d5e1badbb01b13f43 Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Thu, 8 Oct 2026 10:16:35 -0700 Subject: [PATCH 33/44] [files] preserve receipt output and validate Windows hint destinations --- cmd/openai/files_receipt_unix_test.go | 200 ++++++++++++++++++++++++++ cmd/openai/files_workflow_test.go | 8 +- docs/files.md | 3 + pkg/custom/file_results.go | 16 ++- pkg/custom/file_results_test.go | 87 ++++++++++- 5 files changed, 304 insertions(+), 10 deletions(-) create mode 100644 cmd/openai/files_receipt_unix_test.go diff --git a/cmd/openai/files_receipt_unix_test.go b/cmd/openai/files_receipt_unix_test.go new file mode 100644 index 00000000..14816c4d --- /dev/null +++ b/cmd/openai/files_receipt_unix_test.go @@ -0,0 +1,200 @@ +//go:build !windows + +package main + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "os" + "os/exec" + "path/filepath" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// Native Windows terminal behavior needs a separate harness. These cases use +// Unix PTYs so both output streams exercise the public interactive presenter. +func TestMainFilesReceiptErrorOptionsIndependent(t *testing.T) { + python, err := exec.LookPath("python3") + if err != nil { + t.Skip("Python 3 is required for the Unix Files receipt PTY cases") + } + bash, err := exec.LookPath("bash") + if err != nil { + t.Skip("Bash is required for the Unix Files receipt PTY cases") + } + const response = `{"id":"` + filesWorkflowID + `","object":"file","bytes":13,"created_at":1700000000,"filename":"upload space.txt","purpose":"user_data","status":"uploaded"}` + const apiError = `{"message":"Synthetic upload rejected.","type":"invalid_request_error","code":"synthetic_file_error"}` + const legacyMetadata = "ID: " + filesWorkflowID + "\nObject: file\nBytes: 13\nCreated at: 1700000000\n" + + "Filename: upload space.txt\nPurpose: user_data\nStatus: uploaded\n" + errorJSON := []string{"--format-error", "json"} + errorTransform := []string{"--transform-error", "message"} + errorBoth := []string{"--format-error", "json", "--transform-error", "message"} + for _, tc := range []struct { + name, route, output string + flags []string + status int + }{ + {"upload default", "upload", "receipt", nil, http.StatusOK}, + {"upload error JSON", "upload", "receipt", errorJSON, http.StatusOK}, + {"upload error transform", "upload", "receipt", errorTransform, http.StatusOK}, + {"upload both error options", "upload", "receipt", errorBoth, http.StatusOK}, + {"quiet with error options", "upload", "metadata", append([]string{"--quiet"}, errorBoth...), http.StatusOK}, + {"success JSON with error options", "upload", "json", append([]string{"--format", "json"}, errorBoth...), http.StatusOK}, + {"raw extraction with error options", "upload", "raw", append([]string{"--transform", "id", "--raw-output"}, errorBoth...), http.StatusOK}, + {"legacy create default", "create", "legacy", nil, http.StatusOK}, + {"legacy create error JSON", "create", "legacy", errorJSON, http.StatusOK}, + {"legacy create error transform", "create", "legacy", errorTransform, http.StatusOK}, + {"legacy create both error options", "create", "legacy", errorBoth, http.StatusOK}, + {"HTTP 400 structured error", "upload", "error", errorJSON, http.StatusBadRequest}, + } { + t.Run(tc.name, func(t *testing.T) { + work := t.TempDir() + const filename = "upload space.txt" + payload := []byte("hello files!\n") + require.NoError(t, os.WriteFile(filepath.Join(work, filename), payload, 0o600)) + body := response + if tc.status != http.StatusOK { + body = `{"error":` + apiError + `}` + } + server, requests := filesWorkflowUploadServerWithResponse(t, filename, payload, tc.status, body) + args := []string{"openai", "files", tc.route} + if tc.route == "create" { + args = append(args, "--file") + } + args = append(args, filename, "--purpose", "user_data") + args = append(args, tc.flags...) + got := runFilesReceiptPTY(t, python, bash, work, server.URL, args) + require.EqualValues(t, 1, requests.Load(), "each command must upload exactly once; result=%+v", got) + if tc.output == "error" { + require.Equal(t, 1, got.code) + require.Empty(t, got.stdout) + require.JSONEq(t, apiError, got.stderr, "the complete error stream must remain one JSON value") + require.NotContains(t, got.stderr, "Uploaded ") + require.NotContains(t, got.stderr, "Download it:") + return + } + require.Zero(t, got.code, "%+v", got) + require.Empty(t, got.stderr) + if tc.output == "receipt" { + want := "Uploaded upload space.txt (13 B)\nID: " + filesWorkflowID + "\nPurpose: user_data\n\n" + + "Download it: openai files download " + filesWorkflowID + " --output 'upload space.txt'\n" + require.Equal(t, want, got.stdout, "error options must not change successful stdout") + return + } + require.NotContains(t, got.stdout, "Uploaded ") + require.NotContains(t, got.stdout, "Download it:") + switch tc.output { + case "json": + require.JSONEq(t, response, got.stdout) + case "raw": + require.Equal(t, filesWorkflowID+"\n", got.stdout) + case "metadata", "legacy": + require.Equal(t, legacyMetadata, got.stdout, "keep the complete legacy metadata output") + } + }) + } +} + +func runFilesReceiptPTY(t *testing.T, python, bash, work, endpoint string, args []string) mainDispatchResult { + t.Helper() + binary, err := os.Executable() + require.NoError(t, err) + input, err := json.Marshal(struct { + Binary, Bash, Work, Endpoint string + Args []string + }{binary, bash, work, endpoint, args}) + require.NoError(t, err) + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + defer cancel() + child := exec.CommandContext(ctx, python, "-c", filesReceiptPTYScript) + // Keep Python and the public CLI isolated from personal credentials and hooks. + child.Env = []string{"PATH=/usr/bin:/bin", "HOME=" + work, "LANG=en_US.UTF-8"} + child.Stdin = bytes.NewReader(input) + var stdout, stderr bytes.Buffer + child.Stdout, child.Stderr = &stdout, &stderr + child.Cancel = func() error { return child.Process.Signal(syscall.SIGTERM) } + child.WaitDelay = 2 * time.Second + err = child.Run() + require.NoError(t, ctx.Err(), "PTY harness timed out; stdout=%q stderr=%q", stdout.String(), stderr.String()) + require.NoError(t, err, "PTY harness failed; stdout=%q stderr=%q", stdout.String(), stderr.String()) + require.Empty(t, stderr.String()) + var result struct { + Code int + Stdout, Stderr string + } + require.NoError(t, json.Unmarshal(stdout.Bytes(), &result), "invalid PTY harness result: %s", stdout.String()) + return mainDispatchResult{result.Code, result.Stdout, result.Stderr} +} + +const filesReceiptPTYScript = ` +import errno, json, os, pty, selectors, signal, subprocess, sys, time, tty + +def interrupted(signum, frame): + raise TimeoutError("Files receipt harness interrupted") + +signal.signal(signal.SIGTERM, interrupted) +config = json.load(sys.stdin) +env = { + "PATH": "/usr/bin:/bin", "HOME": config["Work"], "XDG_CONFIG_HOME": config["Work"], + "LANG": "en_US.UTF-8", "TERM": "xterm-256color", "NO_COLOR": "1", "FORCE_COLOR": "0", + "GOMAXPROCS": "2", "OPENAI_CLI_MAIN_DISPATCH_PROCESS": "1", + "OPENAI_API_KEY": "sk-fake-files-receipt-test", "OPENAI_BASE_URL": config["Endpoint"], +} +selector = selectors.DefaultSelector() +masters, slaves, output = [], [], [bytearray(), bytearray()] +process = None +completed = False +try: + for stream in range(2): + master, slave = pty.openpty() + masters.append(master) + slaves.append(slave) + tty.setraw(slave) + os.set_blocking(master, False) + selector.register(master, selectors.EVENT_READ, stream) + # Keep Bash alive so the production presenter detects its actual caller. + command = [config["Bash"], "--noprofile", "--norc", "-c", + '"$@"\nresult=$?\nexit "$result"', "files-receipt-test", + config["Binary"], "-test.run=^TestMainDispatchProcess$", "--", *config["Args"]] + process = subprocess.Popen(command, cwd=config["Work"], env=env, stdin=subprocess.DEVNULL, + stdout=slaves[0], stderr=slaves[1], start_new_session=True) + for slave in slaves: + os.close(slave) + slaves.clear() + deadline = time.monotonic() + 10 + while selector.get_map() or process.poll() is None: + if time.monotonic() >= deadline: + raise TimeoutError("Files receipt command exceeded ten seconds") + for key, _ in selector.select(0.1): + try: + data = os.read(key.fd, 65536) + except OSError as error: + if error.errno == errno.EAGAIN: + continue + if error.errno != errno.EIO: + raise + data = b"" + if data: + output[key.data].extend(data) + else: + selector.unregister(key.fd) + code = process.wait(timeout=2) + completed = True +finally: + if process is not None and not completed: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.wait(timeout=2) + selector.close() + for descriptor in masters + slaves: + os.close(descriptor) +print(json.dumps({"Code": code, "Stdout": output[0].decode("utf-8"), "Stderr": output[1].decode("utf-8")})) +` diff --git a/cmd/openai/files_workflow_test.go b/cmd/openai/files_workflow_test.go index 1a6fc7bb..ebaee32e 100644 --- a/cmd/openai/files_workflow_test.go +++ b/cmd/openai/files_workflow_test.go @@ -20,6 +20,11 @@ const filesWorkflowID = "file-example-full-copyable-0123456789abcdefghijklmnopqr const filesWorkflowMetadata = `{"id":"` + filesWorkflowID + `","object":"file","bytes":13,"created_at":1700000000,"filename":"upload space.txt","purpose":"user_data","status":"uploaded","extra":{"note":"preserved value"}}` func filesWorkflowUploadServer(t *testing.T, filename string, payload []byte) (*httptest.Server, *atomic.Int32) { + t.Helper() + return filesWorkflowUploadServerWithResponse(t, filename, payload, http.StatusOK, filesWorkflowMetadata) +} + +func filesWorkflowUploadServerWithResponse(t *testing.T, filename string, payload []byte, status int, response string) (*httptest.Server, *atomic.Int32) { t.Helper() requests := new(atomic.Int32) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -51,7 +56,8 @@ func filesWorkflowUploadServer(t *testing.T, filename string, payload []byte) (* t.Errorf("upload changed: filename=%q bytes=%q error=%v", header.Filename, body, err) } w.Header().Set("Content-Type", "application/json") - _, _ = io.WriteString(w, filesWorkflowMetadata) + w.WriteHeader(status) + _, _ = io.WriteString(w, response) })) t.Cleanup(server.Close) return server, requests diff --git a/docs/files.md b/docs/files.md index 1adb1166..cf785e76 100644 --- a/docs/files.md +++ b/docs/files.md @@ -94,6 +94,8 @@ Download it: openai files download file-example --output 'upload space.txt' The receipt uses the returned filename, size, ID, and purpose. The CLI omits the size when the API does not supply it. The download suggestion appears for supported shells and unambiguous returned filenames. +On Windows, the CLI omits suggestions for drive-relative names, alternate-stream syntax, reserved device names, and trailing dots or spaces. +Choose an ordinary destination explicitly when the receipt omits the suggestion. A successful upload does not mean that downstream processing has finished. When the returned status is `error`, the receipt suggests inspecting the metadata. An upload can return success while its status reports a processing error. @@ -155,6 +157,7 @@ openai files get file-example --transform filename --raw-output Interactive receipts do not appear in JSON, extraction, or piped upload output. `--quiet` suppresses upload receipts and retains the selected metadata output. +`--format-error` and `--transform-error` affect errors only; they do not change successful upload output. The receipt's suggested command preserves the executable used for the upload. See [reading command results](readable-output.md) for output formats. diff --git a/pkg/custom/file_results.go b/pkg/custom/file_results.go index 24af96b7..52cce0c2 100644 --- a/pkg/custom/file_results.go +++ b/pkg/custom/file_results.go @@ -3,9 +3,11 @@ package custom import ( "fmt" "io" + "runtime" "strings" "unicode/utf8" + "github.com/openai/openai-cli/internal/imageoutput" "github.com/openai/openai-cli/internal/jsonview" "github.com/openai/openai-cli/internal/readable" "github.com/openai/openai-cli/pkg/transformers" @@ -35,7 +37,7 @@ func showFileResult(value gjson.Result, opts ShowJSONOpts) (bool, error) { } return true, readable.Write(out, metadata) case command == fileUploadCommand && opts.Operation == "(resource) files > (method) create": - if !outputDiagnosticsAllowed(opts.Context) { + if quietOutput(opts.Context) { // Quiet suppresses the receipt, while ordinary rendering keeps data. return false, nil } @@ -126,7 +128,7 @@ func writeFileReceipt(out io.Writer, value gjson.Result, shell string, invocatio if verb == "get" { args = append(args, "--format", "json") } else { - if !utf8.ValidString(filename) || strings.ContainsRune(filename, 0) || strings.ContainsAny(filename, `/\`) || filename == "." || filename == ".." { + if !fileReceiptDestinationAllowed(filename, runtime.GOOS) { return nil } if filename == "-" { @@ -145,6 +147,16 @@ func writeFileReceipt(out io.Writer, value gjson.Result, shell string, invocatio return nil } +// Limit suggested destinations, without changing accepted upload or download paths. +func fileReceiptDestinationAllowed(filename, goos string) bool { + if filename == "" || !utf8.ValidString(filename) || strings.ContainsRune(filename, 0) || + strings.ContainsAny(filename, `/\`) || filename == "." || filename == ".." { + return false + } + // Reuse the existing portable filename rules only where Windows requires them. + return goos != "windows" || imageoutput.ValidateName(filename) == nil +} + // Keep help's executable selection, using the actual shell for quoted paths. func fileReceiptInvocation(invocation fileInvocation, shell string) string { if !utf8.ValidString(invocation.executable) { diff --git a/pkg/custom/file_results_test.go b/pkg/custom/file_results_test.go index 7fcefe0f..c75447d6 100644 --- a/pkg/custom/file_results_test.go +++ b/pkg/custom/file_results_test.go @@ -7,8 +7,10 @@ import ( "errors" "io" "os/exec" + "runtime" "strings" "testing" + "time" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" @@ -94,19 +96,26 @@ func TestFileReceiptCommandsPreserveShellArguments(t *testing.T) { if err != nil { t.Skipf("%s is unavailable", shell) } - for _, tc := range []struct{ id, filename string }{ - {"file-example", "upload space.txt"}, - {"-file-example", "-notes.txt"}, - {"file-example", "-"}, - {"file-'quote", "an owner's $HOME `literal` $(printf BAD) file.txt"}, - {"file-example", "@literal.txt"}, - {"file-example", "line\nwith\tcontrols\x1b.txt"}, + for _, tc := range []struct { + id, filename string + windowsHint bool + }{ + {"file-example", "upload space.txt", true}, + {"-file-example", "-notes.txt", true}, + {"file-example", "-", true}, + {"file-'quote", "an owner's $HOME `literal` $(printf BAD) file.txt", true}, + {"file-example", "@literal.txt", true}, + {"file-example", "line\nwith\tcontrols\x1b.txt", false}, } { data, err := json.Marshal(map[string]any{"object": "file", "id": tc.id, "filename": tc.filename, "purpose": "user_data", "bytes": 0}) require.NoError(t, err) var out bytes.Buffer require.NoError(t, writeFileReceipt(&out, gjson.ParseBytes(data), shell, fileInvocation{display: "openai"})) _, command, found := strings.Cut(out.String(), "\nDownload it: ") + if runtime.GOOS == "windows" && !tc.windowsHint { + require.False(t, found, "Windows must omit the control-character destination") + continue + } require.True(t, found) command = strings.TrimSuffix(command, "\n") require.NotContains(t, command, "\n") @@ -168,6 +177,70 @@ func TestFileReceiptOmitsAmbiguousDestinations(t *testing.T) { } } +func TestFileReceiptDestinationRulesPreservePlatforms(t *testing.T) { + for _, filename := range []string{"", ".", "..", "../outside.txt", "/absolute.txt", `folder\file.txt`, "nul\x00.txt", string([]byte{0xff})} { + for _, goos := range []string{"windows", "linux", "darwin"} { + require.False(t, fileReceiptDestinationAllowed(filename, goos), "%s %q", goos, filename) + } + } + for _, filename := range []string{"C:report.txt", "report.txt:stream", "NUL", "CON.txt", "con .txt", "CONIN$", "CONOUT$", "COM1", "LPT9.txt", "COM¹", "LPT²", "trailing.", "trailing ", " ", `quoted".txt`, "star*.txt", "question?.txt", "pipe|.txt", "less<.txt", "greater>.txt", "line\nwith\tcontrols\x1b.txt"} { + require.False(t, fileReceiptDestinationAllowed(filename, "windows"), filename) + for _, goos := range []string{"linux", "darwin"} { + require.True(t, fileReceiptDestinationAllowed(filename, goos), "%s %q", goos, filename) + } + } + for _, filename := range []string{"ordinary.txt", "upload space.txt", "owner's file.txt", "@literal.txt", "-notes.txt", "-", ".hidden", "café-雪.txt", "COM10.txt", "lpt0", "auxiliary.txt"} { + for _, goos := range []string{"windows", "linux", "darwin"} { + require.True(t, fileReceiptDestinationAllowed(filename, goos), "%s %q", goos, filename) + } + } +} + +func TestFileReceiptProcessingFailureKeepsInspectionHint(t *testing.T) { + for _, filename := range []string{"../outside.txt", "C:report.txt", "NUL"} { + data, err := json.Marshal(map[string]any{ + "object": "file", "id": "file-example", "filename": filename, "purpose": "user_data", "status": "error", + }) + require.NoError(t, err) + var out bytes.Buffer + require.NoError(t, writeFileReceipt(&out, gjson.ParseBytes(data), "pwsh", fileInvocation{display: "openai"})) + require.Contains(t, out.String(), "ID: file-example") + require.Contains(t, out.String(), "Status: error") + require.Contains(t, out.String(), "Inspect it: openai files get file-example --format json") + require.NotContains(t, out.String(), "Download it:") + } +} + +func TestFileReceiptPreservesUnixPowerShellFilenames(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("these literal filenames exercise Unix behavior") + } + pwsh, err := exec.LookPath("pwsh") + if err != nil { + t.Skip("PowerShell is unavailable") + } + for _, filename := range []string{"C:report.txt", "report.txt:stream", "NUL", "CON.txt", "trailing.", "trailing "} { + t.Run(filename, func(t *testing.T) { + data, err := json.Marshal(map[string]any{"object": "file", "id": "file-example", "filename": filename, "purpose": "user_data"}) + require.NoError(t, err) + var out bytes.Buffer + require.NoError(t, writeFileReceipt(&out, gjson.ParseBytes(data), "pwsh", fileInvocation{display: "openai"})) + _, command, found := strings.Cut(out.String(), "\nDownload it: ") + require.True(t, found) + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + process := exec.CommandContext(ctx, pwsh, "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", + `function openai { [Console]::Out.Write((ConvertTo-Json -Compress -InputObject @($args))) }; `+strings.TrimSpace(command)) + process.Dir = t.TempDir() + output, err := process.CombinedOutput() + require.NoError(t, err, string(output)) + var arguments []string + require.NoError(t, json.Unmarshal(output, &arguments)) + require.Equal(t, []string{"files", "download", "file-example", "--output", filename}, arguments) + }) + } +} + func TestFileReceiptPreservesSafeExecutableInvocation(t *testing.T) { invocation := fileInvocation{display: `'/tmp/CLI'\''s folder/openai'`, executable: "/tmp/CLI's folder/openai"} for _, status := range []string{"uploaded", "error"} { From af738605da3fdc29b7d5bb0b1b80c514fb287441 Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Thu, 8 Oct 2026 11:46:06 -0700 Subject: [PATCH 34/44] Complete verbose lifecycle and suppress quiet image progress --- cmd/openai/deterministic_output_test.go | 52 +++- cmd/openai/error_request_id_test.go | 103 ++++++++ cmd/openai/global_flags_test.go | 2 +- cmd/openai/image_progress_test.go | 29 ++- cmd/openai/main.go | 4 +- cmd/openai/main_api_error_test.go | 2 +- cmd/openai/main_readable_errors_test.go | 6 +- cmd/openai/output_startup_test.go | 89 +++++++ cmd/openai/readable_output_test.go | 2 +- cmd/openai/resource_summaries_test.go | 2 +- docs/deterministic-output.md | 24 +- pkg/custom/output_invocation_test.go | 281 ++++++++++++++++++++++ pkg/custom/output_invocation_unix_test.go | 42 ++++ pkg/custom/output_policy.go | 156 ++++++++++-- pkg/custom/output_policy_test.go | 31 ++- pkg/custom/readable_errors.go | 61 ++++- pkg/custom/readable_errors_test.go | 37 ++- pkg/custom/readable_request_id_test.go | 216 +++++++++++++++++ pkg/custom/resource_summaries.go | 2 +- pkg/custom/resource_summaries_test.go | 2 +- pkg/custom/save_final_image_stream.go | 2 +- scripts/check-image-picker-integration.py | 2 +- scripts/check-image-progress.py | 10 +- scripts/demos/README.md | 2 +- scripts/demos/output/validate.py | 8 +- 25 files changed, 1091 insertions(+), 76 deletions(-) create mode 100644 cmd/openai/error_request_id_test.go create mode 100644 cmd/openai/output_startup_test.go create mode 100644 pkg/custom/output_invocation_test.go create mode 100644 pkg/custom/output_invocation_unix_test.go create mode 100644 pkg/custom/readable_request_id_test.go diff --git a/cmd/openai/deterministic_output_test.go b/cmd/openai/deterministic_output_test.go index d08e170d..4127b8b6 100644 --- a/cmd/openai/deterministic_output_test.go +++ b/cmd/openai/deterministic_output_test.go @@ -8,6 +8,7 @@ import ( "strings" "sync/atomic" "testing" + "time" ) func TestMainDispatchOutputPipedFormatsIgnoreForcedDecoration(t *testing.T) { @@ -118,6 +119,11 @@ func TestMainDispatchOutputVerboseKeepsDataAndProtectsMachineErrors(t *testing.T strings.Contains(got.stderr, "Command result:") != tc.verbose || strings.Contains(got.stderr, "synthetic-private-organization") { t.Fatalf("verbose policy changed data, privacy, or failure status: %+v", got) } + if tc.verbose { + removeVerboseElapsed(t, got.stderr) + } else if strings.Contains(got.stderr, "Elapsed:") { + t.Fatalf("suppressed verbose timing reached stderr: %+v", got) + } if tc.failure && !tc.verbose { var detail map[string]any if json.Unmarshal([]byte(got.stderr), &detail) != nil || got.stdout != "" { @@ -190,11 +196,55 @@ func TestMainDispatchOutputVerboseBinaryKeepsBytesAndLabelsFormatOption(t *testi })) defer server.Close() got := runReadableCommand(t, server, "--verbose", "files", "content", "file_synthetic", "--output", "-") - if got.code != 0 || got.stdout != payload || got.stderr != "Command: files content\nFormat option: auto\nCommand result: completed\n" { + details, _ := removeVerboseElapsed(t, got.stderr) + if got.code != 0 || got.stdout != payload || details != "Command: files content\nFormat option: auto\nCommand result: completed\n" { t.Fatalf("verbose changed binary data or reported its bytes as text: %+v", got) } } +func TestMainDispatchOutputVerboseElapsedIncludesCommandWork(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + time.Sleep(50 * time.Millisecond) + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, readableModelResponse) + })) + defer server.Close() + got := runReadableCommand(t, server, "--verbose", "--format", "raw", "--format-error", "text", "models", "retrieve", "model_synthetic") + details, elapsed := removeVerboseElapsed(t, got.stderr) + if got.code != 0 || got.stdout != readableModelResponse+"\n" || + details != "Command: models retrieve\nFormat option: raw\nCommand result: completed\n" || requests.Load() != 1 { + t.Fatalf("timing changed data, diagnostics, requests, or status: %+v; requests=%d", got, requests.Load()) + } + if elapsed < 40*time.Millisecond { + t.Fatalf("elapsed time omitted delayed command work: %s", elapsed) + } +} + +func removeVerboseElapsed(t *testing.T, details string) (string, time.Duration) { + t.Helper() + var remaining strings.Builder + var elapsed time.Duration + count := 0 + for line := range strings.SplitAfterSeq(details, "\n") { + if value, ok := strings.CutPrefix(line, "Elapsed: "); ok { + count++ + var err error + elapsed, err = time.ParseDuration(strings.TrimSuffix(value, "\n")) + if err != nil || elapsed < 0 || elapsed%time.Millisecond != 0 { + t.Fatalf("invalid rounded elapsed time in stderr: %q", line) + } + } else { + remaining.WriteString(line) + } + } + if count != 1 { + t.Fatalf("stderr must contain one elapsed time, got %d: %q", count, details) + } + return remaining.String(), elapsed +} + func TestMainDispatchOutputQuietPreservesSavedImagePaths(t *testing.T) { payload := imageGenerationPNG(t) var requests atomic.Int32 diff --git a/cmd/openai/error_request_id_test.go b/cmd/openai/error_request_id_test.go new file mode 100644 index 00000000..d1ca5a2c --- /dev/null +++ b/cmd/openai/error_request_id_test.go @@ -0,0 +1,103 @@ +package main + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +const requestIDErrorBody = `{"error":{"message":"synthetic response detail","type":"server_error","future_field":"kept"}}` + +func runRequestIDError(t *testing.T, requestID string, reflectRequestID bool, flags ...string) mainDispatchResult { + t.Helper() + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("x-should-retry", "false") + w.Header().Set("X-Private-Diagnostic", "synthetic-private-other-header") + returnedID := requestID + if reflectRequestID { + returnedID = r.Header.Get("X-Request-ID") + if returnedID != "synthetic-private-reflected" { + t.Error("custom request ID did not reach the API fixture") + } + } + if returnedID != "" { + w.Header().Set("X-Request-ID", returnedID) + } + w.WriteHeader(http.StatusInternalServerError) + if _, err := io.WriteString(w, requestIDErrorBody); err != nil { + t.Error(err) + } + })) + t.Cleanup(server.Close) + args := append([]string{"openai"}, flags...) + args = append(args, "models", "retrieve", "model_synthetic") + got := runMainDispatchWithEnv(t, "bash", []string{ + "OPENAI_BASE_URL=" + server.URL, + "OPENAI_API_KEY=synthetic-private-request-id-key", + }, args...) + if got.code != 1 || got.stdout != "" || requests.Load() != 1 { + t.Fatalf("request-ID presentation changed status, data, or requests: %+v; requests=%d", got, requests.Load()) + } + return got +} + +func TestMainDispatchOutputAPIErrorRequestID(t *testing.T) { + const requestID = "req_abc123" + for _, tc := range []struct { + name, responseID string + flags []string + wantID bool + reflectID bool + }{ + {"ordinary", requestID, nil, true, false}, + {"quiet", requestID, []string{"--quiet"}, true, false}, + {"missing", "", nil, false, false}, + {"invalid", "https://synthetic.invalid/?token=synthetic-private-header", nil, false, false}, + {"oversized", strings.Repeat("a", 257), nil, false, false}, + {"custom header reflection", "synthetic-private-reflected", []string{"--header", "X-Request-ID: synthetic-private-reflected"}, false, true}, + } { + t.Run(tc.name, func(t *testing.T) { + got := runRequestIDError(t, tc.responseID, tc.reflectID, tc.flags...) + prefix := "HTTP 500: Internal Server Error.\n" + if tc.wantID { + prefix += "Request ID: " + tc.responseID + "\n" + } + if !strings.HasPrefix(got.stderr, prefix) || strings.Contains(got.stderr, "Request ID:") != tc.wantID { + t.Fatalf("request ID is missing, misplaced, or should be omitted: %q", got.stderr) + } + for _, private := range []string{"synthetic-private-", "synthetic response detail", "https://", "\x1b"} { + if strings.Contains(got.stderr, private) { + t.Fatalf("human error exposed unrelated or unsafe data: %q", got.stderr) + } + } + if !strings.Contains(got.stderr, "The API may have received the request.") || + !strings.Contains(got.stderr, "Check its status before trying again.") { + t.Fatalf("request-ID display lost recovery guidance: %q", got.stderr) + } + }) + } +} + +func TestMainDispatchOutputRequestIDKeepsMachineErrors(t *testing.T) { + for _, flags := range [][]string{ + {"--format-error", "json"}, {"--format-error", "jsonl"}, + {"--format-error", "yaml"}, {"--format-error", "raw"}, + {"--format-error", "pretty"}, {"--format-error", "explore"}, + {"--format-error", "json", "--transform-error", "future_field"}, + {"--format-error", "text", "--transform-error", "future_field"}, + } { + t.Run(strings.Join(flags, " "), func(t *testing.T) { + withoutID := runRequestIDError(t, "", false, flags...) + withID := runRequestIDError(t, "req_abc123", false, flags...) + if withID != withoutID || strings.Contains(withID.stderr, "req_abc123") { + t.Fatalf("request-ID metadata changed machine or extracted error bytes: without=%+v with=%+v", withoutID, withID) + } + }) + } +} diff --git a/cmd/openai/global_flags_test.go b/cmd/openai/global_flags_test.go index 7625c200..9a28407e 100644 --- a/cmd/openai/global_flags_test.go +++ b/cmd/openai/global_flags_test.go @@ -93,7 +93,7 @@ func TestMainGlobalFlagsPlacementHeadersAndOutput(t *testing.T) { globalFlagsAt([]string{"models", "list"}, flags, position)...) wantStderr := "" if format == "text" { - wantStderr = "Summary; use --format json for full data.\n" + wantStderr = "Full data: --format json.\n" } if got.code != 0 || got.stderr != wantStderr { t.Fatalf("command failed: %+v", got) diff --git a/cmd/openai/image_progress_test.go b/cmd/openai/image_progress_test.go index 9594c56c..14186fda 100644 --- a/cmd/openai/image_progress_test.go +++ b/cmd/openai/image_progress_test.go @@ -52,11 +52,14 @@ func TestMainImageProgressTerminal(t *testing.T) { } gateDirectory := os.Getenv("OPENAI_CLI_PROGRESS_GATE_DIR") require.NotEmpty(t, gateDirectory, "requires the PTY observer gate") - for _, scenario := range []string{"generate", "edit", "stdin", "iterm", "off", "persisted-off", "explicit-on", "apple-auto", "apple-on", "apple-persisted-on", "apple-no-color", "apple-error-json", "ci", "api", "malformed", "malformed-error-json", "failure", "final-first"} { + for _, scenario := range []string{"generate", "edit", "stdin", "iterm", "off", "persisted-off", "explicit-on", "apple-auto", "apple-on", "apple-persisted-on", "apple-no-color", "apple-error-json", "ci", "api", "malformed", "malformed-error-json", "failure", "final-first", "quiet", "quiet-malformed", "quiet-api", "error-json", "quiet-error-json"} { t.Run(scenario, func(t *testing.T) { apple := strings.HasPrefix(scenario, "apple-") fontRequested := apple && scenario != "apple-auto" - streamFails := scenario == "failure" || scenario == "malformed-error-json" || scenario == "apple-error-json" + quiet := strings.HasPrefix(scenario, "quiet") + api := scenario == "api" || scenario == "quiet-api" + machineError := strings.HasSuffix(scenario, "error-json") + streamFails := scenario == "failure" || machineError payload := imageGenerationPNG(t) encoded := base64.StdEncoding.EncodeToString(payload) closed := make(chan struct{}) @@ -70,7 +73,7 @@ func TestMainImageProgressTerminal(t *testing.T) { require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) require.Equal(t, float64(2), body["partial_images"]) require.Equal(t, true, body["stream"]) - if scenario == "api" { + if api { require.NotContains(t, body, "model") } else { require.Equal(t, defaultProgressModel, body["model"]) @@ -83,7 +86,7 @@ func TestMainImageProgressTerminal(t *testing.T) { } if scenario != "final-first" { first := encoded - if scenario == "malformed" || scenario == "malformed-error-json" { + if scenario == "malformed" || scenario == "malformed-error-json" || scenario == "quiet-malformed" { first = "private-invalid-progress" } fmt.Fprintf(w, "data: {\"type\":%q,\"partial_image_index\":0,\"b64_json\":%q}\n\n", kind+".partial_image", first) @@ -92,7 +95,7 @@ func TestMainImageProgressTerminal(t *testing.T) { fmt.Fprintf(w, "data: {\"type\":%q,\"partial_image_index\":1,\"b64_json\":%q}\n\n", kind+".partial_image", encoded) w.(http.Flusher).Flush() } - if scenario == "generate" || scenario == "edit" || scenario == "stdin" || scenario == "iterm" || scenario == "explicit-on" || scenario == "failure" || (apple && (runtime.GOOS == "darwin" || scenario != "apple-no-color")) { + if scenario == "generate" || scenario == "edit" || scenario == "stdin" || scenario == "iterm" || scenario == "explicit-on" || scenario == "failure" || scenario == "error-json" || (apple && (runtime.GOOS == "darwin" || scenario != "apple-no-color")) { deadline := time.NewTimer(5 * time.Second) defer deadline.Stop() poll := time.NewTicker(10 * time.Millisecond) @@ -119,7 +122,7 @@ func TestMainImageProgressTerminal(t *testing.T) { } fmt.Fprintf(w, "data: {\"type\":%q,\"b64_json\":%q}\n\n", kind+".completed", encoded) w.(http.Flusher).Flush() - if scenario != "api" { + if !api { select { case <-r.Context().Done(): close(closed) @@ -133,6 +136,12 @@ func TestMainImageProgressTerminal(t *testing.T) { require.NoError(t, os.Mkdir(temporary, 0700)) env := append(imageGenerationEnv(server, home), "APPDATA="+home, "XDG_CONFIG_HOME="+home, "OPENAI_CLI_MAIN_DISPATCH_PROCESS=1", "TERM_PROGRAM=kitty", "TERM=xterm-256color", "CI=false", "TMPDIR="+temporary, "TMP="+temporary, "TEMP="+temporary) args := []string{"images", "generate", "--prompt", "synthetic progress", "--partial-images", "2", "--max-items", "-1"} + if quiet { + args = append(args, "--quiet") + if !api { + args = append(args, "--inline", "on") + } + } var input io.Reader if scenario == "edit" { source, _ := imageUploadSource(t) @@ -166,11 +175,11 @@ func TestMainImageProgressTerminal(t *testing.T) { if scenario == "iterm" { env = append(env, "TERM_PROGRAM=iTerm.app") } - if scenario == "api" { + if api { args = append([]string{"--format", "json"}, args...) args = append(args, "--stream", "true") } - if scenario == "malformed-error-json" || scenario == "apple-error-json" { + if machineError { args = append([]string{"--format-error", "json"}, args...) } binary, err := os.Executable() @@ -201,7 +210,7 @@ func TestMainImageProgressTerminal(t *testing.T) { if streamFails { require.Error(t, err) require.Contains(t, diagnostic.String(), "before trying again") - if scenario == "malformed-error-json" || scenario == "apple-error-json" { + if machineError { require.True(t, json.Valid(diagnostic.Bytes()), diagnostic.String()) } } else { @@ -218,7 +227,7 @@ func TestMainImageProgressTerminal(t *testing.T) { } require.NotContains(t, diagnostic.String(), "synthetic-private") files := imageGenerationFiles(t, filepath.Join(home, "Downloads", "gpt-images")) - if streamFails || scenario == "api" { + if streamFails || api { require.Empty(t, files) } else { require.Len(t, files, 1) diff --git a/cmd/openai/main.go b/cmd/openai/main.go index 7c750594..e1ab7f91 100644 --- a/cmd/openai/main.go +++ b/cmd/openai/main.go @@ -35,7 +35,9 @@ func main() { ctx := context.Background() if err == nil { custom.SetupImagePickerShellOnFirstRun(ctx, os.Args) - err = runWithRequestConfiguration(ctx, app, args, completing) + err = custom.RunWithOutputPolicy(ctx, app, func(ctx context.Context) error { + return runWithRequestConfiguration(ctx, app, args, completing) + }) } if err != nil { exitCode := 1 diff --git a/cmd/openai/main_api_error_test.go b/cmd/openai/main_api_error_test.go index 6cebbb57..f573a904 100644 --- a/cmd/openai/main_api_error_test.go +++ b/cmd/openai/main_api_error_test.go @@ -258,7 +258,7 @@ func TestMainStreamErrorsPreserveEventDetails(t *testing.T) { } switch { case test.format == "text": - if !strings.Contains(result.stderr, "The response stream failed. Output may be incomplete.") || strings.Contains(result.stderr, "synthetic private stream detail") || strings.Contains(result.stderr, "200") { + if !strings.Contains(result.stderr, "The response stream failed.\nOutput may be incomplete.") || strings.Contains(result.stderr, "synthetic private stream detail") || strings.Contains(result.stderr, "200") { t.Errorf("missing safe stream failure summary: %q", result.stderr) } case test.extracted != "": diff --git a/cmd/openai/main_readable_errors_test.go b/cmd/openai/main_readable_errors_test.go index c11b1dfa..dda5ceab 100644 --- a/cmd/openai/main_readable_errors_test.go +++ b/cmd/openai/main_readable_errors_test.go @@ -24,9 +24,9 @@ func TestMainReadableErrorsStayOnStderr(t *testing.T) { name, contentType, body, want string status int }{ - {"API error", "application/json", `{"error":{"message":"synthetic private response detail","code":"model_not_found","param":"model"}}`, "Request failed (404", http.StatusNotFound}, - {"gateway text", "text/plain", "synthetic private response detail", "Request failed (502", http.StatusBadGateway}, - {"malformed JSON", "application/json", `{"error":{"message":"synthetic private response detail"`, "Request failed (502", http.StatusBadGateway}, + {"API error", "application/json", `{"error":{"message":"synthetic private response detail","code":"model_not_found","param":"model"}}`, "HTTP 404:", http.StatusNotFound}, + {"gateway text", "text/plain", "synthetic private response detail", "HTTP 502:", http.StatusBadGateway}, + {"malformed JSON", "application/json", `{"error":{"message":"synthetic private response detail"`, "HTTP 502:", http.StatusBadGateway}, {"unexpected shape", "application/json", `{"error":"synthetic private response detail"}`, "Could not decode JSON", http.StatusBadGateway}, } { t.Run(test.name, func(t *testing.T) { diff --git a/cmd/openai/output_startup_test.go b/cmd/openai/output_startup_test.go new file mode 100644 index 00000000..25e7cd5b --- /dev/null +++ b/cmd/openai/output_startup_test.go @@ -0,0 +1,89 @@ +package main + +import ( + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync/atomic" + "testing" +) + +func TestMainDispatchOutputVerboseBeforeFailure(t *testing.T) { + var connections atomic.Int32 + server := httptest.NewUnstartedServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Error("startup failure reached the API") + })) + server.Config.ConnState = func(_ net.Conn, state http.ConnState) { + if state == http.StateNew { + connections.Add(1) + } + } + server.StartTLS() + t.Cleanup(server.Close) + home := t.TempDir() + missing := filepath.Join(home, "synthetic-private-missing.pem") + for _, config := range []struct { + name, hint string + env []string + }{ + {"base URL", "OPENAI_BASE_URL", []string{"OPENAI_BASE_URL=synthetic-private-invalid%url"}}, + {"mTLS", "mTLS", []string{"OPENAI_BASE_URL=" + server.URL, + "OPENAI_MTLS_CLIENT_CERT_FILE=" + missing, "OPENAI_MTLS_CLIENT_KEY_FILE=" + missing}}, + } { + for _, policy := range []struct { + name string + flags []string + afterCommand bool + wantVerbose bool + structuredErr bool + }{ + {name: "human", wantVerbose: true}, + {name: "nested flags", afterCommand: true, wantVerbose: true}, + {name: "quiet", flags: []string{"--quiet"}}, + {name: "inherited machine", flags: []string{"--format", "json"}, structuredErr: true}, + {name: "explicit machine", flags: []string{"--format-error", "json"}, structuredErr: true}, + {name: "extracted error", flags: []string{"--format-error", "text", "--transform-error", "message"}}, + } { + t.Run(config.name+"/"+policy.name, func(t *testing.T) { + env := append([]string{"HOME=" + home, "USERPROFILE=" + home, "APPDATA=" + home, + "XDG_CONFIG_HOME=" + home, "OPENAI_API_KEY=synthetic-private-startup-key"}, config.env...) + args := append([]string{"openai"}, policy.flags...) + // A value named help must not select a help command or enter the report. + args = append(args, "models", "retrieve", "--model", "help") + ordinary := runMainDispatchWithEnv(t, "bash", env, args...) + if policy.afterCommand { + args = append(args, "--verbose") + } else { + args = append([]string{"openai", "--verbose"}, args[1:]...) + } + got := runMainDispatchWithEnv(t, "bash", env, args...) + if ordinary.code != 1 || got.code != 1 || ordinary.stdout != "" || got.stdout != "" || connections.Load() != 0 { + t.Fatalf("startup status, data, or network boundary changed: ordinary=%+v verbose=%+v; connections=%d", ordinary, got, connections.Load()) + } + if !strings.Contains(got.stderr, config.hint) { + t.Fatalf("startup error lost its safe configuration hint: %q", got.stderr) + } + if policy.wantVerbose { + details, _ := removeVerboseElapsed(t, got.stderr) + want := "Command: models retrieve\nFormat option: auto\nCommand result: failed\n" + ordinary.stderr + if details != want { + t.Fatalf("startup verbose must report once and preserve the error: got %q; want %q", details, want) + } + } else if got != ordinary { + t.Fatalf("suppressed verbose changed the original error: ordinary=%+v verbose=%+v", ordinary, got) + } + if policy.structuredErr && !json.Valid([]byte(got.stderr)) { + t.Fatalf("verbose corrupted the complete JSON error: %q", got.stderr) + } + for _, private := range []string{home, missing, server.URL, "synthetic-private-", "\x1b"} { + if strings.Contains(got.stderr, private) { + t.Fatalf("startup diagnostics exposed configuration data: %q", got.stderr) + } + } + }) + } + } +} diff --git a/cmd/openai/readable_output_test.go b/cmd/openai/readable_output_test.go index 0d4adfb0..b8da0ca8 100644 --- a/cmd/openai/readable_output_test.go +++ b/cmd/openai/readable_output_test.go @@ -307,7 +307,7 @@ func TestMainReadableKeepsAPIErrorOnStderr(t *testing.T) { t.Fatalf("error routing changed: %+v", got) } if len(prefix) == 0 || prefix[0] == "--format" { - if !strings.HasPrefix(got.stderr, "Request failed (400 Bad Request).\n") || strings.Contains(got.stderr, "synthetic invalid request") { + if !strings.HasPrefix(got.stderr, "HTTP 400: Bad Request.\n") || strings.Contains(got.stderr, "synthetic invalid request") { t.Fatalf("expected a safe readable error summary: %+v", got) } continue diff --git a/cmd/openai/resource_summaries_test.go b/cmd/openai/resource_summaries_test.go index 7404b63f..bf2c6f28 100644 --- a/cmd/openai/resource_summaries_test.go +++ b/cmd/openai/resource_summaries_test.go @@ -20,7 +20,7 @@ import ( "time" ) -const resourceSummaryHint = "Summary; use --format json for full data." +const resourceSummaryHint = "Full data: --format json." func TestMainResourceSummariesRetainActionFields(t *testing.T) { for _, tc := range []struct { diff --git a/docs/deterministic-output.md b/docs/deterministic-output.md index 2a927e9f..bd6443a5 100644 --- a/docs/deterministic-output.md +++ b/docs/deterministic-output.md @@ -20,12 +20,18 @@ Requested raw strings and binary data can contain escape bytes; the CLI preserve `--quiet` suppresses optional hints, progress, and success receipts. It preserves selected data, requested help, errors, and exit status. Saved image paths remain selected data. +Quiet suppresses partial-image progress but preserves explicitly requested final previews. Quiet does not change the format or discard stdout. +Receipts from binary file saves and manpage generation currently remain on stdout, including with `--quiet`. -`--verbose` reports the command, format option, and command result on stderr. +`--verbose` reports the command, format option, elapsed time, and command result on stderr. +Elapsed time includes argument parsing, request setup, command work, and cleanup. +Command work includes local operations, picker interaction, output, and saving. +It excludes process startup and the final verbose message; it is not API-only latency. +Verbose also reports request-setup failures that occur before the command action. The format option does not change an existing binary response into text. -It excludes argument values, credentials, URLs, headers, prompts, filenames, and response bodies. -Command completion does not imply that asynchronous API work has finished. +Verbose excludes argument values, credentials, URLs, headers, prompts, filenames, and response bodies. +Command completion and elapsed time do not imply that asynchronous API work has finished. Verbose omits its final details after cancellation, deadlines, or an existing interruption status. The original action error and exit status remain. Quiet overrides verbose when both flags are present. @@ -40,6 +46,9 @@ openai models list --format jsonl --verbose --format-error text > models.jsonl openai models list --format jsonl --quiet > models.jsonl ``` +Embedded callers use `custom.RunWithOutputPolicy` to report the complete invocation and cleanup. +Direct `Command.Run` calls retain action-scoped reporting and timing. + ## Lists and streams Lists retain their existing record formats. @@ -56,7 +65,8 @@ openai files list --format json --transform id --raw-output ``` `--max-items -1` remains unlimited. -`--max-items 0` emits no items; the SDK can still make its initial request. +`--max-items 0` emits no items, except with `--format raw`, which retains the page envelope. +The SDK can still make its initial request. GJSON extraction applies to each item. Missing paths retain the original item. `--raw-output` unquotes strings; `--format raw` selects original response data. @@ -66,6 +76,9 @@ Explicit terminal explore retains its interactive viewer. Nonterminal explore retains its JSON fallback. Piped stdout never opens a pager or picker. CI does not infer a format or introduce a new output mode. +Explicit explore still opens its viewer when CI has a real terminal. +Quiet suppresses optional feedback; it does not disable explicitly selected interaction or prompts. +Existing CI guards for automatic previews and shell setup remain. ## Failures and saved output @@ -77,6 +90,9 @@ A broken output sink cannot guarantee a complete output document. Binary stdout preserves every byte. Redirecting stderr does not redirect selected stdout data. Explicit error formats control stderr data, including API error payloads. +Human API errors show returned request IDs, including with `--quiet`. +The CLI omits missing IDs, unusual characters, IDs over 256 bytes, and IDs accompanying a custom `X-Request-ID` request header. +This display bound does not limit API payloads or change structured errors. Shell redirection opens or truncates its destination before the CLI starts. The CLI cannot restore a file truncated by `>` after a failed request. diff --git a/pkg/custom/output_invocation_test.go b/pkg/custom/output_invocation_test.go new file mode 100644 index 00000000..c99de15f --- /dev/null +++ b/pkg/custom/output_invocation_test.go @@ -0,0 +1,281 @@ +package custom + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/urfave/cli/v3" +) + +// These tests change process stderr, so they must remain sequential. +func captureInvocationStderr(t *testing.T, run func(*os.File) error) (string, error) { + t.Helper() + name := filepath.Join(t.TempDir(), "stderr") + file, err := os.Create(name) + if err != nil { + t.Fatal(err) + } + previous := os.Stderr + os.Stderr = file + closed := false + defer func() { + os.Stderr = previous + if !closed { + if err := file.Close(); err != nil { + t.Error(err) + } + } + }() + runErr := run(file) + if err := file.Close(); err != nil { + t.Fatal(err) + } + closed = true + data, err := os.ReadFile(name) + if err != nil { + t.Fatal(err) + } + return string(data), runErr +} + +func invocationCommand(commands ...*cli.Command) *cli.Command { + root := &cli.Command{Name: "openai", Writer: io.Discard, ErrWriter: io.Discard, + Flags: []cli.Flag{ + &cli.StringFlag{Name: "format", Value: "auto"}, + &cli.StringFlag{Name: "format-error", Value: "text"}, + &cli.StringFlag{Name: "transform-error"}, + }, Commands: commands, + } + configureOutputPolicy(root) + ConfigureCommandErrors(root) + return root +} + +func checkInvocationReport(t *testing.T, data, command, result string) time.Duration { + t.Helper() + lines := strings.Split(data, "\n") + if len(lines) != 5 || lines[0] != "Command: "+command || lines[1] != "Format option: auto" || + lines[3] != "Command result: "+result || lines[4] != "" { + t.Fatalf("unexpected invocation report: %q", data) + } + value, ok := strings.CutPrefix(lines[2], "Elapsed: ") + elapsed, err := time.ParseDuration(value) + if !ok || err != nil || elapsed < 0 || elapsed%time.Millisecond != 0 { + t.Fatalf("invalid elapsed value: %q", lines[2]) + } + return elapsed +} + +func TestOutputInvocationReportsAfterFinalOutcome(t *testing.T) { + beforeErr := errors.New("synthetic Before failure") + actionErr := cli.Exit("synthetic Action failure", 27) + afterErr := cli.Exit("synthetic After failure", 28) + parentErr := errors.New("synthetic parent After failure") + cleanupErr := cli.Exit("synthetic cleanup failure", 29) + for _, tc := range []struct { + name string + before, action, after, parent, cleanup error + }{ + {name: "success"}, + {name: "Before", before: beforeErr}, + {name: "Action", action: actionErr}, + {name: "After", after: afterErr}, + {name: "cleanup", cleanup: cleanupErr}, + {name: "combined", action: actionErr, after: afterErr, parent: parentErr, cleanup: cleanupErr}, + } { + t.Run(tc.name, func(t *testing.T) { + var stdout bytes.Buffer + actions := 0 + leaf := &cli.Command{Name: "synthetic", Action: func(ctx context.Context, command *cli.Command) error { + actions++ + if _, err := io.WriteString(command.Root().Writer, "selected\x00\xffdata\n"); err != nil { + return err + } + return tc.action + }} + root := invocationCommand(leaf) + root.Writer = &stdout + root.Before = func(ctx context.Context, _ *cli.Command) (context.Context, error) { return ctx, tc.before } + var original error + details, err := captureInvocationStderr(t, func(sink *os.File) error { + leaf.After = func(context.Context, *cli.Command) error { + info, err := sink.Stat() + if err != nil { + t.Fatal(err) + } + if info.Size() != 0 { + t.Error("verbose report preceded After") + } + return tc.after + } + root.After = func(context.Context, *cli.Command) error { return tc.parent } + return RunWithOutputPolicy(t.Context(), root, func(ctx context.Context) error { + original = root.Run(ctx, []string{"openai", "--verbose", "synthetic"}) + if tc.cleanup != nil { + original = errors.Join(original, tc.cleanup) + } + return original + }) + }) + wantResult := "completed" + if original != nil { + wantResult = "failed" + if !errors.Is(err, original) || err.Error() != original.Error() { + t.Fatalf("final error identity or text changed: original=%v; got=%v", original, err) + } + } else if err != nil { + t.Fatal(err) + } + checkInvocationReport(t, details, "synthetic", wantResult) + for _, cause := range []error{tc.before, tc.action, tc.after, tc.parent, tc.cleanup} { + if cause != nil && !errors.Is(err, cause) { + t.Errorf("lost cause %v: %v", cause, err) + } + } + if tc.action != nil { + var exit cli.ExitCoder + if !errors.As(err, &exit) || exit.ExitCode() != 27 { + t.Fatalf("Action exit lost precedence: %v", err) + } + } + if tc.before != nil { + if actions != 0 || stdout.Len() != 0 { + t.Fatalf("Before failure ran Action: calls=%d bytes=%q", actions, stdout.String()) + } + } else if actions != 1 || stdout.String() != "selected\x00\xffdata\n" { + t.Fatalf("Action bytes or count changed: calls=%d bytes=%q", actions, stdout.String()) + } + }) + } +} + +func TestOutputInvocationRetainsFirstActionAndResetsBetweenRuns(t *testing.T) { + calls := 0 + inner := &cli.Command{Name: "inner", Action: func(context.Context, *cli.Command) error { calls++; return nil }} + outer := &cli.Command{Name: "outer", Action: func(ctx context.Context, _ *cli.Command) error { + return errors.Join(inner.Action(ctx, inner), inner.Action(ctx, inner)) + }} + root := invocationCommand(outer, inner) + for _, name := range []string{"outer", "inner", "outer"} { + details, err := captureInvocationStderr(t, func(*os.File) error { + return RunWithOutputPolicy(t.Context(), root, func(ctx context.Context) error { + return root.Run(ctx, []string{"openai", "--verbose", name}) + }) + }) + if err != nil { + t.Fatal(err) + } + checkInvocationReport(t, details, name, "completed") + } + if calls != 5 { + t.Fatalf("nested actions reran or disappeared: %d", calls) + } +} + +func TestOutputInvocationPreservesDirectActionBoundary(t *testing.T) { + root := invocationCommand(&cli.Command{Name: "synthetic", Action: func(context.Context, *cli.Command) error { return nil }}) + cause := errors.New("synthetic After failure") + details, err := captureInvocationStderr(t, func(sink *os.File) error { + root.After = func(context.Context, *cli.Command) error { + info, err := sink.Stat() + if err != nil { + t.Fatal(err) + } + if info.Size() == 0 { + t.Error("direct Command.Run lost action-scoped reporting") + } + return cause + } + return root.Run(t.Context(), []string{"openai", "--verbose", "synthetic"}) + }) + if !errors.Is(err, cause) { + t.Fatalf("direct After error changed: %v", err) + } + checkInvocationReport(t, details, "synthetic", "completed") +} + +func TestOutputInvocationElapsedIncludesBeforeAfterAndCleanup(t *testing.T) { + root := invocationCommand(&cli.Command{Name: "synthetic", Action: func(context.Context, *cli.Command) error { return nil }}) + root.Before = func(ctx context.Context, _ *cli.Command) (context.Context, error) { + time.Sleep(25 * time.Millisecond) + return ctx, nil + } + root.After = func(context.Context, *cli.Command) error { time.Sleep(25 * time.Millisecond); return nil } + details, err := captureInvocationStderr(t, func(*os.File) error { + return RunWithOutputPolicy(t.Context(), root, func(ctx context.Context) error { + err := root.Run(ctx, []string{"openai", "--verbose", "synthetic"}) + time.Sleep(25 * time.Millisecond) + return err + }) + }) + if err != nil { + t.Fatal(err) + } + if elapsed := checkInvocationReport(t, details, "synthetic", "completed"); elapsed < 65*time.Millisecond { + t.Fatalf("outer timing omitted lifecycle work: %s", elapsed) + } +} + +func TestOutputInvocationInterruptionsAndDiagnosticFailures(t *testing.T) { + closed, err := os.CreateTemp(t.TempDir(), "closed-stderr") + if err != nil { + t.Fatal(err) + } + if err := closed.Close(); err != nil { + t.Fatal(err) + } + previous := os.Stderr + os.Stderr = closed + defer func() { os.Stderr = previous }() + for _, tc := range []struct { + name string + cause error + code int + cancel bool + wantWrite bool + }{ + {"cancellation", context.Canceled, 27, false, false}, + {"deadline", context.DeadlineExceeded, 27, false, false}, + {"timeout exit", errors.New("synthetic"), 124, false, false}, + {"interrupt exit", errors.New("synthetic"), 130, false, false}, + {"termination exit", errors.New("synthetic"), 143, false, false}, + {"parent cancellation", errors.New("synthetic"), 27, true, false}, + {"ordinary failure", errors.New("synthetic"), 27, false, true}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + failure := &interruptedOutputTestError{cause: tc.cause, code: tc.code} + after := errors.New("synthetic After failure") + cleanup := cli.Exit("synthetic cleanup failure", 29) + root := invocationCommand(&cli.Command{Name: "synthetic", Action: func(context.Context, *cli.Command) error { + if tc.cancel { + cancel() + } + return failure + }}) + root.After = func(context.Context, *cli.Command) error { return after } + var original error + err := RunWithOutputPolicy(ctx, root, func(ctx context.Context) error { + original = errors.Join(root.Run(ctx, []string{"openai", "--verbose", "synthetic"}), cleanup) + return original + }) + var exit cli.ExitCoder + if !errors.Is(err, failure) || !errors.Is(err, after) || !errors.Is(err, cleanup) || !errors.Is(err, original) || + !errors.As(err, &exit) || exit.ExitCode() != tc.code { + t.Fatalf("combined failure lost identity, causes, or exit ordering: %v", err) + } + var diagnostic *diagnosticWriteError + if errors.Is(err, os.ErrClosed) != tc.wantWrite || errors.As(err, &diagnostic) != tc.wantWrite { + t.Fatalf("interruption guard or diagnostic provenance changed: %v", err) + } + }) + } +} diff --git a/pkg/custom/output_invocation_unix_test.go b/pkg/custom/output_invocation_unix_test.go new file mode 100644 index 00000000..f9f7bce3 --- /dev/null +++ b/pkg/custom/output_invocation_unix_test.go @@ -0,0 +1,42 @@ +//go:build !windows + +package custom + +import ( + "context" + "errors" + "os" + "syscall" + "testing" + + "github.com/urfave/cli/v3" +) + +func TestOutputInvocationStderrBrokenPipeKeepsFailureProvenance(t *testing.T) { + read, write, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + if err := read.Close(); err != nil { + t.Fatal(err) + } + previous := os.Stderr + os.Stderr = write + defer func() { + os.Stderr = previous + if err := write.Close(); err != nil { + t.Error(err) + } + }() + cause := cli.Exit("synthetic failure", 27) + root := invocationCommand(&cli.Command{Name: "synthetic", Action: func(context.Context, *cli.Command) error { return cause }}) + err = RunWithOutputPolicy(t.Context(), root, func(ctx context.Context) error { + return root.Run(ctx, []string{"openai", "--verbose", "synthetic"}) + }) + var diagnostic *diagnosticWriteError + var exit cli.ExitCoder + if !errors.Is(err, cause) || !errors.Is(err, syscall.EPIPE) || !errors.As(err, &diagnostic) || + !errors.As(err, &exit) || exit.ExitCode() != 27 || isOutputBrokenPipe(err) { + t.Fatalf("stderr EPIPE hid the original failure or became stdout EPIPE: %v", err) + } +} diff --git a/pkg/custom/output_policy.go b/pkg/custom/output_policy.go index 37e095fa..3cc77ffc 100644 --- a/pkg/custom/output_policy.go +++ b/pkg/custom/output_policy.go @@ -8,12 +8,106 @@ import ( "os" "slices" "strings" + "time" "github.com/openai/openai-cli/internal/readable" "github.com/urfave/cli/v3" ) type outputPolicyKey struct{} +type outputInvocationKey struct{} + +// One synchronous invocation owns this state. Nested actions retain its first identity. +type outputInvocation struct { + command *cli.Command + label string + protocol bool + context context.Context +} + +// RunWithOutputPolicy reports after the complete CLI run and its cleanup return. +// Direct Command.Run callers retain the existing action-scoped reporting boundary. +func RunWithOutputPolicy(ctx context.Context, root *cli.Command, run func(context.Context) error) error { + invocation := &outputInvocation{} + ctx = context.WithValue(ctx, outputInvocationKey{}, invocation) + started := time.Now() + err := run(ctx) + elapsed := time.Since(started) + err = exposeInvocationErrors(err) + if invocation.command == nil { + // Successful help, version, and completion need no action report. + if err == nil || root.SkipFlagParsing { + return err + } + invocation.command, invocation.label, invocation.protocol = parsedOutputCommand(root) + if invocation.command == nil || root.Bool("help") || root.Bool("version") || invocation.command.Bool("help") { + return err + } + } else { + ctx = invocation.context + } + ctx = outputPolicyContext(ctx, invocation.command) + return finishOutputPolicy(ctx, invocation.command, invocation.label, invocation.protocol, err, elapsed) +} + +// The framework hides combined Action/After errors behind Errors(), not Unwrap(). +// Adapt only those containers and the callback's joined cleanup errors. +func exposeInvocationErrors(err error) error { + var causes []error + switch combined := err.(type) { + case cli.MultiError: + causes = combined.Errors() + for i, cause := range causes { + causes[i] = exposeInvocationErrors(cause) + } + // The opaque original retains its identity without hiding ordered children. + causes = append([]error{err}, causes...) + case interface{ Unwrap() []error }: + for _, cause := range combined.Unwrap() { + causes = append(causes, exposeInvocationErrors(cause)) + } + // Visit normalized operation errors before joined cleanup errors. + causes = append(causes, err) + default: + return err + } + return &outputInvocationError{error: err, causes: causes} +} + +type outputInvocationError struct { + error + causes []error +} + +func (e *outputInvocationError) Unwrap() []error { return e.causes } + +// Read parsed selectors, but emit only names from the declared command tree. +func parsedOutputCommand(root *cli.Command) (*cli.Command, string, bool) { + command := root + var path []string + for command != nil { + if command.Name == "help" || command.Name == "__complete" { + return nil, "", false + } + if command.Args() == nil || !command.Args().Present() { + break + } + child := command.Command(command.Args().First()) + if child == nil { + break + } + path = append(path, child.Name) + command = child + } + if command == nil { + return nil, "", false + } + label := strings.Join(path, " ") + if label == "" { + label = root.Name + } + return command, label, command.Name == "@completion" +} type outputPolicy struct { quiet bool @@ -87,27 +181,18 @@ func configureOutputPolicy(root *cli.Command) { label := strings.Join(path, " ") protocol := command.Name == "@completion" command.Action = func(ctx context.Context, command *cli.Command) error { - root := command.Root() ctx = outputPolicyContext(ctx, command) - err := next(ctx, command) - if protocol && !command.Bool("install-picker") && !command.Bool("uninstall-picker") || - !root.Bool("verbose") || !outputDiagnosticsAllowed(ctx) { - return err - } - // Optional feedback must not restart output after an interrupted action. - var exit cli.ExitCoder - if ctx.Err() != nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || - errors.As(err, &exit) && (exit.ExitCode() == 124 || exit.ExitCode() == 130 || exit.ExitCode() == 143) { - return err - } - format := strings.ToLower(root.String("format")) - if format == "" { - format = "auto" - } - if !slices.Contains(OutputFormats, format) { - format = "unknown" + if invocation, _ := ctx.Value(outputInvocationKey{}).(*outputInvocation); invocation != nil { + if invocation.command == nil { + invocation.command, invocation.label, invocation.protocol = command, label, protocol + invocation.context = ctx + } + return next(ctx, command) } - return errors.Join(err, writeVerboseResult(os.Stderr, label, format, err)) + started := time.Now() + err := next(ctx, command) + elapsed := time.Since(started) + return finishOutputPolicy(ctx, command, label, protocol, err, elapsed) } } for _, child := range command.Commands { @@ -117,14 +202,41 @@ func configureOutputPolicy(root *cli.Command) { visit(root, nil) } -// Only static command declarations and validated format names reach this sink. +func finishOutputPolicy(ctx context.Context, command *cli.Command, label string, protocol bool, err error, elapsed time.Duration) error { + root := command.Root() + if protocol && !command.Bool("install-picker") && !command.Bool("uninstall-picker") || + !root.Bool("verbose") || !outputDiagnosticsAllowed(ctx) { + return err + } + // Optional feedback must not restart output after an interrupted action. + var exit cli.ExitCoder + if ctx.Err() != nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || + errors.As(err, &exit) && (exit.ExitCode() == 124 || exit.ExitCode() == 130 || exit.ExitCode() == 143) { + return err + } + format := strings.ToLower(root.String("format")) + if format == "" { + format = "auto" + } + if !slices.Contains(OutputFormats, format) { + format = "unknown" + } + diagnosticErr := writeVerboseResult(os.Stderr, label, format, err, elapsed) + if diagnosticErr != nil { + diagnosticErr = &diagnosticWriteError{diagnosticErr} + } + return errors.Join(err, diagnosticErr) +} + +// Diagnostics use declared command names, validated formats, local timing, and a fixed outcome. // An action returning nil confirms command completion, not asynchronous API work. -func writeVerboseResult(out io.Writer, command, format string, failure error) error { +func writeVerboseResult(out io.Writer, command, format string, failure error, elapsed time.Duration) error { result := "completed" if failure != nil { result = "failed" } _, err := fmt.Fprintf(outputWriter{ctx: context.Background(), out: out}, - "Command: %s\nFormat option: %s\nCommand result: %s\n", command, format, result) + "Command: %s\nFormat option: %s\nElapsed: %s\nCommand result: %s\n", + command, format, elapsed.Round(time.Millisecond).String(), result) return err } diff --git a/pkg/custom/output_policy_test.go b/pkg/custom/output_policy_test.go index 07c2759e..c4de7823 100644 --- a/pkg/custom/output_policy_test.go +++ b/pkg/custom/output_policy_test.go @@ -8,6 +8,7 @@ import ( "os" "strings" "testing" + "time" "github.com/urfave/cli/v3" ) @@ -79,18 +80,42 @@ func TestOutputPolicyProtectsMachineErrors(t *testing.T) { func TestVerboseResultNeverIncludesFailureDetails(t *testing.T) { var output bytes.Buffer failure := errors.New("synthetic-secret https://example.invalid/?token=synthetic prompt=private") - if err := writeVerboseResult(&output, "models list", "json", failure); err != nil { + if err := writeVerboseResult(&output, "models list", "json", failure, 1250*time.Millisecond); err != nil { t.Fatal(err) } - if output.String() != "Command: models list\nFormat option: json\nCommand result: failed\n" { + if output.String() != "Command: models list\nFormat option: json\nElapsed: 1.25s\nCommand result: failed\n" { t.Fatalf("unexpected verbose output: %q", output.String()) } cause := errors.New("synthetic sink failure") - if err := writeVerboseResult(outputPolicyFailureWriter{cause}, "models list", "json", nil); !errors.Is(err, cause) { + if err := writeVerboseResult(outputPolicyFailureWriter{cause}, "models list", "json", nil, 0); !errors.Is(err, cause) { t.Fatalf("diagnostic write failure disappeared: %v", err) } } +func TestVerboseResultRoundsElapsedToMilliseconds(t *testing.T) { + for _, tc := range []struct { + elapsed time.Duration + want string + }{ + {0, "0s"}, + {499 * time.Microsecond, "0s"}, + {1500 * time.Microsecond, "2ms"}, + {1250 * time.Millisecond, "1.25s"}, + {time.Minute + 2345*time.Millisecond, "1m2.345s"}, + } { + t.Run(tc.elapsed.String(), func(t *testing.T) { + var output bytes.Buffer + if err := writeVerboseResult(&output, "models list", "json", nil, tc.elapsed); err != nil { + t.Fatal(err) + } + want := "Command: models list\nFormat option: json\nElapsed: " + tc.want + "\nCommand result: completed\n" + if output.String() != want { + t.Fatalf("elapsed diagnostic changed: got %q, want %q", output.String(), want) + } + }) + } +} + func TestOutputPolicyReachesLocalReceiptActions(t *testing.T) { for _, args := range [][]string{ {"--quiet", "@completion", "install", "picker"}, diff --git a/pkg/custom/readable_errors.go b/pkg/custom/readable_errors.go index 7d7d82c2..9b57ff34 100644 --- a/pkg/custom/readable_errors.go +++ b/pkg/custom/readable_errors.go @@ -85,7 +85,7 @@ func ShowCommandError(root *cli.Command, failure error, out io.Writer) error { if isAPI && !errors.Is(failure, context.Canceled) { message = readableAPIErrorMessage(root, failure, apierr) } else if isStream { - message = readableStreamErrorMessage + message = readableStreamErrorText } else { message = localErrorMessage(root, failure) } @@ -120,18 +120,20 @@ func ShowCommandError(root *cli.Command, failure error, out io.Writer) error { }) } +// Keep the existing structured fallback payload independent of human guidance. const readableStreamErrorMessage = "The response stream failed. Output may be incomplete.\nFor API error details, add --format-error json." +const readableStreamErrorText = "The response stream failed.\nOutput may be incomplete.\nAPI error details: --format-error json." func readableAPIErrorMessage(root *cli.Command, failure error, apierr *openai.Error) string { invocation := errorHelpInvocation(root) message := "The API could not complete the request." switch apierr.StatusCode { case http.StatusUnauthorized: - message = "Authentication failed. Check your API key, organization, and project.\nKey setup: " + invocation + " help setup" + message = "Check API key, organization and project.\nKey setup: " + invocation + " help setup" case http.StatusForbidden: - message = "Access denied. Check the key's permissions and your project's access to this resource." + message = "Check your key's permissions.\nCheck project access to this resource." case http.StatusNotFound: - message = "The requested resource or model was not found or is not available to your key." + message = "Check the resource or model ID.\nCheck your key's access to it." case http.StatusBadRequest, http.StatusUnprocessableEntity: var contextual *commandError var command *cli.Command @@ -140,21 +142,60 @@ func readableAPIErrorMessage(root *cli.Command, failure error, apierr *openai.Er } message = readableAPIArgumentMessage(apierr, command) case http.StatusTooManyRequests: - message = "Rate limit reached. Wait before trying again." + message = "Wait before trying again." switch apierr.Code { case "insufficient_quota", "billing_hard_limit_reached", "credit_balance_exhausted", "organization_spend_limit_exceeded", "project_spend_limit_exceeded", "organization_usage_limit_exceeded": - message = "API usage or billing limit reached. Check your project's billing and limits." + message = "API usage or billing limit reached.\nCheck your project's billing and limits." } case http.StatusConflict: - message = "The resource changed or is not ready for this operation. Check its current status." + message = "The resource changed or is not ready.\nCheck its current status." case http.StatusRequestTimeout, http.StatusGatewayTimeout: - message = "The request timed out. The API may have received it; check its status before repeating it." + message = "The request timed out.\nThe API may have received the request.\nCheck its status before trying again." default: if apierr.StatusCode >= 500 { - message = "The API is temporarily unavailable. The request may have been received; check its status before repeating it." + message = "The API is temporarily unavailable.\nThe API may have received the request.\nCheck its status before trying again." } } - return fmt.Sprintf("Request failed (%d %s).\n%s\nFor API error details, add --format-error json.", apierr.StatusCode, http.StatusText(apierr.StatusCode), message) + if apierr.StatusCode > 0 { + status := fmt.Sprintf("HTTP %d", apierr.StatusCode) + if reason := http.StatusText(apierr.StatusCode); reason != "" { + status += ": " + reason + } + header := status + ".\n" + if requestID := readableAPIRequestID(apierr); requestID != "" { + header += "Request ID: " + requestID + "\n" + } + message = header + message + } + return message + "\nAPI error details: --format-error json." +} + +// Accept only bounded, single-line returned identifiers. +func readableAPIRequestID(apierr *openai.Error) string { + if apierr == nil { + return "" + } + // A caller-supplied header can be reflected by the server. Inspect keys only. + if apierr.Request != nil { + for name := range apierr.Request.Header { + if strings.EqualFold(name, "X-Request-ID") { + return "" + } + } + } + if apierr.Response == nil { + return "" + } + requestID := apierr.Response.Header.Get("X-Request-ID") + if len(requestID) == 0 || len(requestID) > 256 { + return "" + } + for _, c := range requestID { + if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '.' || c == '_' || c == '-') { + return "" + } + } + return requestID } // Help's invocation is local display metadata, not part of the API response. diff --git a/pkg/custom/readable_errors_test.go b/pkg/custom/readable_errors_test.go index a8531592..5ce6d788 100644 --- a/pkg/custom/readable_errors_test.go +++ b/pkg/custom/readable_errors_test.go @@ -57,16 +57,16 @@ func TestReadableAPIErrorSummaryDoesNotEchoResponseDetails(t *testing.T) { code string want string }{ - {"authentication", http.StatusUnauthorized, "invalid_api_key", "Authentication failed."}, - {"permissions", http.StatusForbidden, "synthetic-private-code", "Access denied."}, - {"not found", http.StatusNotFound, "synthetic-private-code", "not found or is not available"}, + {"authentication", http.StatusUnauthorized, "invalid_api_key", "Check API key, organization and project."}, + {"permissions", http.StatusForbidden, "synthetic-private-code", "Check your key's permissions."}, + {"not found", http.StatusNotFound, "synthetic-private-code", "Check the resource or model ID.\nCheck your key's access to it."}, {"invalid argument", http.StatusBadRequest, "synthetic-private-code", "The API rejected the request."}, {"unprocessable argument", http.StatusUnprocessableEntity, "synthetic-private-code", "The API rejected the request."}, {"quota", http.StatusTooManyRequests, "insufficient_quota", "API usage or billing limit reached."}, - {"rate", http.StatusTooManyRequests, "synthetic-private-code", "Rate limit reached."}, + {"rate", http.StatusTooManyRequests, "synthetic-private-code", "Wait before trying again."}, {"conflict", http.StatusConflict, "synthetic-private-code", "Check its current status."}, - {"request timeout", http.StatusRequestTimeout, "synthetic-private-code", "check its status before repeating it"}, - {"gateway timeout", http.StatusGatewayTimeout, "synthetic-private-code", "check its status before repeating it"}, + {"request timeout", http.StatusRequestTimeout, "synthetic-private-code", "Check its status before trying again."}, + {"gateway timeout", http.StatusGatewayTimeout, "synthetic-private-code", "Check its status before trying again."}, {"server", http.StatusInternalServerError, "synthetic-private-code", "The API is temporarily unavailable."}, {"unknown status", 499, "synthetic-private-code", "The API could not complete the request."}, } { @@ -83,7 +83,7 @@ func TestReadableAPIErrorSummaryDoesNotEchoResponseDetails(t *testing.T) { var out bytes.Buffer require.NoError(t, ShowCommandError(root, fmt.Errorf("synthetic-wrapper: %w", apierr), &out)) require.Contains(t, out.String(), test.want) - require.Contains(t, out.String(), fmt.Sprintf("Request failed (%d", test.status)) + require.Contains(t, out.String(), fmt.Sprintf("HTTP %d", test.status)) require.Contains(t, out.String(), "--format-error json") assertReadableErrorContainsNoPrivateDetails(t, out.String()) if test.status == http.StatusUnauthorized { @@ -93,6 +93,25 @@ func TestReadableAPIErrorSummaryDoesNotEchoResponseDetails(t *testing.T) { } } +func TestReadableAPIErrorHeaderHandlesUnknownOrMissingStatus(t *testing.T) { + for _, tc := range []struct { + status int + header string + }{ + {http.StatusInternalServerError, "HTTP 500: Internal Server Error.\n"}, + {599, "HTTP 599.\n"}, + {0, "The API could not complete the request.\n"}, + } { + t.Run(fmt.Sprint(tc.status), func(t *testing.T) { + var out bytes.Buffer + require.NoError(t, ShowCommandError(readableErrorTestCommand(t), &openai.Error{StatusCode: tc.status}, &out)) + require.True(t, strings.HasPrefix(out.String(), tc.header), out.String()) + require.NotContains(t, out.String(), "HTTP 0") + require.NotContains(t, out.String(), ": .") + }) + } +} + func TestReadableErrorFormatRouting(t *testing.T) { for _, test := range []struct { name string @@ -258,7 +277,7 @@ func TestReadableStreamErrorSummaryAndCancellation(t *testing.T) { flags []string want string }{ - {"wrapped stream", fmt.Errorf("synthetic-private-wrapper: %w", streamerr), nil, "The response stream failed. Output may be incomplete."}, + {"wrapped stream", fmt.Errorf("synthetic-private-wrapper: %w", streamerr), nil, "The response stream failed.\nOutput may be incomplete."}, {"canceled stream", errors.Join(streamerr, context.Canceled), nil, "Request canceled.\n"}, {"canceled structured stream", errors.Join(streamerr, context.Canceled), []string{"--format-error", "json"}, "Request canceled."}, } { @@ -283,7 +302,7 @@ func TestStructuredStreamErrorWithoutJSONData(t *testing.T) { require.NoError(t, ShowCommandError(readableErrorTestCommand(t, "--format-error", "json"), failure, &out)) var payload map[string]any require.NoError(t, json.Unmarshal(out.Bytes(), &payload)) - require.Contains(t, payload["message"], "The response stream failed. Output may be incomplete.") + require.Equal(t, "The response stream failed. Output may be incomplete.\nFor API error details, add --format-error json.", payload["message"]) require.NotContains(t, payload, "status_code") assertReadableErrorContainsNoPrivateDetails(t, out.String()) }) diff --git a/pkg/custom/readable_request_id_test.go b/pkg/custom/readable_request_id_test.go new file mode 100644 index 00000000..8105bf77 --- /dev/null +++ b/pkg/custom/readable_request_id_test.go @@ -0,0 +1,216 @@ +package custom + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/openai/openai-go/v3" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" +) + +func requestIDError(t *testing.T, status int, id string) *openai.Error { + t.Helper() + header := make(http.Header) + header.Set("X-Request-ID", id) + apierr := &openai.Error{StatusCode: status, Response: &http.Response{Header: header}} + require.NoError(t, json.Unmarshal([]byte(`{"message":"synthetic body detail","type":"server_error","future_field":{"sequence":9007199254740993}}`), apierr)) + return apierr +} + +func requestIDErrorCommand(t *testing.T, args ...string) *cli.Command { + t.Helper() + root := &cli.Command{ + Name: "openai", Writer: io.Discard, ErrWriter: io.Discard, HideHelpCommand: true, + Flags: []cli.Flag{ + &cli.StringFlag{Name: "format", Value: "auto"}, + &cli.StringFlag{Name: "format-error", Value: "auto"}, + &cli.StringFlag{Name: "transform-error"}, + &cli.BoolFlag{Name: "quiet"}, + }, + Action: func(context.Context, *cli.Command) error { return nil }, + } + require.NoError(t, root.Run(t.Context(), append([]string{"openai"}, args...))) + return root +} + +func TestReadableAPIRequestIDPlacementAndSelectedError(t *testing.T) { + for _, wrapper := range []string{"direct", "wrapped", "joined", "joined exit"} { + t.Run(wrapper, func(t *testing.T) { + apierr := requestIDError(t, http.StatusInternalServerError, "req_abc123") + var failure error = apierr + switch wrapper { + case "wrapped": + failure = fmt.Errorf("synthetic-private-wrapper: %w", apierr) + case "joined": + failure = errors.Join(errors.New("synthetic-private-wrapper"), apierr) + case "joined exit": + failure = errors.Join(cli.Exit("synthetic-private-exit", 27), apierr) + } + var out bytes.Buffer + require.NoError(t, ShowCommandError(requestIDErrorCommand(t), failure, &out)) + require.True(t, strings.HasPrefix(out.String(), "HTTP 500: Internal Server Error.\nRequest ID: req_abc123\nThe API is temporarily unavailable.\n"), out.String()) + require.Equal(t, 1, strings.Count(out.String(), "Request ID:")) + require.NotContains(t, out.String(), "synthetic-private") + require.NotContains(t, out.String(), "synthetic body detail") + require.ErrorIs(t, failure, apierr) + require.Equal(t, http.StatusInternalServerError, apierr.StatusCode) + if wrapper == "joined exit" { + var exit cli.ExitCoder + require.ErrorAs(t, failure, &exit) + require.Equal(t, 27, exit.ExitCode()) + } + }) + } + first := requestIDError(t, http.StatusForbidden, "first.opaque-ID_42") + second := requestIDError(t, http.StatusInternalServerError, "second-identifier") + for _, pair := range [][2]*openai.Error{{first, second}, {second, first}} { + var out bytes.Buffer + require.NoError(t, ShowCommandError(requestIDErrorCommand(t), errors.Join(pair[0], pair[1]), &out)) + want := fmt.Sprintf("HTTP %d: %s.\nRequest ID: %s\n", pair[0].StatusCode, http.StatusText(pair[0].StatusCode), pair[0].Response.Header.Get("X-Request-ID")) + require.True(t, strings.HasPrefix(out.String(), want), out.String()) + require.NotContains(t, out.String(), pair[1].Response.Header.Get("X-Request-ID")) + } +} + +func TestReadableAPIRequestIDValidationAndBounds(t *testing.T) { + for _, tc := range []struct { + name, id string + accepted bool + }{ + {"prefixed", "req_abc123", true}, + {"opaque", "opaque.ID-42_value", true}, + {"UUID", "d25f3504-5ef8-408c-a5d5-0349c29c899a", true}, + {"256 bytes", strings.Repeat("a", 256), true}, + {"257 bytes", strings.Repeat("a", 257), false}, + {"empty", "", false}, + {"leading space", " req_abc123", false}, + {"trailing space", "req_abc123 ", false}, + {"newline", "req_abc123\nforged line", false}, + {"carriage return", "req_abc123\rforged", false}, + {"tab", "req_abc123\tforged", false}, + {"escape", "req_abc123\x1b[2J", false}, + {"NUL", "req_abc123\x00", false}, + {"bidi", "req_abc123\u202e", false}, + {"Unicode", "req_é", false}, + {"invalid UTF-8", "req_\xff", false}, + {"URL", "https://synthetic.invalid/?token=private", false}, + {"path", "/synthetic/private/file", false}, + {"assignment", "token=synthetic-private", false}, + {"colon", "req:abc123", false}, + } { + t.Run(tc.name, func(t *testing.T) { + root := requestIDErrorCommand(t) + var baseline, output bytes.Buffer + require.NoError(t, ShowCommandError(root, requestIDError(t, http.StatusBadRequest, ""), &baseline)) + require.NoError(t, ShowCommandError(root, requestIDError(t, http.StatusBadRequest, tc.id), &output)) + if !tc.accepted { + require.Equal(t, baseline.String(), output.String(), "invalid IDs must be omitted entirely") + return + } + line := "Request ID: " + tc.id + "\n" + require.True(t, strings.HasPrefix(output.String(), "HTTP 400: Bad Request.\n"+line), output.String()) + require.Equal(t, baseline.String(), strings.Replace(output.String(), line, "", 1)) + }) + } +} + +func TestReadableAPIRequestIDNilAndMissingMetadata(t *testing.T) { + require.Empty(t, readableAPIRequestID(nil)) + for _, apierr := range []*openai.Error{ + {}, {Request: &http.Request{}}, {Response: &http.Response{}}, + {Response: &http.Response{Header: http.Header{"Unrelated-Id": {"synthetic-private"}}}}, + } { + require.Empty(t, readableAPIRequestID(apierr)) + } + var out bytes.Buffer + require.NoError(t, ShowCommandError(requestIDErrorCommand(t), nil, &out)) + require.Empty(t, out.String()) + // Only the API error's actual Request governs reflection, not Response.Request. + apierr := requestIDError(t, http.StatusBadRequest, "req_returned") + apierr.Response.Request = &http.Request{Header: http.Header{"X-Request-Id": {"different-request"}}} + require.Equal(t, "req_returned", readableAPIRequestID(apierr)) + apierr.Request = &http.Request{} + require.Equal(t, "req_returned", readableAPIRequestID(apierr)) +} + +func TestReadableAPIRequestIDSuppressesCallerHeaderReflection(t *testing.T) { + for _, key := range []string{"X-Request-ID", "x-request-id", "x-ReQuEsT-Id"} { + for _, values := range [][]string{nil, {""}, {"synthetic-private-header"}} { + apierr := requestIDError(t, http.StatusBadRequest, "req_returned") + apierr.Request = &http.Request{Header: http.Header{key: values}} + var baseline, output bytes.Buffer + root := requestIDErrorCommand(t) + require.NoError(t, ShowCommandError(root, requestIDError(t, http.StatusBadRequest, ""), &baseline)) + require.NoError(t, ShowCommandError(root, apierr, &output)) + require.Equal(t, baseline.String(), output.String(), "request header key %q must suppress the returned ID", key) + } + } + apierr := requestIDError(t, http.StatusBadRequest, "req_returned") + request, err := http.NewRequest(http.MethodGet, "https://synthetic-private.invalid/?token=synthetic-private", nil) + require.NoError(t, err) + request.Header = http.Header{ + "Authorization": {"Bearer synthetic-private"}, "Cookie": {"synthetic-private"}, + "X-Request-ID-Other": {"synthetic-private"}, + } + apierr.Request = request + apierr.Response.Header.Set("Set-Cookie", "synthetic-private") + apierr.Response.Header.Set("Location", "https://synthetic-private.invalid") + var out bytes.Buffer + require.NoError(t, ShowCommandError(requestIDErrorCommand(t), apierr, &out)) + require.Contains(t, out.String(), "Request ID: req_returned\n") + require.NotContains(t, out.String(), "synthetic-private") +} + +func TestReadableAPIRequestIDQuietAndMachinePayloads(t *testing.T) { + apierr := requestIDError(t, http.StatusBadRequest, "req_header_only") + var ordinary, quiet bytes.Buffer + require.NoError(t, ShowCommandError(requestIDErrorCommand(t), apierr, &ordinary)) + quietRoot := requestIDErrorCommand(t, "--quiet") + require.True(t, quietRoot.Bool("quiet")) + require.NoError(t, ShowCommandError(quietRoot, apierr, &quiet)) + require.Equal(t, ordinary.String(), quiet.String()) + require.Contains(t, quiet.String(), "Request ID: req_header_only\n") + for _, args := range [][]string{ + {"--format-error", "json"}, {"--format-error", "jsonl"}, {"--format-error", "yaml"}, + {"--format-error", "raw"}, {"--format-error", "pretty"}, {"--format-error", "explore"}, + {"--format", "json"}, {"--quiet", "--format-error", "json"}, + {"--transform-error", "message"}, {"--format-error", "text", "--transform-error", "message"}, + } { + t.Run(strings.Join(args, " "), func(t *testing.T) { + root := requestIDErrorCommand(t, args...) + var baseline, output bytes.Buffer + require.NoError(t, ShowCommandError(root, requestIDError(t, http.StatusBadRequest, ""), &baseline)) + require.NoError(t, ShowCommandError(root, apierr, &output)) + require.Equal(t, baseline.String(), output.String(), "returned metadata must not alter selected error data") + require.NotContains(t, output.String(), "req_header_only") + }) + } +} + +func TestReadableAPIRequestIDCancellationAndSinkFailures(t *testing.T) { + apierr := requestIDError(t, http.StatusBadRequest, "req_abc123") + for _, failure := range []error{ + errors.Join(apierr, context.Canceled), + fmt.Errorf("synthetic-private: %w", errors.Join(context.Canceled, apierr)), + } { + var out bytes.Buffer + require.NoError(t, ShowCommandError(requestIDErrorCommand(t), failure, &out)) + require.Equal(t, "Request canceled.\n", out.String()) + require.ErrorIs(t, failure, context.Canceled) + require.ErrorIs(t, failure, apierr) + } + cause := errors.New("synthetic diagnostic sink failure") + for _, args := range [][]string{nil, {"--quiet"}, {"--format-error", "json"}} { + root := requestIDErrorCommand(t, args...) + require.ErrorIs(t, ShowCommandError(root, apierr, errorSink{cause}), cause) + require.ErrorIs(t, ShowCommandError(root, apierr, shortErrorSink{}), io.ErrShortWrite) + } +} diff --git a/pkg/custom/resource_summaries.go b/pkg/custom/resource_summaries.go index e8e4a9ba..ff3f9c5b 100644 --- a/pkg/custom/resource_summaries.go +++ b/pkg/custom/resource_summaries.go @@ -8,7 +8,7 @@ import ( "github.com/tidwall/gjson" ) -const resourceSummaryHint = "Summary; use --format json for full data." +const resourceSummaryHint = "Full data: --format json." // Resource projections apply only to readable results without field extraction. // Report omissions so each response or list can explain them once. diff --git a/pkg/custom/resource_summaries_test.go b/pkg/custom/resource_summaries_test.go index 2844d87d..e016f6fe 100644 --- a/pkg/custom/resource_summaries_test.go +++ b/pkg/custom/resource_summaries_test.go @@ -14,7 +14,7 @@ import ( ) func TestResourceSummaryNoticePreservesFinalErrors(t *testing.T) { - const hint = "Summary; use --format json for full data." + const hint = "Full data: --format json." item := outputJSON{gjson.Parse(`{"id":"file_summary","object":"file","created_at":17}`)} upstreamErr := errors.New("synthetic page failure") writeErr := errors.New("synthetic notice failure") diff --git a/pkg/custom/save_final_image_stream.go b/pkg/custom/save_final_image_stream.go index 9cd96ac6..a692d3bd 100644 --- a/pkg/custom/save_final_image_stream.go +++ b/pkg/custom/save_final_image_stream.go @@ -42,7 +42,7 @@ func saveFinalImageStream[T any](ctx context.Context, source jsonview.Iterator[T switch event.Get("type").String() { case "image_generation.partial_image", "image_edit.partial_image": index := event.Get("partial_image_index") - if progressUnavailable || plan.partialImages < 1 || plan.partialImages > 3 || + if quietOutput(ctx) || progressUnavailable || plan.partialImages < 1 || plan.partialImages > 3 || index.Type != gjson.Number || index.Float() != float64(index.Int()) || index.Int() < 0 || index.Int() >= plan.partialImages || seen[index.Int()] { continue diff --git a/scripts/check-image-picker-integration.py b/scripts/check-image-picker-integration.py index f05a8cd4..e27f4fe7 100644 --- a/scripts/check-image-picker-integration.py +++ b/scripts/check-image-picker-integration.py @@ -301,7 +301,7 @@ def direct_pipe(name, arguments, data=b'', expected_status=1, expected_body=None elif action == 'api-error': terminal.finish(1) assert '401 Unauthorized' in terminal.text(), terminal.text() - assert 'Authentication failed.' in terminal.text(), terminal.text() + assert 'Check API key, organization and project.' in terminal.text(), terminal.text() else: wait_resumed(terminal, mark) if action == 'two-generations': diff --git a/scripts/check-image-progress.py b/scripts/check-image-progress.py index 935d1e96..a97eee2d 100755 --- a/scripts/check-image-progress.py +++ b/scripts/check-image-progress.py @@ -19,7 +19,7 @@ binaries = {'custom': str(pathlib.Path(custom_binary).resolve()), 'main': str(pathlib.Path(main_binary).resolve()), 'resize': str(pathlib.Path(custom_binary).resolve())} results = {} apple_font_names = {'apple-on', 'apple-persisted-on', 'apple-no-color', 'apple-error-json'} -gated_names = {'generate', 'edit', 'stdin', 'iterm', 'explicit-on', 'failure', 'apple-auto'} | apple_font_names +gated_names = {'generate', 'edit', 'stdin', 'iterm', 'explicit-on', 'failure', 'error-json', 'apple-auto'} | apple_font_names darwin = sys.platform == 'darwin' gates = tempfile.TemporaryDirectory(prefix='image-progress-gates-') for binary, test, prefix in [('custom', 'TestImageProgressTerminal', 'PROGRESS'), ('main', 'TestMainImageProgressTerminal', 'MAIN-PROGRESS'), ('resize', 'TestSavedImageFallbackResizeTerminal', 'FONT-FALLBACK-RESIZE')]: @@ -56,6 +56,7 @@ def observe(captured): 'apple-persisted-on':1 if darwin else 2, 'apple-no-color':1 if darwin else 0, 'apple-error-json':1 if darwin else 2, 'ci':0, 'api':0, 'malformed':0, 'malformed-error-json':0, 'failure':2, 'final-first':0, + 'quiet':0, 'quiet-malformed':0, 'quiet-api':0, 'error-json':2, 'quiet-error-json':0, } for name, count in expected.items(): start = text.index(prefix + '-CASE ' + name + '\r\n') @@ -72,7 +73,7 @@ def observe(captured): progress = section[:final_send] assert '▀' not in progress and 'Saved image:' not in progress and 'The image is saved' not in progress assert 'Sharp progress preview unavailable;' in progress - if binary == 'main' and name == 'api': + if binary == 'main' and name in ('api', 'quiet-api'): assert len(re.findall(r'"partial_image_index":\s*0', section)) == 2 assert '"image_generation.completed"' in section and 'Saved image:' not in section if binary == 'custom' and name == 'apple-auto': @@ -83,6 +84,11 @@ def observe(captured): assert '\x1b_G' not in section and '\x1b]1337;' not in section and '▀' not in section if binary == 'main' and name == 'explicit-on': assert section.count('\x1b_G') == 3, (name, 'expected two previews and the final image') + if binary == 'main' and name in ('quiet', 'quiet-malformed'): + before_save, saved = section.split('Saved image:', 1) + assert '\x1b_G' not in before_save and '\x1b_G' in saved, (name, 'quiet must keep only the final preview') + if binary == 'main' and name == 'quiet-error-json': + assert '\x1b_G' not in section and 'Saved image:' not in section, (name, 'quiet failure must not emit a partial preview') assert '\x1b_G' in text and '\x1b]1337;' in text results[binary] = {'passed': len(expected), 'exit_code':code, 'bytes':len(captured), 'skipped':False} (root / 'pty-results.json').write_text(json.dumps(results, indent=2) + '\n') diff --git a/scripts/demos/README.md b/scripts/demos/README.md index 52d814e2..a5f5be38 100644 --- a/scripts/demos/README.md +++ b/scripts/demos/README.md @@ -61,7 +61,7 @@ the feature branch. Both commands use `openai files list --max-items 2`. The records have distinct IDs, filenames and sizes, with seven fields each so the complete before output fits in the 90-column by 24-row capture. The after scene retains ID, filename, purpose, bytes and status, followed by -one `Summary; use --format json for full data.` notice after the list. +one `Full data: --format json.` notice after the list. Two more scenes retrieve the first file before and after the change. The last scene uses `openai --format json files retrieve file_training`, demonstrating diff --git a/scripts/demos/output/validate.py b/scripts/demos/output/validate.py index 18391366..07993745 100644 --- a/scripts/demos/output/validate.py +++ b/scripts/demos/output/validate.py @@ -2,6 +2,7 @@ """Validate parser outcomes, original records, arrival order, and output routing.""" import json import pathlib +import re import sys @@ -50,9 +51,12 @@ def main(): check((controls / "quiet.txt").read_bytes() == expected_data, "quiet discarded or changed selected data") check((controls / "quiet.stderr").read_bytes() == b"", "quiet printed optional feedback") check((controls / "model.txt").read_bytes() == expected_data, "verbose contaminated stdout") - expected_details = ("Summary; use --format json for full data.\n" + expected_details = ("Full data: --format json.\n" "Command: models retrieve\nFormat option: auto\nCommand result: completed\n") - check((controls / "details.txt").read_text() == expected_details, "verbose stderr contract changed") + details = (controls / "details.txt").read_text() + elapsed = re.findall(r"^Elapsed: (?:(?:\d+h)?(?:\d+m)?\d+(?:\.\d{1,3})?s|\d+ms)\n", details, re.MULTILINE) + check(len(elapsed) == 1, "verbose must report one nonnegative elapsed duration") + check(details.replace(elapsed[0], "", 1) == expected_details, "verbose stderr contract changed") model_request = {"method": "GET", "path": "/v1/models/demo-model"} stream_request = {"method": "POST", "path": "/v1/responses", From 59cf52ff171f16bcfefa7beaaa57435dd158e0cb Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Thu, 8 Oct 2026 12:26:17 -0700 Subject: [PATCH 35/44] Match output validation assets to current diagnostics --- cmd/openai/deterministic_output_unix_test.go | 2 +- scripts/check-image-picker-integration.py | 2 +- scripts/check-image-vscode.py | 2 +- scripts/demos/list-get-results.tape | 2 +- scripts/demos/record.sh | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/openai/deterministic_output_unix_test.go b/cmd/openai/deterministic_output_unix_test.go index 75416707..920bb85f 100644 --- a/cmd/openai/deterministic_output_unix_test.go +++ b/cmd/openai/deterministic_output_unix_test.go @@ -57,7 +57,7 @@ func TestMainDispatchOutputSummaryKeepsStderrPipeFailure(t *testing.T) { } else if err != nil { t.Fatal(err) } - if ctx.Err() != nil || code != tc.code || !strings.Contains(output.String(), "file_synthetic") || strings.Contains(output.String(), "Summary;") { + if ctx.Err() != nil || code != tc.code || !strings.Contains(output.String(), "file_synthetic") || strings.Contains(output.String(), "Full data: --format json.") { t.Fatalf("stderr failure changed data/status: exit=%d stdout=%q context=%v", code, output.String(), ctx.Err()) } }) diff --git a/scripts/check-image-picker-integration.py b/scripts/check-image-picker-integration.py index e27f4fe7..a5def3cd 100644 --- a/scripts/check-image-picker-integration.py +++ b/scripts/check-image-picker-integration.py @@ -300,7 +300,7 @@ def direct_pipe(name, arguments, data=b'', expected_status=1, expected_body=None terminal.finish(130) elif action == 'api-error': terminal.finish(1) - assert '401 Unauthorized' in terminal.text(), terminal.text() + assert 'HTTP 401: Unauthorized.' in terminal.text(), terminal.text() assert 'Check API key, organization and project.' in terminal.text(), terminal.text() else: wait_resumed(terminal, mark) diff --git a/scripts/check-image-vscode.py b/scripts/check-image-vscode.py index 1bfac3fe..f3f21030 100644 --- a/scripts/check-image-vscode.py +++ b/scripts/check-image-vscode.py @@ -440,7 +440,7 @@ def interrupt_after_partial(terminal, case): tty('cancel-after-partial', stream, case=Response('stream-ok', normal, True), interact=interrupt_after_partial, expected=130, images=1, saved=0, stderr_tty=True) _, _, _, error = tty('api-error-tty', generate, case=Response('error', normal), expected=1, images=0, saved=0) - require(b'400 Bad Request' in error and b'--format-error json' in error, 'readable API error lost') + require(b'HTTP 400: Bad Request.' in error and b'--format-error json' in error, 'readable API error lost') tty('format-text-saving', ['--format', 'text', *generate]) for name, flags in [('format-json', ['--format', 'json']), ('format-jsonl', ['--format', 'jsonl']), diff --git a/scripts/demos/list-get-results.tape b/scripts/demos/list-get-results.tape index 36582fc1..74a59363 100644 --- a/scripts/demos/list-get-results.tape +++ b/scripts/demos/list-get-results.tape @@ -60,7 +60,7 @@ Type "# After: list/get summaries | same file" Enter Type "openai files retrieve file_training" Enter -Wait+Screen /Summary; use --format json for full data./ +Wait+Screen /Full data: --format json\./ Sleep 500ms Screenshot after-retrieve.png Sleep 3s diff --git a/scripts/demos/record.sh b/scripts/demos/record.sh index 8e85b6e4..bd833c75 100644 --- a/scripts/demos/record.sh +++ b/scripts/demos/record.sh @@ -119,7 +119,7 @@ for demo_scene in "${demo_scenes[@]}"; do case "$demo_scene" in before|before-retrieve) /usr/bin/grep -Fq 'Created at: 1704067200' "$demo_output/$demo_scene.txt";; after|after-retrieve) - test "$(/usr/bin/grep -Fc 'Summary; use --format json for full data.' "$demo_output/$demo_scene.txt")" -eq 1 + test "$(/usr/bin/grep -Fc 'Full data: --format json.' "$demo_output/$demo_scene.txt")" -eq 1 if /usr/bin/grep -Fq 'Created at:' "$demo_output/$demo_scene.txt"; then echo "Unexpected $demo_scene output: creation timestamp was not summarized" >&2 exit 1 From 61e8db6b7fe85b5c1793396f6be6390de99c1d42 Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Thu, 8 Oct 2026 12:37:40 -0700 Subject: [PATCH 36/44] Check image preference receipts on stderr --- scripts/check-image-vscode.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/scripts/check-image-vscode.py b/scripts/check-image-vscode.py index f3f21030..2aeeb758 100644 --- a/scripts/check-image-vscode.py +++ b/scripts/check-image-vscode.py @@ -220,7 +220,7 @@ def check_saved(home, image, count=1): def tty(name, arguments, case=None, extra=None, expected=0, images=1, shape=normal_shape, width=80, height=24, interact=None, home_name=None, input_data=None, saved=1, stderr_tty=False, - executable=None): + executable=None, success_stderr=b''): executable = executable or binary home, env = environment(home_name or name, extra) server.case = case or Response('ok', normal) @@ -245,7 +245,7 @@ def tty(name, arguments, case=None, extra=None, expected=0, images=1, shape=norm require(case.requests[0].get('stream') is True and case.requests[0].get('partial_images') == 2, 'progress request fields changed') if expected == 0: - require(not error, f'unexpected stderr: {error!r}') + require(error == success_stderr, f'unexpected stderr: {error!r}') elif not stderr_tty: require(error and b'\x1b]1337;' not in error, f'error missing from stderr: {error!r}') if expected == 130: @@ -359,7 +359,11 @@ def repeated_picker(replace): if name == 'unknown-color': require('▀'.encode() in raw, 'unknown capability lost the existing color-block fallback') - tty('remember-off', ['images', 'inline', 'off'], home_name='preferences', images=0, saved=0) + _, _, settings_stdout, _ = tty('remember-off', ['images', 'inline', 'off'], + home_name='preferences', images=0, saved=0, success_stderr=( + b'Automatic image previews off.\nOverride once: --inline auto|on|off\n' + b'View a saved image: openai images preview FILE\n')) + require(not settings_stdout, 'local setting receipt contaminated stdout') tty('persisted-off', generate, home_name='preferences', images=0) tty('override-off-with-on', generate + ['--inline', 'on'], home_name='preferences', saved=2) tty('override-off-with-auto', generate + ['--inline', 'auto'], home_name='preferences', saved=3) From b06bae7f58c5ca3fbcfbcbd08a1e05b65245caf2 Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Fri, 9 Oct 2026 00:03:08 -0700 Subject: [PATCH 37/44] Fix Windows output lifecycle fixture cleanup --- cmd/openai/output_lifecycle_windows_test.go | 35 ++++++++++++++++----- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/cmd/openai/output_lifecycle_windows_test.go b/cmd/openai/output_lifecycle_windows_test.go index bb3e34ab..719e4726 100644 --- a/cmd/openai/output_lifecycle_windows_test.go +++ b/cmd/openai/output_lifecycle_windows_test.go @@ -62,18 +62,31 @@ func testWindowsOutputTerminalLifecycle(t *testing.T, scenario string, quiet boo t.Helper() home := t.TempDir() payload := imageGenerationPNG(t) - started, closed := make(chan struct{}), make(chan struct{}) + started, closed, fixtureRelease := make(chan struct{}), make(chan struct{}), make(chan struct{}) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // net/http starts disconnect observation after the request body reaches EOF. + // Read and close it before the parent can send cancellation or viewer input. + body, readErr := io.ReadAll(r.Body) + closeErr := r.Body.Close() + if readErr != nil || closeErr != nil { + t.Errorf("read synthetic request: %v; close: %v", readErr, closeErr) + return + } close(started) - defer close(closed) if strings.HasSuffix(scenario, "cancel") { - <-r.Context().Done() + select { + case <-r.Context().Done(): + close(closed) + case <-fixtureRelease: + // Cleanup must not report a successful client-side source close. + } return } w.Header().Set("Content-Type", "text/event-stream") if scenario == "images-progress" { + defer close(closed) var request map[string]any - if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + if err := json.Unmarshal(body, &request); err != nil { t.Error(err) return } @@ -91,10 +104,18 @@ func testWindowsOutputTerminalLifecycle(t *testing.T, scenario string, quiet boo writeStreamingTextEvent(w, fmt.Sprintf(`{"type":"response.output_text.delta","delta":"synthetic-viewer-%03d","sequence_number":%d}`, i, i)) } w.(http.Flusher).Flush() - <-r.Context().Done() // q must close the source; the fixture never sends EOF. + select { + case <-r.Context().Done(): + close(closed) // q must close the source; the fixture never sends EOF. + case <-fixtureRelease: + // Failure cleanup releases the handler without satisfying the assertion. + } })) - t.Cleanup(server.Close) - t.Cleanup(server.CloseClientConnections) + t.Cleanup(func() { + close(fixtureRelease) + server.CloseClientConnections() + server.Close() + }) args := []string{"models", "list"} if strings.HasPrefix(scenario, "images") { args = []string{"images", "generate", "--prompt", "synthetic native lifecycle", "--output-dir", home, "--name", "final", "--inline", "off"} From 520dc58edc0e341749f456ef085ab3eaa476a2d0 Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Fri, 9 Oct 2026 00:10:46 -0700 Subject: [PATCH 38/44] [files] preserve context in read-only receipt hints and fix Bash paths --- cmd/openai/files_receipt_context_unix_test.go | 165 ++++++++++++++++++ cmd/openai/files_receipt_unix_test.go | 32 +++- cmd/openai/files_workflow_test.go | 71 +++++++- docs/files.md | 37 ++-- internal/autocomplete/bash_assignment_test.go | 10 +- .../autocomplete/literal_file_paths_test.go | 36 ++++ .../shellscripts/bash_autocomplete.bash | 10 +- pkg/custom/file_commands.go | 9 +- pkg/custom/file_invocation_unix_test.go | 2 +- pkg/custom/file_receipt_context.go | 50 ++++++ pkg/custom/file_receipt_context_test.go | 71 ++++++++ pkg/custom/file_results.go | 37 +--- pkg/custom/file_results_test.go | 82 +++------ scripts/demos/files-workflow/README.md | 1 + scripts/demos/files-workflow/validate.py | 3 +- 15 files changed, 498 insertions(+), 118 deletions(-) create mode 100644 cmd/openai/files_receipt_context_unix_test.go create mode 100644 pkg/custom/file_receipt_context.go create mode 100644 pkg/custom/file_receipt_context_test.go diff --git a/cmd/openai/files_receipt_context_unix_test.go b/cmd/openai/files_receipt_context_unix_test.go new file mode 100644 index 00000000..d2694076 --- /dev/null +++ b/cmd/openai/files_receipt_context_unix_test.go @@ -0,0 +1,165 @@ +//go:build !windows + +package main + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/require" +) + +// Execute the receipt through Bash after changing the upload source. The copied +// command must inspect metadata in the same request context without saving bytes. +func TestMainFilesReceiptFollowupPreservesSourceAndContext(t *testing.T) { + python, err := exec.LookPath("python3") + if err != nil { + t.Skip("Python 3 is required for Unix receipt checks") + } + bash, err := exec.LookPath("bash") + if err != nil { + t.Skip("Bash is required for Unix receipt checks") + } + for _, name := range []string{"default", "context before command", "context after command", "empty project", "processing error", "inspection fails", "API key override", "header override", "URL query"} { + t.Run(name, func(t *testing.T) { + work := t.TempDir() + const filename = "upload space.txt" + original := []byte("hello files!\n") + require.NoError(t, os.WriteFile(filepath.Join(work, filename), original, 0o600)) + status := "uploaded" + if name == "processing error" { + status = "error" + } + response := fmt.Sprintf(`{"id":%q,"object":"file","bytes":13,"filename":%q,"purpose":"user_data","status":%q}`, filesWorkflowID, filename, status) + var uploads, inspections, downloads atomic.Int32 + project, organization := "", "" + key := "sk-fake-files-receipt-test" + if strings.HasPrefix(name, "context ") || name == "processing error" { + project, organization = "project-'quoted", "org-one" + } + if name == "API key override" { + key = "sk-fake-private-override" + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("OpenAI-Project") != project || r.Header.Get("OpenAI-Organization") != organization { + t.Errorf("request context changed: project=%q organization=%q", r.Header.Get("OpenAI-Project"), r.Header.Get("OpenAI-Organization")) + } + if r.Header.Get("Authorization") != "Bearer "+key { + t.Error("unexpected synthetic credential selection") + } + switch { + case r.Method == http.MethodPost && r.URL.Path == "/files": + uploads.Add(1) + if err := r.ParseMultipartForm(1 << 20); err != nil { + t.Error(err) + return + } + defer r.MultipartForm.RemoveAll() + file, header, err := r.FormFile("file") + if err != nil { + t.Error(err) + return + } + defer file.Close() + body, err := io.ReadAll(file) + if err != nil || string(body) != string(original) || header.Filename != filename || r.FormValue("purpose") != "user_data" { + t.Error("multipart filename, purpose, or bytes changed") + } + case r.Method == http.MethodGet && r.URL.Path == "/files/"+filesWorkflowID: + inspections.Add(1) + if name == "inspection fails" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = io.WriteString(w, `{"error":{"message":"Synthetic file missing."}}`) + return + } + case r.Method == http.MethodGet && r.URL.Path == "/files/"+filesWorkflowID+"/content": + downloads.Add(1) + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write(original) + return + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, response) + })) + defer server.Close() + inheritedEndpoint := server.URL + environment := map[string]string{} + var flags []string + if project != "" { + flags = []string{"--project=" + project, "--organization=" + organization, "--base-url=" + server.URL} + // A missing copied base URL must fail locally, without contacting another backend. + inheritedEndpoint = "http://127.0.0.1:1" + } + switch name { + case "empty project": + flags = []string{"--project="} + environment["OPENAI_PROJECT_ID"] = "project-inherited" + case "API key override": + flags = []string{"--api-key=" + key} + case "header override": + flags = []string{"--header=X-Synthetic: fake-private-header"} + case "URL query": + flags = []string{"--base-url=" + server.URL + "?token=fake-private-query"} + } + args := []string{"openai", "files", "upload", filename, "--purpose=user_data"} + if name == "context before command" { + args = append(append([]string{"openai"}, flags...), args[1:]...) + } else { + args = append(args, flags...) + } + receipt := runFilesReceiptPTY(t, python, bash, work, inheritedEndpoint, args, environment) + require.Zero(t, receipt.code, "%+v", receipt) + require.Empty(t, receipt.stderr) + require.Contains(t, receipt.stdout, "Uploaded "+filename) + require.EqualValues(t, 1, uploads.Load()) + for _, private := range []string{"sk-fake-", "fake-private-header", "fake-private-query"} { + require.NotContains(t, receipt.stdout, private) + } + _, command, found := strings.Cut(receipt.stdout, "\nInspect it: ") + if name == "API key override" || name == "header override" || name == "URL query" { + require.False(t, found) + require.NotContains(t, receipt.stdout, "Download it:") + return + } + require.True(t, found, receipt.stdout) + require.NotContains(t, command, "--output") + require.NotContains(t, command, " download ") + changed := []byte("local edits after the upload\n") + require.NoError(t, os.WriteFile(filepath.Join(work, filename), changed, 0o600)) + inspection := runFilesReceiptPTYCommand(t, python, bash, work, inheritedEndpoint, strings.TrimSpace(command), nil, environment) + if name == "inspection fails" { + require.NotZero(t, inspection.code) + require.Contains(t, inspection.stderr, "HTTP 404:") + require.Contains(t, inspection.stderr, "Check the resource or model ID.") + require.Empty(t, inspection.stdout) + } else { + require.Zero(t, inspection.code, "%+v", inspection) + require.Empty(t, inspection.stderr) + if name == "processing error" { + require.JSONEq(t, response, inspection.stdout) + } else { + require.Contains(t, inspection.stdout, filesWorkflowID) + } + } + contents, err := os.ReadFile(filepath.Join(work, filename)) + require.NoError(t, err) + require.Equal(t, changed, contents) + require.EqualValues(t, 1, inspections.Load()) + require.Zero(t, downloads.Load()) + entries, err := os.ReadDir(work) + require.NoError(t, err) + require.Len(t, entries, 1, "read-only follow-up must not create a destination") + }) + } +} diff --git a/cmd/openai/files_receipt_unix_test.go b/cmd/openai/files_receipt_unix_test.go index 14816c4d..012530a0 100644 --- a/cmd/openai/files_receipt_unix_test.go +++ b/cmd/openai/files_receipt_unix_test.go @@ -76,19 +76,19 @@ func TestMainFilesReceiptErrorOptionsIndependent(t *testing.T) { require.Empty(t, got.stdout) require.JSONEq(t, apiError, got.stderr, "the complete error stream must remain one JSON value") require.NotContains(t, got.stderr, "Uploaded ") - require.NotContains(t, got.stderr, "Download it:") + require.NotContains(t, got.stderr, "Inspect it:") return } require.Zero(t, got.code, "%+v", got) require.Empty(t, got.stderr) if tc.output == "receipt" { want := "Uploaded upload space.txt (13 B)\nID: " + filesWorkflowID + "\nPurpose: user_data\n\n" + - "Download it: openai files download " + filesWorkflowID + " --output 'upload space.txt'\n" + "Inspect it: openai files get " + filesWorkflowID + "\n" require.Equal(t, want, got.stdout, "error options must not change successful stdout") return } require.NotContains(t, got.stdout, "Uploaded ") - require.NotContains(t, got.stdout, "Download it:") + require.NotContains(t, got.stdout, "Inspect it:") switch tc.output { case "json": require.JSONEq(t, response, got.stdout) @@ -101,14 +101,23 @@ func TestMainFilesReceiptErrorOptionsIndependent(t *testing.T) { } } -func runFilesReceiptPTY(t *testing.T, python, bash, work, endpoint string, args []string) mainDispatchResult { +func runFilesReceiptPTY(t *testing.T, python, bash, work, endpoint string, args []string, environment ...map[string]string) mainDispatchResult { + return runFilesReceiptPTYCommand(t, python, bash, work, endpoint, "", args, environment...) +} + +func runFilesReceiptPTYCommand(t *testing.T, python, bash, work, endpoint, command string, args []string, environment ...map[string]string) mainDispatchResult { t.Helper() binary, err := os.Executable() require.NoError(t, err) + var extraEnv map[string]string + if len(environment) != 0 { + extraEnv = environment[0] + } input, err := json.Marshal(struct { - Binary, Bash, Work, Endpoint string - Args []string - }{binary, bash, work, endpoint, args}) + Binary, Bash, Work, Endpoint, Command string + Args []string + Env map[string]string + }{binary, bash, work, endpoint, command, args, extraEnv}) require.NoError(t, err) ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) defer cancel() @@ -146,6 +155,7 @@ env = { "GOMAXPROCS": "2", "OPENAI_CLI_MAIN_DISPATCH_PROCESS": "1", "OPENAI_API_KEY": "sk-fake-files-receipt-test", "OPENAI_BASE_URL": config["Endpoint"], } +env.update(config["Env"] or {}) selector = selectors.DefaultSelector() masters, slaves, output = [], [], [bytearray(), bytearray()] process = None @@ -161,7 +171,13 @@ try: # Keep Bash alive so the production presenter detects its actual caller. command = [config["Bash"], "--noprofile", "--norc", "-c", '"$@"\nresult=$?\nexit "$result"', "files-receipt-test", - config["Binary"], "-test.run=^TestMainDispatchProcess$", "--", *config["Args"]] + config["Binary"], "-test.run=^TestMainDispatchProcess$", "--", *(config["Args"] or [])] + if config["Command"]: + script = ('dispatch_binary=$1\n' + 'openai() { "$dispatch_binary" -test.run=^TestMainDispatchProcess$ -- openai "$@"; }\n' + + config["Command"] + '\nresult=$?\nexit "$result"') + command = [config["Bash"], "--noprofile", "--norc", "-c", script, + "files-receipt-test", config["Binary"]] process = subprocess.Popen(command, cwd=config["Work"], env=env, stdin=subprocess.DEVNULL, stdout=slaves[0], stderr=slaves[1], start_new_session=True) for slave in slaves: diff --git a/cmd/openai/files_workflow_test.go b/cmd/openai/files_workflow_test.go index ebaee32e..2271a3e5 100644 --- a/cmd/openai/files_workflow_test.go +++ b/cmd/openai/files_workflow_test.go @@ -171,6 +171,36 @@ func TestMainFilesWorkflowUploadStdinCompatibility(t *testing.T) { require.EqualValues(t, 10, requests.Load()) } +func TestMainFilesWorkflowExplicitBinaryStdin(t *testing.T) { + for _, tc := range []struct { + name string + payload []byte + }{ + {"empty", []byte{}}, + {"binary", []byte{0, 255, 128, 27, 13, 10, 26, 0}}, + {"JSON stays file bytes", []byte("{\"file\":\"missing.txt\",\"purpose\":\"batch\",\"future\":null}\n")}, + } { + t.Run(tc.name, func(t *testing.T) { + server, requests := filesWorkflowUploadServer(t, "anonymous_file", tc.payload) + var want mainDispatchResult + for index, route := range []string{"create", "upload"} { + before := requests.Load() + got := runShellFileCommand(t, server, shellFileInput(t, tc.payload), nil, + "files", route, "--file", "-", "--purpose", "user_data") + require.Zero(t, got.code, "%s: %+v", route, got) + require.Empty(t, got.stderr) + require.JSONEq(t, filesWorkflowMetadata, got.stdout) + require.Equal(t, before+1, requests.Load(), "each route must upload stdin exactly once") + if index == 0 { + want = got + } + require.Equal(t, want, got, "the upload alias must preserve the legacy stdin result") + } + require.EqualValues(t, 2, requests.Load()) + }) + } +} + func TestMainFilesWorkflowMachineOutput(t *testing.T) { path := filepath.Join(t.TempDir(), "upload space.txt") payload := []byte("hello files!\n") @@ -261,7 +291,8 @@ func TestMainFilesWorkflowDownloadBytes(t *testing.T) { for index, route := range []string{"content", "download"} { got := runReadableCommand(t, server, "files", route, "--file-id", filesWorkflowID, flag, path) require.Zero(t, got.code, "%+v", got) - require.Empty(t, got.stderr) + require.Empty(t, got.stdout) + require.Equal(t, "Wrote output to: "+path+"\n", got.stderr) if index == 0 { want = got } @@ -277,6 +308,44 @@ func TestMainFilesWorkflowDownloadBytes(t *testing.T) { } } +func TestMainFilesWorkflowDownloadReceiptModes(t *testing.T) { + payload := []byte{0, 255, 128, 27, 13, 10, 26, 0} + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if r.Method != http.MethodGet || r.URL.Path != "/files/"+filesWorkflowID+"/content" { + t.Errorf("download request changed: %s %s", r.Method, r.URL.Path) + } + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write(payload) + })) + defer server.Close() + for _, tc := range []struct { + name string + flags []string + }{ + {"quiet", []string{"--quiet"}}, + {"error JSON", []string{"--format-error", "json"}}, + {"error transform", []string{"--transform-error", "message"}}, + } { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "saved copy's.bin") + for _, route := range []string{"content", "download"} { + before := requests.Load() + args := append([]string{"files", route, filesWorkflowID, "--output", path}, tc.flags...) + got := runReadableCommand(t, server, args...) + require.Equal(t, mainDispatchResult{}, got, "%s %s must save silently", route, tc.name) + body, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, payload, body, "suppressing a receipt must preserve every downloaded byte") + require.Equal(t, before+1, requests.Load(), "each route must download exactly once") + require.NoError(t, os.Remove(path)) + } + }) + } + require.EqualValues(t, 6, requests.Load()) +} + func TestMainFilesWorkflowFailuresPreserveExits(t *testing.T) { var requests atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/docs/files.md b/docs/files.md index cf785e76..d8ded8b3 100644 --- a/docs/files.md +++ b/docs/files.md @@ -71,8 +71,14 @@ openai files upload --purpose user_data -- "-notes.txt" ``` Use `./-` for a file named `-`. The existing `--file -` sentinel requests stdin. -Current piped input can be consumed first as JSON/YAML and rejected before the upload starts. -Use a local file for binary uploads until the shared stdin handling changes. +Explicit stdin selection uploads bytes without interpreting them as whole-request JSON/YAML: + +```sh +cat upload.bin | openai files upload --file - --purpose user_data +``` + +Only one parameter can consume stdin. File paths avoid shell conversion of binary pipelines. +See [shell and file input](shell-file-input.md) for stdin metadata and Windows shell differences. Supply `--purpose` for each upload. The CLI does not select a purpose for you. The example uses `user_data`; select the purpose required by your API workflow. @@ -88,16 +94,19 @@ Uploaded upload space.txt (13 B) ID: file-example Purpose: user_data -Download it: openai files download file-example --output 'upload space.txt' +Inspect it: openai files get file-example ``` The receipt uses the returned filename, size, ID, and purpose. The CLI omits the size when the API does not supply it. -The download suggestion appears for supported shells and unambiguous returned filenames. -On Windows, the CLI omits suggestions for drive-relative names, alternate-stream syntax, reserved device names, and trailing dots or spaces. -Choose an ordinary destination explicitly when the receipt omits the suggestion. +The next command inspects metadata without writing files or overwriting the upload source. +It preserves the executable and selected project, organization, and safe base URL. +Keep the same environment when copying it. +The CLI omits the suggestion when credential, header, or private-path overrides cannot be reproduced safely. +URLs containing user information, queries, or fragments also suppress the suggestion. +Credentials never appear in the suggested command. A successful upload does not mean that downstream processing has finished. -When the returned status is `error`, the receipt suggests inspecting the metadata. +When the returned status is `error`, the suggested inspection requests JSON to retain complete processing details. An upload can return success while its status reports a processing error. `files get` shows metadata, including the full ID and timestamps in UTC. @@ -117,6 +126,9 @@ Pipes, redirected stderr, and the existing `create` and `retrieve` commands keep `--output PATH` writes the exact downloaded bytes to that path. Its parent directory must already exist. An existing destination file is overwritten. +The CLI stages the complete transfer before changing an existing ordinary file. +A failed network transfer leaves that file unchanged. +The final local copy preserves links and permissions, but local write failures can leave partial contents. The `-o` alias remains available: ```sh @@ -129,6 +141,8 @@ Upload receipts and metadata do not appear in downloaded contents. With terminal stdout and no destination, download keeps the existing content behavior. The CLI displays text safely and saves binary contents to a generated local file. Use `--output -` to force contents to stdout. +Successful save receipts go to stderr. Quiet mode and structured error selection suppress these optional receipts. +A receipt-write failure returns a nonzero status while retaining the completed download. ## Scripts and existing commands @@ -190,7 +204,8 @@ Treat unexpected output as unconfirmed, even when the process succeeds. | HTTP 409, timeout, connection loss, or HTTP 5xx | Inspect the file before repeating an upload. The API might have received the request. | | Processing status `error` | Inspect the metadata and status details. Correct the underlying input or workflow before another upload. | | Missing destination directory | Create the parent directory or choose an existing directory. Then download to an unused filename. | -| Interrupted download, disk full, or write failure | Treat the destination as incomplete. Correct the cause before downloading to an unused filename. | +| Failed download transfer | An existing ordinary `--output` file stays unchanged. Redirected and automatic output can remain partial. | +| Disk full or failure during the final local copy | The destination can contain partial bytes. Correct the cause before downloading to an unused filename. | | Missing or malformed upload result, cancellation, or receipt write failure | Check for an accepted upload before repeating it. An absent receipt is not an upload rollback. | If you have the file ID, inspect it without uploading again: @@ -220,8 +235,9 @@ Do not repeat an uncertain upload just to add `--format-error json`. An upload can succeed even when the terminal or pipe cannot display its result. The CLI does not undo an accepted upload when output fails. -Downloads can leave partial files after failure or interruption. -An existing `--output` destination can be truncated when downloading begins. +An explicit ordinary destination remains unchanged until the complete transfer reaches local staging. +Failures during the final local copy can still leave partial files. +Automatic destinations and special paths retain their existing streaming behavior. Shell redirection can truncate its destination before the CLI starts, even when the API request fails. Choose an unused destination when recovering: @@ -233,6 +249,7 @@ The parent directory must already exist. Check the command's exit status before using the download. If you have an expected byte count or checksum, compare it with the downloaded file. The command does not resume a partial download. +See [binary output guarantees](shell-file-input.md#binary-output) for concurrency, interruption, and filesystem limits. Read requests can retry transient failures. The CLI does not automatically replay streamed file uploads. Cancellation stops local work; it does not prove that the API discarded the upload. diff --git a/internal/autocomplete/bash_assignment_test.go b/internal/autocomplete/bash_assignment_test.go index eb691c50..c2f6f26a 100644 --- a/internal/autocomplete/bash_assignment_test.go +++ b/internal/autocomplete/bash_assignment_test.go @@ -21,7 +21,7 @@ func TestBashFileCompletionPreservesAssignments(t *testing.T) { script, err := shellCompletions[CompletionStyleBash](&cli.Command{}, "openai") require.NoError(t, err) dir := t.TempDir() - for _, name := range []string{"assets/logo.png", "assets/long name.png", "assets/a=b.png", "--file=logo.png", "=literal.wav"} { + for _, name := range []string{"assets/logo.png", "assets/long name.png", "assets/a=b.png", "--file=logo.png", "=literal.wav", "@week:notes.txt", "@week::notes.txt", "@week:=notes.txt", ":notes.txt"} { path := filepath.Join(dir, name) require.NoError(t, os.MkdirAll(filepath.Dir(path), 0700)) require.NoError(t, os.WriteFile(path, nil, 0600)) @@ -45,6 +45,14 @@ func TestBashFileCompletionPreservesAssignments(t *testing.T) { {"repeated earlier quoted filename", `openai --format "assets/logo.png" --file="assets/lo`, "assets/lo", []string{"--format", `"assets/logo.png"`, "--file", "=", `"assets/logo.png"`}, []string{"assets/logo.png", "assets/long name.png"}}, {"bash5 repeated equals", "openai --file==", "", []string{"--file", "=="}, []string{"literal.wav"}}, {"bash5 equals filename", "openai --file=assets/a=b", "b", []string{"--file", "=", "assets/a", "=", "b"}, []string{"b.png"}}, + {"separated colon filename", "openai --file @week:no", "no", []string{"--file", "@week", ":", "no"}, []string{"notes.txt"}}, + {"bash3 assigned colon filename", "openai --file=@week:no", "no", []string{"--file=@week", ":", "no"}, []string{"notes.txt"}}, + {"bash5 assigned colon filename", "openai --file=@week:no", "no", []string{"--file", "=", "@week", ":", "no"}, []string{"notes.txt"}}, + {"repeated colon filename", "openai --file @week::no", "no", []string{"--file", "@week", "::", "no"}, []string{"notes.txt"}}, + {"colon and equals filename", "openai --file @week:=no", "no", []string{"--file", "@week", ":", "=", "no"}, []string{"notes.txt"}}, + {"leading colon filename", "openai --file :no", "no", []string{"--file", ":", "no"}, []string{"notes.txt"}}, + {"empty colon suffix", "openai --file @week:", "", []string{"--file", "@week", ":"}, []string{"notes.txt", ":notes.txt", "=notes.txt"}}, + {"spaces around colon", "openai --file @week : no", "no", []string{"--file", "@week", ":", "no"}, nil}, {"separate flag-like value", "openai --file --file=lo", "lo", []string{"--file", "--file", "=", "lo"}, []string{"logo.png"}}, {"separate equals value", "openai --file =", "", []string{"--file", "="}, []string{"literal.wav"}}, {"spaces around equals", "openai --file = assets/lo", "assets/lo", []string{"--file", "=", "assets/lo"}, nil}, diff --git a/internal/autocomplete/literal_file_paths_test.go b/internal/autocomplete/literal_file_paths_test.go index 0d51495f..ee6bd92b 100644 --- a/internal/autocomplete/literal_file_paths_test.go +++ b/internal/autocomplete/literal_file_paths_test.go @@ -1,11 +1,13 @@ package autocomplete import ( + "context" "os" "os/exec" "path/filepath" "strings" "testing" + "time" "github.com/stretchr/testify/require" "github.com/urfave/cli/v3" @@ -53,3 +55,37 @@ printf '%s\n' "${COMPREPLY[@]}" require.NotContains(t, string(out), "@plain.txt") } } + +func TestBashPositionalFileCompletionPreservesColonWord(t *testing.T) { + bash, err := exec.LookPath("bash") + if err != nil { + t.Skip("bash is not available") + } + script, err := shellCompletions[CompletionStyleBash](&cli.Command{}, "openai") + require.NoError(t, err) + directory := t.TempDir() + for _, name := range []string{"@week:notes.txt", "nope.txt"} { + require.NoError(t, os.WriteFile(filepath.Join(directory, name), nil, 0o600)) + } + // Files upload requests literal file completion. Readline splits the colon + // and replaces only "no", but compgen must search the complete local path. + probe := ` +openai() { return 10; } +` + script + ` +COMP_LINE='openai files upload @week:no' +COMP_POINT=${#COMP_LINE} +COMP_WORDS=(openai files upload @week : no) +COMP_CWORD=5 +__openai_bash_autocomplete openai no '' +printf '%s\n' "${COMPREPLY[@]}" +` + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + command := exec.CommandContext(ctx, bash, "--noprofile", "--norc", "-c", probe) + command.Dir = directory + command.Env = []string{"PATH=/usr/bin:/bin", "HOME=" + directory, "LANG=en_US.UTF-8"} + out, err := command.CombinedOutput() + require.NoError(t, ctx.Err(), "Bash completion timed out") + require.NoError(t, err, string(out)) + require.Equal(t, "notes.txt\n", string(out)) +} diff --git a/internal/autocomplete/shellscripts/bash_autocomplete.bash b/internal/autocomplete/shellscripts/bash_autocomplete.bash index ad4c5fbf..2d8bc7eb 100755 --- a/internal/autocomplete/shellscripts/bash_autocomplete.bash +++ b/internal/autocomplete/shellscripts/bash_autocomplete.bash @@ -10,8 +10,8 @@ ____APPNAME___bash_autocomplete() { local word_index previous raw source before after current_raw="$cur" local -a adjacent=() source="${COMP_LINE:0:COMP_POINT}" - # Bash 5 splits '=' into words. Source gaps distinguish assignments from - # separate '=' values. Walk backwards so earlier commands cannot interfere. + # Readline splits '=' and ':' into words. Preserve source gaps when joining + # those words. Walk backwards so earlier commands cannot interfere. for ((word_index = COMP_CWORD; word_index >= 0; word_index--)); do raw="${COMP_WORDS[word_index]}" if [[ $word_index -eq $COMP_CWORD && -n "$source" ]]; then @@ -33,8 +33,8 @@ ____APPNAME___bash_autocomplete() { [[ $word_index -eq $COMP_CWORD ]] && raw="$current_raw" previous=$((word_index - 1)) if [[ ${#completion_args[@]} -gt 0 && "${adjacent[previous]-}" == 1 && - ( ( -n "$raw" && -z "${raw//=/}" ) || - ( -n "${COMP_WORDS[previous]}" && -z "${COMP_WORDS[previous]//=/}" ) ) ]]; then + ( ( -n "$raw" && -z "${raw//[=:]/}" ) || + ( -n "${COMP_WORDS[previous]}" && -z "${COMP_WORDS[previous]//[=:]/}" ) ) ]]; then previous=$((${#completion_args[@]} - 1)) completion_args[previous]+="$raw" else @@ -117,7 +117,7 @@ ____APPNAME___bash_autocomplete() { local current_value="${completion_args[${#completion_args[@]} - 1]}" local assignment="$completions" replacement_prefix="" file_part="${current_value#"$assignment"}" - # Readline replaces the callback word, which can follow the last '='. + # Readline replaces the callback word, after the last '=' or ':'. # Restore only the part that Readline includes in its replacement. if [[ $# -ge 2 && "$current_value" == *"$2" ]]; then replacement_prefix="${current_value%"$2"}" diff --git a/pkg/custom/file_commands.go b/pkg/custom/file_commands.go index 294bd847..f5a3edd6 100644 --- a/pkg/custom/file_commands.go +++ b/pkg/custom/file_commands.go @@ -17,9 +17,11 @@ type fileCommandKey struct{} type fileInvocationKey struct{} type fileInvocation struct { - display string - executable string - goRun bool + display string + executable string + goRun bool + requestArgs []string + omitHint bool } const ( @@ -80,6 +82,7 @@ func configureFileCommands(root *cli.Command) { } } invocation := fileInvocation{display: errorHelpInvocation(command.Root())} + invocation.requestArgs, invocation.omitHint = fileReceiptRequestOptions(command.Root()) invocation.goRun = invocation.display == "go run ./cmd/"+command.Root().Name if len(os.Args) > 0 { invocation.executable = os.Args[0] diff --git a/pkg/custom/file_invocation_unix_test.go b/pkg/custom/file_invocation_unix_test.go index d2bf3ab0..26b15ff0 100644 --- a/pkg/custom/file_invocation_unix_test.go +++ b/pkg/custom/file_invocation_unix_test.go @@ -36,7 +36,7 @@ func fileReceiptForExecutable(t *testing.T, executable, shell string) string { require.NoError(t, err) configureFileCommands(root) require.NoError(t, root.Run(t.Context(), []string{executable, "files", "upload", "--file", "synthetic.txt", "--purpose", "user_data"})) - _, command, found := strings.Cut(receipt.String(), "\nDownload it: ") + _, command, found := strings.Cut(receipt.String(), "\nInspect it: ") require.True(t, found, receipt.String()) return strings.TrimSuffix(command, "\n") } diff --git a/pkg/custom/file_receipt_context.go b/pkg/custom/file_receipt_context.go new file mode 100644 index 00000000..3efbc3d4 --- /dev/null +++ b/pkg/custom/file_receipt_context.go @@ -0,0 +1,50 @@ +package custom + +import ( + "net/url" + "os" + "strings" + "unicode" + "unicode/utf8" + + "github.com/urfave/cli/v3" +) + +// Reproduce request selection without copying credentials into terminal output. +func fileReceiptRequestOptions(root *cli.Command) (args []string, omit bool) { + if root.IsSet("header") { + return nil, true + } + for _, option := range [][2]string{ + {"api-key", "OPENAI_API_KEY"}, + {"admin-api-key", "OPENAI_ADMIN_KEY"}, + {"webhook-secret", "OPENAI_WEBHOOK_SECRET"}, + {mtlsClientCertFileFlag, mtlsClientCertFileEnv}, + {mtlsClientKeyFileFlag, mtlsClientKeyFileEnv}, + } { + // The copied command can reuse inherited environment values. Any effective + // override requires a secret or private path that the receipt must not print. + if root.IsSet(option[0]) && root.String(option[0]) != os.Getenv(option[1]) { + return nil, true + } + } + for _, name := range []string{"project", "organization", "base-url"} { + if !root.IsSet(name) { + continue + } + value := root.String(name) + if !utf8.ValidString(value) || strings.ContainsRune(value, 0) { + return nil, true + } + if name == "base-url" && value != "" { + endpoint, err := url.Parse(value) + if err != nil || strings.IndexFunc(value, unicode.IsControl) >= 0 || endpoint.Host == "" || endpoint.User != nil || + endpoint.RawQuery != "" || endpoint.ForceQuery || endpoint.Fragment != "" || + (endpoint.Scheme != "http" && endpoint.Scheme != "https") { + return nil, true + } + } + args = append(args, "--"+name+"="+value) + } + return args, false +} diff --git a/pkg/custom/file_receipt_context_test.go b/pkg/custom/file_receipt_context_test.go new file mode 100644 index 00000000..576aabdc --- /dev/null +++ b/pkg/custom/file_receipt_context_test.go @@ -0,0 +1,71 @@ +package custom + +import ( + "bytes" + "context" + "io" + "os" + "testing" + + "github.com/openai/openai-cli/internal/requestflag" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +func TestFileReceiptRequestContext(t *testing.T) { + for _, tc := range []struct { + name string + flags []string + want []string + omit bool + }{ + {"environment credentials", nil, nil, false}, + {"request selection", []string{"--project=project-one", "--organization=org-one", "--base-url=https://example.invalid/proxy/v1"}, []string{"--project=project-one", "--organization=org-one", "--base-url=https://example.invalid/proxy/v1"}, false}, + {"explicit empty project", []string{"--project="}, []string{"--project="}, false}, + {"same inherited key", []string{"--api-key=sk-fake-inherited"}, nil, false}, + {"different key", []string{"--api-key=sk-fake-override"}, nil, true}, + {"empty key override", []string{"--api-key="}, nil, true}, + {"admin key override", []string{"--admin-api-key=sk-fake-admin"}, nil, true}, + {"webhook secret override", []string{"--webhook-secret=fake-secret"}, nil, true}, + {"header override", []string{"-H", "Authorization: Bearer fake-secret"}, nil, true}, + {"certificate override", []string{"--mtls-client-cert-file=/synthetic/private/cert.pem"}, nil, true}, + {"private key override", []string{"--mtls-client-key-file=/synthetic/private/key.pem"}, nil, true}, + {"URL user info", []string{"--base-url=https://fake:secret@example.invalid"}, nil, true}, + {"URL query", []string{"--base-url=https://example.invalid/v1?key=fake-secret"}, nil, true}, + {"URL fragment", []string{"--base-url=https://example.invalid/v1#fake-secret"}, nil, true}, + {"URL empty query", []string{"--base-url=https://example.invalid/v1?"}, nil, true}, + {"invalid project", []string{"--project=proj\x00name"}, nil, true}, + } { + t.Run(tc.name, func(t *testing.T) { + for _, name := range []string{"OPENAI_ADMIN_KEY", "OPENAI_WEBHOOK_SECRET", "OPENAI_ORG_ID", "OPENAI_PROJECT_ID", mtlsClientCertFileEnv, mtlsClientKeyFileEnv} { + t.Setenv(name, "") + require.NoError(t, os.Unsetenv(name)) + } + t.Setenv("OPENAI_API_KEY", "sk-fake-inherited") + root := fileWorkflowCommandTree(func(ctx context.Context, _ *cli.Command) error { + invocation := ctx.Value(fileInvocationKey{}).(fileInvocation) + require.Equal(t, tc.want, invocation.requestArgs) + require.Equal(t, tc.omit, invocation.omitHint) + var receipt bytes.Buffer + require.NoError(t, writeFileReceipt(&receipt, gjson.Parse(fileReceiptFixture), "bash", invocation)) + require.Equal(t, !tc.omit, bytes.Contains(receipt.Bytes(), []byte("Inspect it:"))) + for _, secret := range []string{"sk-fake", "fake-secret", "/synthetic/private/"} { + require.NotContains(t, receipt.String(), secret) + } + return nil + }) + root.Writer, root.ErrWriter = io.Discard, io.Discard + root.Flags = []cli.Flag{&cli.StringFlag{Name: "base-url"}, NewRequestHeaderFlag()} + for _, pair := range [][2]string{{"api-key", "OPENAI_API_KEY"}, {"admin-api-key", "OPENAI_ADMIN_KEY"}, {"webhook-secret", "OPENAI_WEBHOOK_SECRET"}, {"organization", "OPENAI_ORG_ID"}, {"project", "OPENAI_PROJECT_ID"}} { + root.Flags = append(root.Flags, &requestflag.Flag[string]{Name: pair[0], Sources: cli.EnvVars(pair[1])}) + } + root.Flags = append(root.Flags, mtlsClientFlags()...) + configureRootRequestFlags(root) + configureTaskCommands(root) + configureFileCommands(root) + args := append([]string{"openai", "files", "upload", "source.txt", "--purpose=user_data"}, tc.flags...) + require.NoError(t, root.Run(t.Context(), args)) + }) + } +} diff --git a/pkg/custom/file_results.go b/pkg/custom/file_results.go index 52cce0c2..ad2d9a05 100644 --- a/pkg/custom/file_results.go +++ b/pkg/custom/file_results.go @@ -3,11 +3,9 @@ package custom import ( "fmt" "io" - "runtime" "strings" "unicode/utf8" - "github.com/openai/openai-cli/internal/imageoutput" "github.com/openai/openai-cli/internal/jsonview" "github.com/openai/openai-cli/internal/readable" "github.com/openai/openai-cli/pkg/transformers" @@ -113,50 +111,31 @@ func writeFileReceipt(out io.Writer, value gjson.Result, shell string, invocatio return err } } - if shell == "" || strings.ContainsRune(id, 0) || !utf8.ValidString(id) { + if shell == "" || invocation.omitHint || strings.ContainsRune(id, 0) || !utf8.ValidString(id) { return nil } - verb, label := "download", "Download it: " - if value.Get("status").Str == "error" { - verb, label = "get", "Inspect it: " - } // Reuse the existing shell quoting contracts, including control characters. - args := []string{"files", verb, id} + args := []string{"files", "get", id} if strings.HasPrefix(id, "-") { - args = []string{"files", verb, "--file-id=" + id} + args = []string{"files", "get", "--file-id=" + id} } - if verb == "get" { + if value.Get("status").Str == "error" { args = append(args, "--format", "json") - } else { - if !fileReceiptDestinationAllowed(filename, runtime.GOOS) { - return nil - } - if filename == "-" { - filename = "./-" - } - args = append(args, "--output", filename) } + // The next workflow step is read-only. Never suggest a destination that could + // overwrite the source or another file when the command is copied later. + args = append(append([]string{}, invocation.requestArgs...), args...) if command := formatImagePickerCommand(args, shell); command != "" { prefix := fileReceiptInvocation(invocation, shell) if prefix == "" { return nil } command = prefix + strings.TrimPrefix(command, "openai") - return readable.WriteText(out, "\n"+label+command) + return readable.WriteText(out, "\nInspect it: "+command) } return nil } -// Limit suggested destinations, without changing accepted upload or download paths. -func fileReceiptDestinationAllowed(filename, goos string) bool { - if filename == "" || !utf8.ValidString(filename) || strings.ContainsRune(filename, 0) || - strings.ContainsAny(filename, `/\`) || filename == "." || filename == ".." { - return false - } - // Reuse the existing portable filename rules only where Windows requires them. - return goos != "windows" || imageoutput.ValidateName(filename) == nil -} - // Keep help's executable selection, using the actual shell for quoted paths. func fileReceiptInvocation(invocation fileInvocation, shell string) string { if !utf8.ValidString(invocation.executable) { diff --git a/pkg/custom/file_results_test.go b/pkg/custom/file_results_test.go index c75447d6..0c258fdf 100644 --- a/pkg/custom/file_results_test.go +++ b/pkg/custom/file_results_test.go @@ -7,7 +7,6 @@ import ( "errors" "io" "os/exec" - "runtime" "strings" "testing" "time" @@ -98,24 +97,20 @@ func TestFileReceiptCommandsPreserveShellArguments(t *testing.T) { } for _, tc := range []struct { id, filename string - windowsHint bool }{ - {"file-example", "upload space.txt", true}, - {"-file-example", "-notes.txt", true}, - {"file-example", "-", true}, - {"file-'quote", "an owner's $HOME `literal` $(printf BAD) file.txt", true}, - {"file-example", "@literal.txt", true}, - {"file-example", "line\nwith\tcontrols\x1b.txt", false}, + {"file-example", "upload space.txt"}, + {"-file-example", "-notes.txt"}, + {"file-example", "-"}, + {"file-'quote", "an owner's $HOME `literal` $(printf BAD) file.txt"}, + {"file-example", "@literal.txt"}, + {"file-example", "line\nwith\tcontrols\x1b.txt"}, } { data, err := json.Marshal(map[string]any{"object": "file", "id": tc.id, "filename": tc.filename, "purpose": "user_data", "bytes": 0}) require.NoError(t, err) var out bytes.Buffer - require.NoError(t, writeFileReceipt(&out, gjson.ParseBytes(data), shell, fileInvocation{display: "openai"})) - _, command, found := strings.Cut(out.String(), "\nDownload it: ") - if runtime.GOOS == "windows" && !tc.windowsHint { - require.False(t, found, "Windows must omit the control-character destination") - continue - } + requestArgs := []string{"--project=project-'one", "--organization=org two", "--base-url=http://127.0.0.1:4010/custom"} + require.NoError(t, writeFileReceipt(&out, gjson.ParseBytes(data), shell, fileInvocation{display: "openai", requestArgs: requestArgs})) + _, command, found := strings.Cut(out.String(), "\nInspect it: ") require.True(t, found) command = strings.TrimSuffix(command, "\n") require.NotContains(t, command, "\n") @@ -129,14 +124,11 @@ func TestFileReceiptCommandsPreserveShellArguments(t *testing.T) { process.Env = []string{"LC_ALL=C"} got, err := process.CombinedOutput() require.NoError(t, err, string(got)) - id, filename := tc.id, tc.filename + id := tc.id if strings.HasPrefix(id, "-") { id = "--file-id=" + id } - if filename == "-" { - filename = "./-" - } - want := []string{"files", "download", id, "--output", filename} + want := append(requestArgs, "files", "get", id) require.Equal(t, strings.Join(want, "\x00")+"\x00", string(got), command) } }) @@ -157,45 +149,19 @@ func TestFileReceiptPropagatesOutputFailuresAndCancellation(t *testing.T) { require.Empty(t, out.String()) } -func TestFileReceiptOmitsAmbiguousDestinations(t *testing.T) { - for _, tc := range []struct{ id, filename string }{ - {"file-example", "../outside.txt"}, - {"file-example", "/dev/stdout"}, - {"file-example", `folder\file.txt`}, - {"file-example", "."}, - {"file-example", ".."}, - {"file-example", "nul\x00.txt"}, - {"file-\x00example", "safe.txt"}, - } { - data, err := json.Marshal(map[string]any{"object": "file", "id": tc.id, "filename": tc.filename, "purpose": "user_data"}) +func TestFileReceiptDoesNotSelectLocalDestinations(t *testing.T) { + for _, filename := range []string{"upload space.txt", "../outside.txt", "/dev/stdout", `folder\file.txt`, ".", "..", "NUL", "C:report.txt", "report.txt:stream", "trailing.", "nul\x00.txt"} { + data, err := json.Marshal(map[string]any{"object": "file", "id": "file-example", "filename": filename, "purpose": "user_data"}) require.NoError(t, err) var out bytes.Buffer require.NoError(t, writeFileReceipt(&out, gjson.ParseBytes(data), "bash", fileInvocation{display: "openai"})) - require.Contains(t, out.String(), "Uploaded ") - require.NotContains(t, out.String(), "Download it:") + _, command, found := strings.Cut(out.String(), "\nInspect it: ") + require.True(t, found) + require.Equal(t, "openai files get file-example\n", command) require.NotContains(t, out.String(), "\x00") } } -func TestFileReceiptDestinationRulesPreservePlatforms(t *testing.T) { - for _, filename := range []string{"", ".", "..", "../outside.txt", "/absolute.txt", `folder\file.txt`, "nul\x00.txt", string([]byte{0xff})} { - for _, goos := range []string{"windows", "linux", "darwin"} { - require.False(t, fileReceiptDestinationAllowed(filename, goos), "%s %q", goos, filename) - } - } - for _, filename := range []string{"C:report.txt", "report.txt:stream", "NUL", "CON.txt", "con .txt", "CONIN$", "CONOUT$", "COM1", "LPT9.txt", "COM¹", "LPT²", "trailing.", "trailing ", " ", `quoted".txt`, "star*.txt", "question?.txt", "pipe|.txt", "less<.txt", "greater>.txt", "line\nwith\tcontrols\x1b.txt"} { - require.False(t, fileReceiptDestinationAllowed(filename, "windows"), filename) - for _, goos := range []string{"linux", "darwin"} { - require.True(t, fileReceiptDestinationAllowed(filename, goos), "%s %q", goos, filename) - } - } - for _, filename := range []string{"ordinary.txt", "upload space.txt", "owner's file.txt", "@literal.txt", "-notes.txt", "-", ".hidden", "café-雪.txt", "COM10.txt", "lpt0", "auxiliary.txt"} { - for _, goos := range []string{"windows", "linux", "darwin"} { - require.True(t, fileReceiptDestinationAllowed(filename, goos), "%s %q", goos, filename) - } - } -} - func TestFileReceiptProcessingFailureKeepsInspectionHint(t *testing.T) { for _, filename := range []string{"../outside.txt", "C:report.txt", "NUL"} { data, err := json.Marshal(map[string]any{ @@ -211,10 +177,7 @@ func TestFileReceiptProcessingFailureKeepsInspectionHint(t *testing.T) { } } -func TestFileReceiptPreservesUnixPowerShellFilenames(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("these literal filenames exercise Unix behavior") - } +func TestFileReceiptPowerShellInspectionUsesNoDestination(t *testing.T) { pwsh, err := exec.LookPath("pwsh") if err != nil { t.Skip("PowerShell is unavailable") @@ -224,8 +187,9 @@ func TestFileReceiptPreservesUnixPowerShellFilenames(t *testing.T) { data, err := json.Marshal(map[string]any{"object": "file", "id": "file-example", "filename": filename, "purpose": "user_data"}) require.NoError(t, err) var out bytes.Buffer - require.NoError(t, writeFileReceipt(&out, gjson.ParseBytes(data), "pwsh", fileInvocation{display: "openai"})) - _, command, found := strings.Cut(out.String(), "\nDownload it: ") + requestArgs := []string{"--project=project-'one", "--organization=org two"} + require.NoError(t, writeFileReceipt(&out, gjson.ParseBytes(data), "pwsh", fileInvocation{display: "openai", requestArgs: requestArgs})) + _, command, found := strings.Cut(out.String(), "\nInspect it: ") require.True(t, found) ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) defer cancel() @@ -236,7 +200,7 @@ func TestFileReceiptPreservesUnixPowerShellFilenames(t *testing.T) { require.NoError(t, err, string(output)) var arguments []string require.NoError(t, json.Unmarshal(output, &arguments)) - require.Equal(t, []string{"files", "download", "file-example", "--output", filename}, arguments) + require.Equal(t, append(requestArgs, "files", "get", "file-example"), arguments) }) } } @@ -250,7 +214,7 @@ func TestFileReceiptPreservesSafeExecutableInvocation(t *testing.T) { if status == "error" { require.Contains(t, out.String(), "Inspect it: "+invocation.display+" files get file-example --format json") } else { - require.Contains(t, out.String(), "Download it: "+invocation.display+" files download file-example") + require.Contains(t, out.String(), "Inspect it: "+invocation.display+" files get file-example") } } } diff --git a/scripts/demos/files-workflow/README.md b/scripts/demos/files-workflow/README.md index 2d41dc7e..85447e34 100644 --- a/scripts/demos/files-workflow/README.md +++ b/scripts/demos/files-workflow/README.md @@ -32,6 +32,7 @@ The before scene uses `create`, `retrieve`, and `content`. The after scene uses positional `upload`, `get`, and `download`. Both scenes upload the same 13-byte file with an explicit `user_data` purpose. Both scenes inspect metadata and download text and binary files. +The upload receipt suggests read-only inspection without selecting a download destination. Each scene downloads through an explicit destination and shell redirection. Actual `cmp` commands verify every downloaded byte. diff --git a/scripts/demos/files-workflow/validate.py b/scripts/demos/files-workflow/validate.py index c6f27ab3..5cd6c699 100644 --- a/scripts/demos/files-workflow/validate.py +++ b/scripts/demos/files-workflow/validate.py @@ -43,7 +43,8 @@ def main(): check('Created at: 1700000000' in before, 'baseline timestamp changed') check('Uploaded upload space.txt (13 B)' in after, 'successful upload receipt missing') check('2023-11-14' in after, 'readable metadata timestamp missing') - check('Download it: openai files download file-example' in after, 'download suggestion missing') + check('Inspect it: openai files get file-example' in after, 'read-only inspection suggestion missing') + check('Download it:' not in after, 'upload receipt must not suggest a download destination') print('PASS: 12 exact requests; two multipart filenames, purposes, and upload payloads') print('PASS: eight text/binary downloads match source bytes through destinations and shell redirection') print('PASS: both workflows exit zero; upload receipt, full ID, and readable timestamp appear after') From 2d482e3f62adf109bca5dfbb8bee6565c277701e Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Fri, 9 Oct 2026 00:14:55 -0700 Subject: [PATCH 39/44] [files] isolate copied-command assertions from startup cache files --- cmd/openai/files_receipt_context_unix_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmd/openai/files_receipt_context_unix_test.go b/cmd/openai/files_receipt_context_unix_test.go index d2694076..129dd5b2 100644 --- a/cmd/openai/files_receipt_context_unix_test.go +++ b/cmd/openai/files_receipt_context_unix_test.go @@ -137,6 +137,8 @@ func TestMainFilesReceiptFollowupPreservesSourceAndContext(t *testing.T) { require.NotContains(t, command, " download ") changed := []byte("local edits after the upload\n") require.NoError(t, os.WriteFile(filepath.Join(work, filename), changed, 0o600)) + entriesBefore, err := os.ReadDir(work) + require.NoError(t, err) inspection := runFilesReceiptPTYCommand(t, python, bash, work, inheritedEndpoint, strings.TrimSpace(command), nil, environment) if name == "inspection fails" { require.NotZero(t, inspection.code) @@ -159,7 +161,7 @@ func TestMainFilesReceiptFollowupPreservesSourceAndContext(t *testing.T) { require.Zero(t, downloads.Load()) entries, err := os.ReadDir(work) require.NoError(t, err) - require.Len(t, entries, 1, "read-only follow-up must not create a destination") + require.Equal(t, entriesBefore, entries, "read-only follow-up must not create a destination") }) } } From 5b0fe1fa89d9abf1ba8dea3fe71fa24a17e1dc01 Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Fri, 9 Oct 2026 00:28:45 -0700 Subject: [PATCH 40/44] [files] clarify read-only hints and upload output guarantees --- docs/files.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/files.md b/docs/files.md index d8ded8b3..1b06ea04 100644 --- a/docs/files.md +++ b/docs/files.md @@ -99,7 +99,7 @@ Inspect it: openai files get file-example The receipt uses the returned filename, size, ID, and purpose. The CLI omits the size when the API does not supply it. -The next command inspects metadata without writing files or overwriting the upload source. +The next command inspects metadata without downloading contents or selecting a local destination. It preserves the executable and selected project, organization, and safe base URL. Keep the same environment when copying it. The CLI omits the suggestion when credential, header, or private-path overrides cannot be reproduced safely. @@ -171,7 +171,7 @@ openai files get file-example --transform filename --raw-output Interactive receipts do not appear in JSON, extraction, or piped upload output. `--quiet` suppresses upload receipts and retains the selected metadata output. -`--format-error` and `--transform-error` affect errors only; they do not change successful upload output. +Error-output options do not change a successful upload's receipt or response data. The receipt's suggested command preserves the executable used for the upload. See [reading command results](readable-output.md) for output formats. From cd44152959d582cab48e95c426b90d8de5d1a85c Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Fri, 9 Oct 2026 00:32:30 -0700 Subject: [PATCH 41/44] Route completed manpage receipts through output policy --- cmd/openai/manpages_test.go | 144 ++++++++++++++- docs/deterministic-output.md | 6 +- pkg/custom/manpage_commands.go | 68 ++++++-- pkg/custom/manpage_commands_test.go | 261 ++++++++++++++++++++++++---- 4 files changed, 435 insertions(+), 44 deletions(-) diff --git a/cmd/openai/manpages_test.go b/cmd/openai/manpages_test.go index 61dc546f..b2253313 100644 --- a/cmd/openai/manpages_test.go +++ b/cmd/openai/manpages_test.go @@ -1,7 +1,10 @@ package main import ( + "bytes" "compress/gzip" + "encoding/json" + "fmt" "io" "os" "path/filepath" @@ -9,10 +12,10 @@ import ( "testing" ) -func TestMainManpagesDeepSubgroups(t *testing.T) { +func TestMainDispatchOutputManpagesDeepSubgroups(t *testing.T) { dir := t.TempDir() got := runMainDispatch(t, "bash", "openai", "@manpages", "--text", "-o", dir) - if got.code != 0 || got.stderr != "" { + if got.code != 0 || got.stdout != "" || got.stderr != "Wrote manpages to "+dir+"\n" { t.Fatalf("manpage generation failed: %+v", got) } text, err := os.ReadFile(filepath.Join(dir, "man1", "openai.1")) @@ -59,3 +62,140 @@ func TestMainManpagesDeepSubgroups(t *testing.T) { } } } + +func TestMainDispatchOutputManpageReceiptPolicy(t *testing.T) { + for _, tc := range []struct { + name string + root, local []string + text, gzip, receipt, verbose bool + defaultDirectory bool + }{ + {name: "default gzip and directory", gzip: true, receipt: true, defaultDirectory: true}, + {name: "text only", local: []string{"--text", "--gzip=false"}, text: true, receipt: true}, + {name: "both formats", local: []string{"--text"}, text: true, gzip: true, receipt: true}, + {name: "neither format", local: []string{"--text=false", "--gzip=false"}}, + {name: "quiet verbose", root: []string{"--quiet", "--verbose"}, local: []string{"--text"}, text: true, gzip: true}, + {name: "nested quiet", local: []string{"--text", "--quiet", "--verbose"}, text: true, gzip: true}, + {name: "inherited machine errors", root: []string{"--format", "json"}, local: []string{"--text"}, text: true, gzip: true}, + {name: "explicit machine errors", root: []string{"--format-error", "json"}, local: []string{"--text"}, text: true, gzip: true}, + {name: "extracted errors", root: []string{"--transform-error", "message"}, local: []string{"--text"}, text: true, gzip: true}, + {name: "verbose text override", root: []string{"--format", "json", "--format-error", "text", "--verbose"}, local: []string{"--text"}, text: true, gzip: true, receipt: true, verbose: true}, + } { + t.Run(tc.name, func(t *testing.T) { + work := t.TempDir() + t.Chdir(work) + dir := filepath.Join(work, "manual with spaces") + args := append(append([]string{"openai"}, tc.root...), "@manpages") + args = append(args, tc.local...) + if tc.defaultDirectory { + dir = "man" + } else { + args = append(args, "--output", dir) + } + got := runMainDispatch(t, "bash", args...) + stderr := got.stderr + if tc.verbose { + stderr, _ = removeVerboseElapsed(t, stderr) + } + want := "" + if tc.receipt { + want = "Wrote manpages to " + dir + "\n" + } + if tc.verbose { + want += "Command: @manpages\nFormat option: json\nCommand result: completed\n" + } + if got.code != 0 || got.stdout != "" || stderr != want { + t.Fatalf("manpage receipt policy: got=%+v want stderr=%q", got, want) + } + info, err := os.Stat(filepath.Join(dir, "man1")) + if err != nil || !info.IsDir() { + t.Fatalf("man1 directory: %v %v", info, err) + } + var text []byte + for _, output := range []struct { + name string + want bool + }{{"openai.1", tc.text}, {"openai.1.gz", tc.gzip}} { + data, err := os.ReadFile(filepath.Join(dir, "man1", output.name)) + if !output.want { + if !os.IsNotExist(err) { + t.Fatalf("unselected output %s exists: %v", output.name, err) + } + continue + } + if err != nil || len(data) == 0 { + t.Fatalf("selected output %s: bytes=%d err=%v", output.name, len(data), err) + } + if output.name == "openai.1" { + text = data + continue + } + reader, err := gzip.NewReader(bytes.NewReader(data)) + if err != nil { + t.Fatal(err) + } + decoded, readErr := io.ReadAll(reader) + closeErr := reader.Close() + if readErr != nil || closeErr != nil || !bytes.Contains(decoded, []byte(".SH NAME")) { + t.Fatalf("incomplete gzip manpage: read=%v close=%v", readErr, closeErr) + } + if tc.text && !bytes.Equal(text, decoded) { + t.Fatal("selected text and gzip content differ") + } + } + }) + } +} + +func TestMainDispatchOutputManpageFailures(t *testing.T) { + for _, disabled := range []bool{false, true} { + for _, machine := range []bool{false, true} { + t.Run(fmt.Sprintf("directory failure/formats disabled=%t/machine=%t", disabled, machine), func(t *testing.T) { + path := filepath.Join(t.TempDir(), "existing file") + if err := os.WriteFile(path, []byte("GOOD"), 0600); err != nil { + t.Fatal(err) + } + args := []string{"openai", "--quiet", "--verbose"} + if machine { + args = append(args, "--format-error", "json") + } + args = append(args, "@manpages", "--output", path) + if disabled { + args = append(args, "--text=false", "--gzip=false") + } + got := runMainDispatch(t, "bash", args...) + assertManpageFailure(t, got, machine) + if data, err := os.ReadFile(path); err != nil || string(data) != "GOOD" { + t.Fatalf("directory failure changed existing file: %q %v", data, err) + } + }) + } + } + t.Run("completed text survives gzip creation failure", func(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "man1", "openai.1.gz"), 0755); err != nil { + t.Fatal(err) + } + got := runMainDispatch(t, "bash", "openai", "@manpages", "--text", "-o", dir) + assertManpageFailure(t, got, false) + text, err := os.ReadFile(filepath.Join(dir, "man1", "openai.1")) + if err != nil || !bytes.Contains(text, []byte(".SH admin projects users roles create")) { + t.Fatalf("completed text was lost: bytes=%d err=%v", len(text), err) + } + }) +} + +func assertManpageFailure(t *testing.T, got mainDispatchResult, machine bool) { + t.Helper() + if got.code != 1 || got.stdout != "" || got.stderr == "" || strings.Contains(got.stderr, "Wrote manpages") || strings.Contains(got.stderr, "Command result:") { + t.Fatalf("manpage failure lost status/data or reported completion: %+v", got) + } + if machine { + var payload map[string]any + err := json.Unmarshal([]byte(got.stderr), &payload) + message, ok := payload["message"].(string) + if err != nil || !ok || strings.TrimSpace(message) == "" { + t.Fatalf("machine error lacks a complete message: %q %v", got.stderr, err) + } + } +} diff --git a/docs/deterministic-output.md b/docs/deterministic-output.md index c247c4ff..758214b1 100644 --- a/docs/deterministic-output.md +++ b/docs/deterministic-output.md @@ -22,8 +22,10 @@ It preserves selected data, requested help, errors, and exit status. Saved image paths remain selected data. Quiet suppresses partial-image progress but preserves explicitly requested final previews. Quiet does not change the format or discard stdout. -Completed API binary saves report once on stderr; quiet and machine error modes suppress those receipts. -Manpage generation currently prints its receipt on stdout, including with `--quiet`. +The CLI writes completion receipts to stderr after successful API binary saves or manpage writes. +Quiet and machine error modes suppress these receipts. +Manpage generation reports completion after all selected files finish writing and close. +Disabling both manpage formats writes no files and emits no save receipt. `--verbose` reports the command, format option, elapsed time, and command result on stderr. Elapsed time includes argument parsing, request setup, command work, and cleanup. diff --git a/pkg/custom/manpage_commands.go b/pkg/custom/manpage_commands.go index 78bc4ffe..809fe701 100644 --- a/pkg/custom/manpage_commands.go +++ b/pkg/custom/manpage_commands.go @@ -1,8 +1,14 @@ package custom import ( + "compress/gzip" "context" + "errors" + "io" + "os" + "path/filepath" + docs "github.com/urfave/cli-docs/v3" "github.com/urfave/cli/v3" ) @@ -11,18 +17,60 @@ func configureManpageCommands(root *cli.Command) { if command == nil || command.Action == nil { return } - next := command.Action - command.Action = func(ctx context.Context, command *cli.Command) error { + command.Action = func(_ context.Context, command *cli.Command) error { // cli-docs increases Markdown heading depth for each command level. - // Deep subgroups exceed Markdown's six levels. Give the existing - // generated renderer/writer a flat, full-path documentation view. - // Its action reads the root directly, so restore the runtime tree on - // every return, including output errors. - original := root.Commands - root.Commands = manpageCommands(original, "") - defer func() { root.Commands = original }() - return next(ctx, command) + // Deep subgroups exceed Markdown's six levels. Render a flat copy + // with full command paths, without changing the runtime tree. + documented := *root + documented.Commands = manpageCommands(root.Commands, "") + manpage, err := docs.ToManWithSection(&documented, 1) + if err != nil { + return err + } + + dir := command.String("output") + if err := os.MkdirAll(filepath.Join(dir, "man1"), 0755); err != nil { + return err + } + writeFile := func(name string, compressed bool) error { + file, err := os.Create(filepath.Join(dir, "man1", name)) + if err != nil { + return err + } + return writeManpageFile(file, manpage, compressed) + } + if command.Bool("text") { + if err := writeFile("openai.1", false); err != nil { + return err + } + } + if command.Bool("gzip") { + if err := writeFile("openai.1.gz", true); err != nil { + return err + } + } + if !command.Bool("text") && !command.Bool("gzip") { + return nil + } + return ReportSaveReceipt(command, os.Stderr, "Wrote manpages to "+dir, nil) + } +} + +// writeManpageFile owns file and finishes compression before closing it. +func writeManpageFile(file io.WriteCloser, manpage string, compressed bool) (err error) { + defer func() { err = errors.Join(err, file.Close()) }() + var written int + if compressed { + writer := gzip.NewWriter(file) + defer func() { err = errors.Join(err, writer.Close()) }() + written, err = writer.Write([]byte(manpage)) + } else { + written, err = io.WriteString(file, manpage) + } + if err == nil && written != len(manpage) { + err = io.ErrShortWrite } + return err } func manpageCommands(commands []*cli.Command, prefix string) []*cli.Command { diff --git a/pkg/custom/manpage_commands_test.go b/pkg/custom/manpage_commands_test.go index 0259d6e6..c4f5df9a 100644 --- a/pkg/custom/manpage_commands_test.go +++ b/pkg/custom/manpage_commands_test.go @@ -1,52 +1,253 @@ package custom import ( + "bytes" + "compress/gzip" "context" "errors" + "io" + "os" + "path/filepath" "slices" "strings" "testing" - docs "github.com/urfave/cli-docs/v3" "github.com/urfave/cli/v3" ) -func TestManpageCommandsPreserveRuntimeTree(t *testing.T) { +func manpageTestCommand(t *testing.T, dir string) (*cli.Command, *cli.Command) { + t.Helper() flag := &cli.StringFlag{Name: "filter", Usage: "Filter documented items"} leaf := &cli.Command{Name: "list", Aliases: []string{"ls"}, Usage: "List documented items", Flags: []cli.Flag{flag}} group := &cli.Command{Name: "resources", Commands: []*cli.Command{leaf}} hidden := &cli.Command{Name: "internal", Hidden: true, Commands: []*cli.Command{{Name: "secret"}}} - root := &cli.Command{Name: "openai"} - wantErr := errors.New("synthetic writer failure") - var fail bool - manpages := &cli.Command{Name: "@manpages", Hidden: true, Action: func(context.Context, *cli.Command) error { - text, err := docs.ToManWithSection(root, 1) - if err != nil { - t.Fatal(err) - } - for _, want := range []string{".SH resources list, resources ls", "List documented items", "Filter documented items"} { - if !strings.Contains(text, want) { - t.Errorf("manpage lost %q", want) - } - } - if strings.Contains(text, "internal") || strings.Contains(text, "secret") { - t.Error("hidden subtree exposed") - } - if fail { - return wantErr - } - return nil - }} - original := []*cli.Command{group, hidden, manpages} - root.Commands = original + manpages := &cli.Command{ + Name: "@manpages", Hidden: true, HideHelpCommand: true, + Flags: []cli.Flag{ + &cli.StringFlag{Name: "output", Value: dir}, + &cli.BoolFlag{Name: "text", Value: true}, + &cli.BoolFlag{Name: "gzip", Value: true}, + }, + Action: func(context.Context, *cli.Command) error { + return errors.New("the generated action must not run") + }, + } + root := &cli.Command{ + Name: "openai", HideHelpCommand: true, + Flags: []cli.Flag{&cli.BoolFlag{Name: "quiet", Value: true}}, + Commands: []*cli.Command{group, hidden, manpages}, + } configureManpageCommands(root) - for _, fail = range []bool{false, true, false} { - err := manpages.Action(context.Background(), manpages) - if (fail && !errors.Is(err, wantErr)) || (!fail && err != nil) { - t.Fatalf("writer result changed: %v", err) - } + return root, manpages +} + +func TestManpageCommandsPreserveRuntimeTree(t *testing.T) { + dir := t.TempDir() + root, manpages := manpageTestCommand(t, dir) + original := slices.Clone(root.Commands) + group := original[0] + leaf := group.Commands[0] + flag := leaf.Flags[0] + assertTree := func() { + t.Helper() if !slices.Equal(root.Commands, original) || group.Commands[0] != leaf || leaf.Name != "list" || !slices.Equal(leaf.Aliases, []string{"ls"}) || leaf.Flags[0] != flag { t.Fatal("documentation generation changed the runtime command tree") } } + if err := root.Run(context.Background(), []string{"openai", "@manpages"}); err != nil { + t.Fatal(err) + } + assertTree() + text, err := os.ReadFile(filepath.Join(dir, "man1", "openai.1")) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{".SH resources list, resources ls", "List documented items", "Filter documented items"} { + if !strings.Contains(string(text), want) { + t.Errorf("manpage lost %q", want) + } + } + if strings.Contains(string(text), "internal") || strings.Contains(string(text), "secret") { + t.Error("hidden subtree exposed") + } + gzipped, err := os.ReadFile(filepath.Join(dir, "man1", "openai.1.gz")) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(gzipped, manpageTestGzip(t, text)) { + t.Fatal("gzip output changed from a single write with default gzip headers") + } + blocked := filepath.Join(dir, "blocked") + if err := os.WriteFile(blocked, []byte("keep"), 0600); err != nil { + t.Fatal(err) + } + if err := manpages.Set("output", blocked); err != nil { + t.Fatal(err) + } + if err := manpages.Action(context.Background(), manpages); err == nil { + t.Fatal("directory failure returned success") + } + assertTree() + if err := manpages.Set("output", dir); err != nil { + t.Fatal(err) + } + if err := manpages.Action(context.Background(), manpages); err != nil { + t.Fatal(err) + } + assertTree() + for name, want := range map[string][]byte{"openai.1": text, "openai.1.gz": gzipped} { + got, err := os.ReadFile(filepath.Join(dir, "man1", name)) + if err != nil || !bytes.Equal(got, want) { + t.Fatalf("repeated %s changed: %v", name, err) + } + } +} + +func TestManpageCommandsDisabledFormats(t *testing.T) { + for _, blocked := range []bool{false, true} { + t.Run(map[bool]string{false: "directory created", true: "directory failure"}[blocked], func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "output") + if blocked { + if err := os.WriteFile(dir, []byte("keep"), 0600); err != nil { + t.Fatal(err) + } + } + root, _ := manpageTestCommand(t, dir) + err := root.Run(context.Background(), []string{"openai", "@manpages", "--text=false", "--gzip=false"}) + if blocked { + if err == nil { + t.Fatal("disabled formats hid directory failure") + } + return + } + if err != nil { + t.Fatal(err) + } + files, err := os.ReadDir(filepath.Join(dir, "man1")) + if err != nil || len(files) != 0 { + t.Fatalf("disabled formats must create an empty directory: files=%v, err=%v", files, err) + } + }) + } +} + +func TestManpageCommandsKeepTextAfterGzipCreateFailure(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "man1", "openai.1.gz"), 0755); err != nil { + t.Fatal(err) + } + root, manpages := manpageTestCommand(t, dir) + if err := root.Run(context.Background(), []string{"openai", "@manpages"}); err == nil { + t.Fatal("gzip create failure returned success") + } + text, err := os.ReadFile(filepath.Join(dir, "man1", "openai.1")) + if err != nil || len(text) == 0 { + t.Fatalf("completed text was not retained: %v", err) + } + if err := manpages.Set("gzip", "false"); err != nil { + t.Fatal(err) + } + if err := manpages.Action(context.Background(), manpages); err != nil { + t.Fatal(err) + } + complete, err := os.ReadFile(filepath.Join(dir, "man1", "openai.1")) + if err != nil || !bytes.Equal(text, complete) { + t.Fatalf("partial failure did not retain the complete text: %v", err) + } +} + +func manpageTestGzip(t *testing.T, data []byte) []byte { + t.Helper() + var output bytes.Buffer + writer := gzip.NewWriter(&output) + if _, err := writer.Write(data); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + return output.Bytes() +} + +type manpageTestFile struct { + write func([]byte) (int, error) + closeErr error + closeCalls int + writesAfterClose int +} + +func (file *manpageTestFile) Write(data []byte) (int, error) { + if file.closeCalls != 0 { + file.writesAfterClose++ + } + return file.write(data) +} + +func (file *manpageTestFile) Close() error { + file.closeCalls++ + return file.closeErr +} + +func TestWriteManpageFileErrorsAndCleanup(t *testing.T) { + const manpage = "synthetic manpage\n" + writeErr := errors.New("synthetic write failure") + closeErr := errors.New("synthetic file-close failure") + gzipSize := len(manpageTestGzip(t, []byte(manpage))) + for _, tc := range []struct { + name string + compressed bool + limit int + writeErr error + closeErr error + wantErr error + }{ + {name: "text success", limit: -1}, + {name: "text short write", limit: 2, wantErr: io.ErrShortWrite}, + {name: "text write failure", limit: 2, writeErr: writeErr, wantErr: writeErr}, + {name: "text close failure", limit: -1, closeErr: closeErr}, + {name: "text joined failures", limit: 2, writeErr: writeErr, closeErr: closeErr, wantErr: writeErr}, + {name: "gzip success", compressed: true, limit: -1}, + {name: "gzip close failure", compressed: true, limit: -1, closeErr: closeErr}, + {name: "gzip header failure", compressed: true, limit: 0, writeErr: writeErr, closeErr: closeErr, wantErr: writeErr}, + {name: "gzip finalization failure", compressed: true, limit: 10, writeErr: writeErr, closeErr: closeErr, wantErr: writeErr}, + {name: "gzip footer failure", compressed: true, limit: gzipSize - 8, writeErr: writeErr, closeErr: closeErr, wantErr: writeErr}, + } { + t.Run(tc.name, func(t *testing.T) { + var output bytes.Buffer + file := &manpageTestFile{closeErr: tc.closeErr, write: func(data []byte) (int, error) { + if tc.limit >= 0 && len(data) > tc.limit-output.Len() { + n, err := output.Write(data[:tc.limit-output.Len()]) + if err != nil { + return n, err + } + return n, tc.writeErr + } + return output.Write(data) + }} + err := writeManpageFile(file, manpage, tc.compressed) + if tc.wantErr == nil && tc.closeErr == nil && err != nil { + t.Fatal(err) + } + for _, want := range []error{tc.wantErr, tc.closeErr} { + if want != nil && !errors.Is(err, want) { + t.Fatalf("error %v lost %v", err, want) + } + } + if file.closeCalls != 1 || file.writesAfterClose != 0 { + t.Fatalf("file closes=%d, writes after close=%d", file.closeCalls, file.writesAfterClose) + } + if tc.limit >= 0 && output.Len() != tc.limit { + t.Fatalf("failure boundary: wrote %d bytes, want %d", output.Len(), tc.limit) + } + if tc.limit < 0 { + want := []byte(manpage) + if tc.compressed { + want = manpageTestGzip(t, want) + } + if !bytes.Equal(output.Bytes(), want) { + t.Fatal("file closed before all content was written") + } + } + }) + } } From 3ae27da487eb3fb4122b62313442a32fcec02304 Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Fri, 9 Oct 2026 01:17:39 -0700 Subject: [PATCH 42/44] [files] retain launcher hints and validate known receipt fields --- .../files_receipt_validation_unix_test.go | 66 +++++++ docs/files.md | 4 +- pkg/custom/file_commands.go | 6 + pkg/custom/file_failures_test.go | 27 +++ pkg/custom/file_receipt_shell.go | 47 +++++ pkg/custom/file_receipt_shell_darwin.go | 23 +++ pkg/custom/file_receipt_shell_linux.go | 30 ++++ pkg/custom/file_receipt_shell_other.go | 9 + pkg/custom/file_receipt_shell_test.go | 166 ++++++++++++++++++ pkg/custom/file_receipt_shell_windows.go | 35 ++++ pkg/custom/file_results.go | 29 ++- pkg/custom/file_results_test.go | 5 +- 12 files changed, 442 insertions(+), 5 deletions(-) create mode 100644 cmd/openai/files_receipt_validation_unix_test.go create mode 100644 pkg/custom/file_receipt_shell.go create mode 100644 pkg/custom/file_receipt_shell_darwin.go create mode 100644 pkg/custom/file_receipt_shell_linux.go create mode 100644 pkg/custom/file_receipt_shell_other.go create mode 100644 pkg/custom/file_receipt_shell_test.go create mode 100644 pkg/custom/file_receipt_shell_windows.go diff --git a/cmd/openai/files_receipt_validation_unix_test.go b/cmd/openai/files_receipt_validation_unix_test.go new file mode 100644 index 00000000..de7923c4 --- /dev/null +++ b/cmd/openai/files_receipt_validation_unix_test.go @@ -0,0 +1,66 @@ +//go:build !windows + +package main + +import ( + "net/http" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMainFilesReceiptKnownFieldsPreserveFullFallback(t *testing.T) { + python, err := exec.LookPath("python3") + if err != nil { + t.Skip("Python 3 is required for Unix receipt checks") + } + bash, err := exec.LookPath("bash") + if err != nil { + t.Skip("Bash is required for Unix receipt checks") + } + for _, tc := range []struct { + name, fields string + receipt bool + }{ + {"created string", `"created_at":"invalid"`, false}, + {"created null", `"created_at":null`, false}, + {"expiry object", `"expires_at":{"preserve":"this field"}`, false}, + {"details array", `"status_details":["preserve","these details"]`, false}, + {"details number", `"status_details":12`, false}, + {"nullable fields", `"expires_at":null,"status_details":null`, true}, + {"signed and large timestamps", `"created_at":-1,"expires_at":9223372036854775808`, true}, + } { + t.Run(tc.name, func(t *testing.T) { + work := t.TempDir() + const filename = "upload space.txt" + payload := []byte("hello files!\n") + require.NoError(t, os.WriteFile(filepath.Join(work, filename), payload, 0o600)) + response := `{"id":"` + filesWorkflowID + `","object":"file","filename":"upload space.txt","purpose":"user_data","bytes":13,` + tc.fields + `}` + server, requests := filesWorkflowUploadServerWithResponse(t, filename, payload, http.StatusOK, response) + upload := runFilesReceiptPTY(t, python, bash, work, server.URL, []string{"openai", "files", "upload", filename, "--purpose=user_data"}) + require.Zero(t, upload.code, "%+v", upload) + require.Empty(t, upload.stderr) + if tc.receipt { + require.Contains(t, upload.stdout, "Uploaded "+filename) + require.Contains(t, upload.stdout, "Inspect it:") + } else { + legacy := runFilesReceiptPTY(t, python, bash, work, server.URL, []string{"openai", "files", "create", "--file", filename, "--purpose=user_data"}) + require.Equal(t, legacy, upload, "malformed known fields must retain the complete legacy response rendering") + require.NotContains(t, upload.stdout, "Uploaded ") + require.NotContains(t, upload.stdout, "Inspect it:") + } + machine := runFilesReceiptPTY(t, python, bash, work, server.URL, []string{"openai", "files", "upload", filename, "--purpose=user_data", "--format=json"}) + require.Zero(t, machine.code, "%+v", machine) + require.Empty(t, machine.stderr) + require.JSONEq(t, response, machine.stdout) + wantRequests := int32(3) + if tc.receipt { + wantRequests = 2 + } + require.Equal(t, wantRequests, requests.Load(), "each process must upload the exact fixture once") + }) + } +} diff --git a/docs/files.md b/docs/files.md index 1b06ea04..3122d137 100644 --- a/docs/files.md +++ b/docs/files.md @@ -99,6 +99,7 @@ Inspect it: openai files get file-example The receipt uses the returned filename, size, ID, and purpose. The CLI omits the size when the API does not supply it. +The CLI shows complete responses for unfamiliar fields, malformed timestamps, or malformed status details. The next command inspects metadata without downloading contents or selecting a local destination. It preserves the executable and selected project, organization, and safe base URL. Keep the same environment when copying it. @@ -172,7 +173,8 @@ openai files get file-example --transform filename --raw-output Interactive receipts do not appear in JSON, extraction, or piped upload output. `--quiet` suppresses upload receipts and retains the selected metadata output. Error-output options do not change a successful upload's receipt or response data. -The receipt's suggested command preserves the executable used for the upload. +Installed commands preserve the executable used for the upload. +Development hints use `go -C CHECKOUT run ./cmd/openai` to retain the source directory. See [reading command results](readable-output.md) for output formats. Inspect command help without making an API request: diff --git a/pkg/custom/file_commands.go b/pkg/custom/file_commands.go index f5a3edd6..e9c56f5d 100644 --- a/pkg/custom/file_commands.go +++ b/pkg/custom/file_commands.go @@ -20,6 +20,7 @@ type fileInvocation struct { display string executable string goRun bool + goRunDir string requestArgs []string omitHint bool } @@ -92,6 +93,11 @@ func configureFileCommands(root *cli.Command) { invocation.goRun = false } } + if invocation.goRun { + var err error + invocation.goRunDir, err = os.Getwd() + invocation.omitHint = invocation.omitHint || err != nil + } ctx = context.WithValue(ctx, fileInvocationKey{}, invocation) return next(context.WithValue(ctx, fileCommandKey{}, fileUploadCommand), command) } diff --git a/pkg/custom/file_failures_test.go b/pkg/custom/file_failures_test.go index dfe2b29b..971d3f50 100644 --- a/pkg/custom/file_failures_test.go +++ b/pkg/custom/file_failures_test.go @@ -20,6 +20,33 @@ func TestFileReceiptPreservesUnfamiliarProcessingStatus(t *testing.T) { } } +func TestFileReceiptKnownFieldShapes(t *testing.T) { + for _, field := range []string{"created_at", "expires_at", "status_details"} { + for _, malformed := range []string{`{}`, `[]`, `true`, `false`} { + value := gjson.Parse(strings.TrimSuffix(fileReceiptFixture, "}") + `,"` + field + `":` + malformed + `}`) + require.False(t, validFileReceipt(value), "%s=%s", field, malformed) + } + } + for _, field := range []string{"created_at", "expires_at"} { + for _, malformed := range []string{`"invalid"`, `"1700000000"`, `1.5`, `1e3`} { + value := gjson.Parse(strings.TrimSuffix(fileReceiptFixture, "}") + `,"` + field + `":` + malformed + `}`) + require.False(t, validFileReceipt(value), "%s=%s", field, malformed) + } + for _, integer := range []string{`0`, `-1`, `1700000000`, `9007199254740993`, `9223372036854775808`, `-9223372036854775809`} { + value := gjson.Parse(strings.TrimSuffix(fileReceiptFixture, "}") + `,"` + field + `":` + integer + `}`) + require.True(t, validFileReceipt(value), "%s=%s", field, integer) + } + } + for _, malformed := range []string{`0`, `1.5`, `"bad` + "\xff" + `value"`} { + value := gjson.Parse(strings.TrimSuffix(fileReceiptFixture, "}") + `,"status_details":` + malformed + `}`) + require.False(t, validFileReceipt(value), malformed) + } + require.False(t, validFileReceipt(gjson.Parse(strings.TrimSuffix(fileReceiptFixture, "}")+`,"created_at":null}`))) + for _, fields := range []string{`"expires_at":null`, `"status_details":null`, `"status_details":""`, `"status_details":"failed\nwith details"`} { + require.True(t, validFileReceipt(gjson.Parse(strings.TrimSuffix(fileReceiptFixture, "}")+`,`+fields+`}`)), fields) + } +} + func TestFileReceiptProcessingFailureSuggestsInspection(t *testing.T) { value := gjson.Parse(strings.TrimSuffix(fileReceiptFixture, "}") + `,"status":"error","status_details":"synthetic processing failure"}`) var out bytes.Buffer diff --git a/pkg/custom/file_receipt_shell.go b/pkg/custom/file_receipt_shell.go new file mode 100644 index 00000000..cf1bb6fc --- /dev/null +++ b/pkg/custom/file_receipt_shell.go @@ -0,0 +1,47 @@ +package custom + +import ( + "context" + "os" + "path/filepath" + "strings" +) + +// Files can cross one verified Go launcher when help recognized a go-run +// invocation. Image and setup callers retain their immediate-parent lookup. +func fileReceiptShell(ctx context.Context, invocation fileInvocation) string { + if ctx.Err() != nil || invocation.omitHint { + return "" + } + pid := os.Getppid() + name, err := imagePickerParentProcessName() + if err != nil || os.Getppid() != pid { + return "" + } + shell := fileReceiptShellName(ctx, invocation.goRun, name, func() (string, error) { + return fileReceiptGoCallerName(pid) + }) + if os.Getppid() != pid { + return "" + } + return shell +} + +func fileReceiptShellName(ctx context.Context, goRun bool, parent string, goCaller func() (string, error)) string { + if ctx.Err() != nil { + return "" + } + if shell := imagePickerShellName(parent); shell != "" { + return shell + } + name := filepath.Base(strings.ReplaceAll(parent, `\`, "/")) + if !goRun || strings.TrimSuffix(strings.ToLower(name), ".exe") != "go" { + return "" + } + caller, err := goCaller() + if err != nil || ctx.Err() != nil { + return "" + } + // Unsupported wrappers stop resolution. Never search older ancestors or SHELL. + return imagePickerShellName(caller) +} diff --git a/pkg/custom/file_receipt_shell_darwin.go b/pkg/custom/file_receipt_shell_darwin.go new file mode 100644 index 00000000..378d38b3 --- /dev/null +++ b/pkg/custom/file_receipt_shell_darwin.go @@ -0,0 +1,23 @@ +package custom + +import ( + "errors" + + "golang.org/x/sys/unix" +) + +func fileReceiptGoCallerName(pid int) (string, error) { + launcher, err := unix.SysctlKinfoProc("kern.proc.pid", pid) + if err != nil { + return "", err + } + parent := int(launcher.Eproc.Ppid) + if parent <= 1 || parent == pid { + return "", errors.New("Go launcher parent is unavailable") + } + caller, err := unix.SysctlKinfoProc("kern.proc.pid", parent) + if err != nil { + return "", err + } + return unix.ByteSliceToString(caller.Proc.P_comm[:]), nil +} diff --git a/pkg/custom/file_receipt_shell_linux.go b/pkg/custom/file_receipt_shell_linux.go new file mode 100644 index 00000000..9e39beab --- /dev/null +++ b/pkg/custom/file_receipt_shell_linux.go @@ -0,0 +1,30 @@ +package custom + +import ( + "errors" + "os" + "strconv" + "strings" +) + +func fileReceiptGoCallerName(pid int) (string, error) { + stat, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat") + if err != nil { + return "", err + } + // comm can contain spaces and parentheses. Fields after its closing ')' start + // with process state, then the parent PID. + end := strings.LastIndexByte(string(stat), ')') + if end < 0 { + return "", errors.New("Go launcher process record is unavailable") + } + fields := strings.Fields(string(stat[end+1:])) + if len(fields) < 2 { + return "", errors.New("Go launcher parent is unavailable") + } + parent, err := strconv.Atoi(fields[1]) + if err != nil || parent <= 1 || parent == pid { + return "", errors.New("Go launcher parent is unavailable") + } + return os.Readlink("/proc/" + strconv.Itoa(parent) + "/exe") +} diff --git a/pkg/custom/file_receipt_shell_other.go b/pkg/custom/file_receipt_shell_other.go new file mode 100644 index 00000000..f8249f0f --- /dev/null +++ b/pkg/custom/file_receipt_shell_other.go @@ -0,0 +1,9 @@ +//go:build !darwin && !linux && !windows + +package custom + +import "errors" + +func fileReceiptGoCallerName(int) (string, error) { + return "", errors.New("Go launcher parent detection is unavailable on this platform") +} diff --git a/pkg/custom/file_receipt_shell_test.go b/pkg/custom/file_receipt_shell_test.go new file mode 100644 index 00000000..ce63fef6 --- /dev/null +++ b/pkg/custom/file_receipt_shell_test.go @@ -0,0 +1,166 @@ +package custom + +import ( + "bytes" + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestFileReceiptShellCrossesOnlyRecognizedGoLauncher(t *testing.T) { + t.Setenv("SHELL", "/bin/bash") + for _, tc := range []struct { + name, parent, caller, want string + goRun bool + lookups int + }{ + {"ordinary Bash", "/bin/bash", "", "bash", false, 0}, + {"ordinary zsh", "-zsh", "", "zsh", false, 0}, + {"ordinary fish", "/usr/bin/fish", "", "fish", false, 0}, + {"ordinary PowerShell", `C:\PowerShell\pwsh.exe`, "", "pwsh", false, 0}, + {"recognized invocation with immediate shell", "bash", "", "bash", true, 0}, + {"Go without invocation evidence", "/usr/local/go/bin/go", "bash", "", false, 0}, + {"Go from Bash", "/usr/local/go/bin/go", "bash", "bash", true, 1}, + {"Go from zsh", "go", "zsh", "zsh", true, 1}, + {"Go from fish", "go", "fish", "fish", true, 1}, + {"Windows Go from PowerShell", `C:\Go\bin\GO.EXE`, `C:\PowerShell\pwsh.exe`, "pwsh", true, 1}, + {"second Go wrapper stops traversal", "go", "go", "", true, 1}, + {"unsupported Go caller", "go", "python3", "", true, 1}, + {"unsupported direct caller", "python3", "bash", "", true, 0}, + {"different Go tool", "gofmt", "bash", "", true, 0}, + {"unknown shell", "/bin/sh", "bash", "", true, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + lookups := 0 + got := fileReceiptShellName(t.Context(), tc.goRun, tc.parent, func() (string, error) { + lookups++ + return tc.caller, nil + }) + require.Equal(t, tc.want, got) + require.Equal(t, tc.lookups, lookups) + }) + } +} + +func TestFileReceiptShellLookupFailureAndCancellation(t *testing.T) { + t.Setenv("SHELL", "/bin/bash") + t.Run("lookup failure does not guess", func(t *testing.T) { + got := fileReceiptShellName(t.Context(), true, "go", func() (string, error) { + return "bash", errors.New("synthetic unavailable parent") + }) + require.Empty(t, got) + }) + t.Run("already canceled performs no lookup", func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + cancel() + got := fileReceiptShellName(ctx, true, "go", func() (string, error) { + t.Fatal("canceled receipt inspected another process") + return "bash", nil + }) + require.Empty(t, got) + require.Empty(t, fileReceiptShell(ctx, fileInvocation{goRun: true})) + }) + t.Run("canceled lookup discards result", func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + got := fileReceiptShellName(ctx, true, "go", func() (string, error) { + cancel() + return "bash", nil + }) + require.Empty(t, got) + }) + t.Run("unsafe request context omits lookup", func(t *testing.T) { + require.Empty(t, fileReceiptShell(t.Context(), fileInvocation{goRun: true, omitHint: true})) + }) +} + +func TestFileReceiptGoRunInvocationCapturesCheckout(t *testing.T) { + directory := filepath.Join(t.TempDir(), "checkout's folder") + invocation := fileInvocation{ + display: "go run ./cmd/openai", executable: "/tmp/go-build123/b001/exe/openai", + goRun: true, goRunDir: directory, + } + for _, shell := range []string{"bash", "zsh", "fish", "pwsh"} { + t.Run(shell, func(t *testing.T) { + got := fileReceiptInvocation(invocation, shell) + require.Equal(t, "go -C "+imagePickerShellQuoter(shell)(directory)+" run ./cmd/openai", got) + require.NotContains(t, got, "go-build123") + }) + } + invocation.goRunDir = filepath.Join(t.TempDir(), "controls\n\t\x1b[31m\u202e") + for _, shell := range []string{"bash", "zsh", "fish", "pwsh"} { + got := fileReceiptInvocation(invocation, shell) + require.NotEmpty(t, got) + for _, control := range []string{"\n", "\t", "\x1b", "\u202e"} { + require.NotContains(t, got, control, shell) + } + } +} + +func TestFileReceiptGoRunInvocationOmitsUnknownDirectory(t *testing.T) { + for _, directory := range []string{"", "relative-checkout", filepath.Join(t.TempDir(), "bad\x00path"), filepath.Join(t.TempDir(), "bad\xffpath")} { + invocation := fileInvocation{display: "go run ./cmd/openai", goRun: true, goRunDir: directory} + require.Empty(t, fileReceiptInvocation(invocation, "bash"), strings.ReplaceAll(directory, "\x00", "NUL")) + } + require.Empty(t, fileReceiptInvocation(fileInvocation{goRun: true, goRunDir: t.TempDir()}, "unknown")) +} + +func TestFileReceiptGoRunHintPreservesShellArguments(t *testing.T) { + for _, shell := range []string{"bash", "zsh", "fish", "pwsh"} { + t.Run(shell, func(t *testing.T) { + binary, err := exec.LookPath(shell) + if err != nil { + t.Skipf("%s is unavailable", shell) + } + work := t.TempDir() + directory := filepath.Join(work, "checkout's folder\nwith\ttab") + requestArgs := []string{"--project=project's value", "--organization=org two", "--base-url=http://127.0.0.1:1/v1"} + invocation := fileInvocation{ + display: "go run ./cmd/openai", executable: "/tmp/go-build123/b001/exe/openai", + goRun: true, goRunDir: directory, requestArgs: requestArgs, + } + var receipt bytes.Buffer + require.NoError(t, writeFileReceipt(&receipt, gjson.Parse(fileReceiptFixture), shell, invocation)) + _, command, found := strings.Cut(receipt.String(), "\nInspect it: ") + require.True(t, found, receipt.String()) + prefix := `go() { printf '%s\0' "$@"; }; ` + args := []string{"--noprofile", "--norc", "-c"} + switch shell { + case "zsh": + args = []string{"-f", "-c"} + case "fish": + prefix = `function go; printf '%s\0' $argv; end; ` + args = []string{"--no-config", "-c"} + case "pwsh": + prefix = `function go { foreach ($item in $args) { [Console]::Out.Write([string]$item); [Console]::Out.Write([char]0) } }; ` + args = []string{"-NoLogo", "-NoProfile", "-NonInteractive", "-Command"} + } + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + process := exec.CommandContext(ctx, binary, append(args, prefix+strings.TrimSpace(command))...) + process.Dir = work + for _, entry := range os.Environ() { + name, _, _ := strings.Cut(entry, "=") + if !strings.HasPrefix(name, "OPENAI_") && name != "BASH_ENV" && name != "ENV" && name != "HOME" && name != "ZDOTDIR" { + process.Env = append(process.Env, entry) + } + } + process.Env = append(process.Env, "HOME="+work, "ZDOTDIR="+work) + output, err := process.CombinedOutput() + require.NoError(t, ctx.Err(), "copied Go invocation timed out") + require.NoError(t, err, string(output)) + require.True(t, bytes.HasSuffix(output, []byte{0}), "missing argument delimiter: %q", output) + arguments := strings.Split(strings.TrimSuffix(string(output), "\x00"), "\x00") + want := append([]string{"-C", directory, "run", "./cmd/openai"}, requestArgs...) + require.Equal(t, append(want, "files", "get", "file-example"), arguments) + }) + } +} diff --git a/pkg/custom/file_receipt_shell_windows.go b/pkg/custom/file_receipt_shell_windows.go new file mode 100644 index 00000000..88c615d4 --- /dev/null +++ b/pkg/custom/file_receipt_shell_windows.go @@ -0,0 +1,35 @@ +package custom + +import ( + "errors" + "unsafe" + + "golang.org/x/sys/windows" +) + +func fileReceiptGoCallerName(pid int) (string, error) { + launcher, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) + if err != nil { + return "", err + } + defer windows.CloseHandle(launcher) + var info windows.PROCESS_BASIC_INFORMATION + if err := windows.NtQueryInformationProcess(launcher, windows.ProcessBasicInformation, unsafe.Pointer(&info), uint32(unsafe.Sizeof(info)), nil); err != nil { + return "", err + } + parent := info.InheritedFromUniqueProcessId + if parent <= 1 || parent == uintptr(pid) || parent > uintptr(^uint32(0)) { + return "", errors.New("Go launcher parent is unavailable") + } + caller, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(parent)) + if err != nil { + return "", err + } + defer windows.CloseHandle(caller) + var path [32768]uint16 + size := uint32(len(path)) + if err := windows.QueryFullProcessImageName(caller, 0, &path[0], &size); err != nil { + return "", err + } + return windows.UTF16ToString(path[:size]), nil +} diff --git a/pkg/custom/file_results.go b/pkg/custom/file_results.go index ad2d9a05..e4389a13 100644 --- a/pkg/custom/file_results.go +++ b/pkg/custom/file_results.go @@ -3,6 +3,7 @@ package custom import ( "fmt" "io" + "path/filepath" "strings" "unicode/utf8" @@ -43,7 +44,7 @@ func showFileResult(value gjson.Result, opts ShowJSONOpts) (bool, error) { return false, nil } invocation, _ := opts.Context.Value(fileInvocationKey{}).(fileInvocation) - return true, writeFileReceipt(out, value, imagePickerParentShell(opts.Context), invocation) + return true, writeFileReceipt(out, value, fileReceiptShell(opts.Context, invocation), invocation) default: return false, nil } @@ -74,6 +75,22 @@ func validFileReceipt(value gjson.Result) bool { return false } } + for _, name := range []string{"created_at", "expires_at"} { + field := value.Get(name) + if !field.Exists() || (name == "expires_at" && field.Type == gjson.Null) { + continue + } + // Inspect the decimal representation without rounding large timestamps or + // imposing a new date range. Full-response output preserves other shapes. + if field.Type != gjson.Number || strings.IndexFunc(strings.TrimPrefix(field.Raw, "-"), func(r rune) bool { return r < '0' || r > '9' }) >= 0 { + return false + } + } + if details := value.Get("status_details"); details.Exists() && details.Type != gjson.Null { + if details.Type != gjson.String || !utf8.ValidString(details.Str) { + return false + } + } // Fall back to full output for new fields or ambiguous duplicate values. seen := map[string]bool{} valid := true @@ -141,11 +158,19 @@ func fileReceiptInvocation(invocation fileInvocation, shell string) string { if !utf8.ValidString(invocation.executable) { return "" } + if invocation.goRun { + quote := imagePickerShellQuoter(shell) + packagePath, recognized := strings.CutPrefix(invocation.display, "go run ") + if quote == nil || !recognized || packagePath == "" || !filepath.IsAbs(invocation.goRunDir) || !utf8.ValidString(invocation.goRunDir) || strings.ContainsRune(invocation.goRunDir, 0) { + return "" + } + return "go -C " + quote(invocation.goRunDir) + " run " + quote(packagePath) + } if invocation.display == "" { return "openai" } plain, _ := imagePickerQuoteProperties(invocation.display) - if plain || invocation.goRun || invocation.executable == "" { + if plain || invocation.executable == "" { return invocation.display } quote := imagePickerShellQuoter(shell) diff --git a/pkg/custom/file_results_test.go b/pkg/custom/file_results_test.go index 0c258fdf..b8450d0e 100644 --- a/pkg/custom/file_results_test.go +++ b/pkg/custom/file_results_test.go @@ -232,7 +232,8 @@ func TestFileReceiptQuotesExecutableNamedLikeGoRun(t *testing.T) { }) } // Only the help-selected development invocation may remain shell syntax. - require.Equal(t, "go run ./cmd/openai", fileReceiptInvocation(fileInvocation{ - display: "go run ./cmd/openai", executable: "/tmp/go-build123/b001/exe/openai", goRun: true, + checkout := t.TempDir() + require.Equal(t, "go -C "+imagePickerShellQuoter("bash")(checkout)+" run ./cmd/openai", fileReceiptInvocation(fileInvocation{ + display: "go run ./cmd/openai", executable: "/tmp/go-build123/b001/exe/openai", goRun: true, goRunDir: checkout, }, "bash")) } From ae2ad9e717448c0877933069d23c7b8e18200ffd Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Fri, 9 Oct 2026 01:45:34 -0700 Subject: [PATCH 43/44] [files] clarify explicit purpose input options --- docs/files.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/files.md b/docs/files.md index 3122d137..112caa52 100644 --- a/docs/files.md +++ b/docs/files.md @@ -80,7 +80,7 @@ cat upload.bin | openai files upload --file - --purpose user_data Only one parameter can consume stdin. File paths avoid shell conversion of binary pipelines. See [shell and file input](shell-file-input.md) for stdin metadata and Windows shell differences. -Supply `--purpose` for each upload. The CLI does not select a purpose for you. +Provide an explicit purpose through `--purpose` or supported JSON/YAML input. The CLI does not select a purpose for you. The example uses `user_data`; select the purpose required by your API workflow. The API applies purpose, file type, and size requirements. See the [Files upload reference](https://developers.openai.com/api/reference/resources/files/methods/create) for current requirements. From 44f75644aa0f1dadb481a251a92b25600cbc6057 Mon Sep 17 00:00:00 2001 From: Sai Guvvala Date: Fri, 9 Oct 2026 01:50:12 -0700 Subject: [PATCH 44/44] Honor output policy for compact table hints --- pkg/custom/list_table.go | 2 +- pkg/custom/list_table_test.go | 73 +++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/pkg/custom/list_table.go b/pkg/custom/list_table.go index d41d2ef7..de852983 100644 --- a/pkg/custom/list_table.go +++ b/pkg/custom/list_table.go @@ -20,7 +20,7 @@ func renderListTablePage(ctx context.Context, operation string, items []gjson.Re if !fits { return "", false, nil } - if len(rows) > 0 { + if len(rows) > 0 && outputDiagnosticsAllowed(ctx) { content += ansi.Wrap(resourceSummaryHint, width, "") + "\n" } return content, true, nil diff --git a/pkg/custom/list_table_test.go b/pkg/custom/list_table_test.go index 79385f6a..c52e5885 100644 --- a/pkg/custom/list_table_test.go +++ b/pkg/custom/list_table_test.go @@ -8,8 +8,81 @@ import ( "github.com/stretchr/testify/require" "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" ) +func TestRenderListNavigationPageHintPolicy(t *testing.T) { + for _, resource := range []struct{ name, input, selected string }{ + {"files", `{"id":"file_exact","object":"file","filename":"input.jsonl","purpose":"batch","bytes":1024,"status":"processed","created_at":123}`, "input.jsonl"}, + {"batches", `{"id":"batch_exact","object":"batch","status":"completed","created_at":123,"metadata":{"label":"retained metadata"}}`, "completed"}, + {"admin.organization.projects", `{"id":"proj_exact","object":"organization.project","name":"Synthetic project","status":"active","created_at":123}`, "Synthetic project"}, + } { + for _, layout := range []struct { + name string + width int + table bool + }{{"table", 80, true}, {"fallback", 1, false}} { + t.Run(resource.name+"/"+layout.name, func(t *testing.T) { + items := []gjson.Result{gjson.Parse(resource.input)} + original := append([]gjson.Result(nil), items...) + opts := ShowJSONOpts{Context: t.Context(), Operation: "(resource) " + resource.name + " > (method) list", OutputKind: OutputPageItem} + _, fits, err := renderListTablePage(t.Context(), opts.Operation, items, layout.width) + require.NoError(t, err) + require.Equal(t, layout.table, fits, "the fixture must exercise the selected renderer") + ordinary, err := renderListNavigationPage(opts, items, layout.width) + require.NoError(t, err) + require.Contains(t, ordinary, items[0].Get("id").String()) + require.Contains(t, ordinary, resource.selected) + // Projects retain their full record in the labeled fallback, so + // that path has no omitted-field hint even without a policy. + wantHint := layout.table || resource.name != "admin.organization.projects" + require.Equal(t, wantHint, strings.HasSuffix(ordinary, resourceSummaryHint+"\n")) + hintCount := 0 + if wantHint { + hintCount = 1 + } + require.Equal(t, hintCount, strings.Count(ordinary, resourceSummaryHint)) + data := strings.TrimSuffix(ordinary, resourceSummaryHint+"\n") + for _, policy := range []struct { + name string + flags []string + hint bool + }{ + {"ordinary", nil, true}, + {"quiet", []string{"--quiet"}, false}, + {"quiet verbose", []string{"--quiet", "--verbose"}, false}, + {"machine errors", []string{"--format-error", "json"}, false}, + {"inherited machine errors", []string{"--format", "json"}, false}, + {"extracted errors", []string{"--transform-error", "message"}, false}, + {"explicit text override", []string{"--format", "json", "--format-error", "text"}, true}, + } { + t.Run(policy.name, func(t *testing.T) { + var content string + root := receiptTestCommand(func(command *cli.Command) error { + // The page is already selected. Parse actual flags for + // its diagnostic policy without changing renderer routing. + selected := opts + selected.Context = outputPolicyContext(t.Context(), command) + var err error + content, err = renderListNavigationPage(selected, items, layout.width) + return err + }) + root.Metadata = map[string]any{"synthetic-marker": "unchanged"} + require.NoError(t, root.Run(t.Context(), append([]string{"openai"}, policy.flags...))) + want := data + if policy.hint && wantHint { + want += resourceSummaryHint + "\n" + } + require.Equal(t, want, content, "policy may change only the optional hint") + require.Equal(t, original, items, "rendering must preserve original records and metadata") + require.Equal(t, map[string]any{"synthetic-marker": "unchanged"}, root.Metadata) + }) + } + }) + } + } +} + func TestRenderListNavigationPageCancelsProjection(t *testing.T) { large := strings.Repeat("synthetic-", 128*1024) for _, test := range []struct{ resource, input string }{