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_receipt_context_unix_test.go b/cmd/openai/files_receipt_context_unix_test.go new file mode 100644 index 00000000..129dd5b2 --- /dev/null +++ b/cmd/openai/files_receipt_context_unix_test.go @@ -0,0 +1,167 @@ +//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)) + 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) + 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.Equal(t, entriesBefore, entries, "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 new file mode 100644 index 00000000..012530a0 --- /dev/null +++ b/cmd/openai/files_receipt_unix_test.go @@ -0,0 +1,216 @@ +//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, "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" + + "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, "Inspect 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, 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, 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() + 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"], +} +env.update(config["Env"] or {}) +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"] 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: + 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_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/cmd/openai/files_workflow_test.go b/cmd/openai/files_workflow_test.go new file mode 100644 index 00000000..2271a3e5 --- /dev/null +++ b/cmd/openai/files_workflow_test.go @@ -0,0 +1,529 @@ +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() + 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) { + 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") + w.WriteHeader(status) + _, _ = io.WriteString(w, response) + })) + 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 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") + 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.stdout) + require.Equal(t, "Wrote output to: "+path+"\n", 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 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) { + 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) + } +} + +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, "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) { + 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/cmd/openai/help_presentation_test.go b/cmd/openai/help_presentation_test.go index 4e1660e3..f7b44e9d 100644 --- a/cmd/openai/help_presentation_test.go +++ b/cmd/openai/help_presentation_test.go @@ -54,7 +54,8 @@ func TestMainHelpPresentationConcreteSyntaxAndLabels(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")...) diff --git a/docs/files.md b/docs/files.md new file mode 100644 index 00000000..112caa52 --- /dev/null +++ b/docs/files.md @@ -0,0 +1,260 @@ +# 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. + +## 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. +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 -` sentinel requests stdin. +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. + +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. + +## 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 + +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. +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 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. +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 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 +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. +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 + +`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. +`--quiet` suppresses upload receipts and retains the selected metadata output. +Error-output options do not change a successful upload's receipt or response data. +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: + +```sh +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. | +| 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: + +```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. + +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: + +```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. +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. +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/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/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 new file mode 100644 index 00000000..ee6bd92b --- /dev/null +++ b/internal/autocomplete/literal_file_paths_test.go @@ -0,0 +1,91 @@ +package autocomplete + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "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") + } +} + +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/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..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 @@ -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]}" @@ -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/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/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/cmdutil.go b/pkg/custom/cmdutil.go index e63f88bf..ee4ec06a 100644 --- a/pkg/custom/cmdutil.go +++ b/pkg/custom/cmdutil.go @@ -578,6 +578,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 b1b16b60..215cb038 100644 --- a/pkg/custom/command.go +++ b/pkg/custom/command.go @@ -30,6 +30,7 @@ func ConfigureCommand(root *cli.Command) { configureReadableSpeech(root) configureCommandSubgroups(root) configureTaskCommands(root) + configureFileCommands(root) registerTokenizerCommands(root) registerCodexCommands(root) configureCommandPresentation(root) diff --git a/pkg/custom/file_commands.go b/pkg/custom/file_commands.go new file mode 100644 index 00000000..e9c56f5d --- /dev/null +++ b/pkg/custom/file_commands.go @@ -0,0 +1,146 @@ +package custom + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "unicode" + + "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 + goRun bool + goRunDir string + requestArgs []string + omitHint bool +} + +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 + } + 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.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." + 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 { + 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 + } + } + 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] + 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 + } + } + 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) + } + } + 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 + } + 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." + } + 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) + } + 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 new file mode 100644 index 00000000..cc31d7a2 --- /dev/null +++ b/pkg/custom/file_commands_test.go @@ -0,0 +1,180 @@ +package custom + +import ( + "context" + "errors" + "io" + "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" +) + +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"}) }) +} + +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_failures_test.go b/pkg/custom/file_failures_test.go new file mode 100644 index 00000000..971d3f50 --- /dev/null +++ b/pkg/custom/file_failures_test.go @@ -0,0 +1,73 @@ +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 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 + 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") + 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", fileInvocation{display: "openai"})) + require.NotContains(t, out.String(), "Download it:", field) + } + } +} diff --git a/pkg/custom/file_invocation_unix_test.go b/pkg/custom/file_invocation_unix_test.go new file mode 100644 index 00000000..26b15ff0 --- /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(), "\nInspect 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_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_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 new file mode 100644 index 00000000..e4389a13 --- /dev/null +++ b/pkg/custom/file_results.go @@ -0,0 +1,185 @@ +package custom + +import ( + "fmt" + "io" + "path/filepath" + "strings" + "unicode/utf8" + + "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 quietOutput(opts.Context) { + // Quiet suppresses the receipt, while ordinary rendering keeps data. + return false, nil + } + if !validFileReceipt(value) { + return false, nil + } + invocation, _ := opts.Context.Value(fileInvocationKey{}).(fileInvocation) + return true, writeFileReceipt(out, value, fileReceiptShell(opts.Context, invocation), invocation) + 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 == "" || !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 + } + } + 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 + } + } + 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 + 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, 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 + 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 == "" || invocation.omitHint || strings.ContainsRune(id, 0) || !utf8.ValidString(id) { + return nil + } + // Reuse the existing shell quoting contracts, including control characters. + args := []string{"files", "get", id} + if strings.HasPrefix(id, "-") { + args = []string{"files", "get", "--file-id=" + id} + } + if value.Get("status").Str == "error" { + args = append(args, "--format", "json") + } + // 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, "\nInspect it: "+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.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.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 new file mode 100644 index 00000000..b8450d0e --- /dev/null +++ b/pkg/custom/file_results_test.go @@ -0,0 +1,239 @@ +package custom + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "os/exec" + "strings" + "testing" + "time" + + "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, fileInvocation{display: "openai"})) + 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, "", 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) + } +} + +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 + 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") + 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 := tc.id + if strings.HasPrefix(id, "-") { + id = "--file-id=" + id + } + want := append(requestArgs, "files", "get", id) + 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", 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", fileInvocation{display: "openai"}), context.Canceled) + require.Empty(t, out.String()) +} + +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"})) + _, 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 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 TestFileReceiptPowerShellInspectionUsesNoDestination(t *testing.T) { + 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 + 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() + 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, append(requestArgs, "files", "get", "file-example"), 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"} { + 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(), "Inspect it: "+invocation.display+" files get file-example") + } + } +} + +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. + 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")) +} diff --git a/pkg/custom/local_errors.go b/pkg/custom/local_errors.go index 761f0b7e..b9148ec5 100644 --- a/pkg/custom/local_errors.go +++ b/pkg/custom/local_errors.go @@ -113,6 +113,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..85447e34 --- /dev/null +++ b/scripts/demos/files-workflow/README.md @@ -0,0 +1,84 @@ +# 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. +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. + +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. + +## 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/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-discovery.sh b/scripts/demos/files-workflow/record-discovery.sh new file mode 100755 index 00000000..31855886 --- /dev/null +++ b/scripts/demos/files-workflow/record-discovery.sh @@ -0,0 +1,57 @@ +#!/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', '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') +(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" 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..5cd6c699 --- /dev/null +++ b/scripts/demos/files-workflow/validate.py @@ -0,0 +1,54 @@ +#!/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('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') + + +if __name__ == '__main__': + main()