diff --git a/README.md b/README.md index 8cb57a7..4cd73ae 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,46 @@ prism usage Each provider is fetched independently, so an unavailable login does not hide usage from the other providers. +## Non-interactive API calls + +Use `prism exec` when a shell pipeline or CI job needs one request through +Prism. The API type is explicit and the JSON request can come from stdin or a +file: + +```sh +cat request.json | prism exec --api chat --model gpt-4.1 --provider copilot +prism exec --api responses --body request.json --output-format text +``` + +The supported API mappings are: + +| `--api` | Endpoint | +| --- | --- | +| `chat` | `/v1/chat/completions` | +| `completions` | `/v1/completions` | +| `responses` | `/v1/responses` | +| `messages` | `/v1/messages` | + +Output is JSON by default. Use `--output-format text` for textual assistant +output or `--output-format stream-json` for one JSON object per SSE event. +`--json` is shorthand for the default JSON mode. `--model` overrides a model in +the request body, and `--provider` is sent as Prism's routing hint. + +For headless CI, provide the normal Circles credential through the environment +and keep it scoped to the command. For example, with a cvlt-backed reference: + +```dotenv +CIRCLES_AUTH_TOKEN=vlt://github.com/example-org/example-repo/PRISM_CI_CIRCLES_KEY +``` + +```sh +cvlt run --env-file=.cvlt.env -- prism exec --api responses --output-format json < request.json +``` + +The CLI never accepts credentials as command-line arguments or reads GitHub +OIDC variables directly. See `prism exec --help` for all options and the +documented exit-code classes. + ## Cursor Install the official Cursor Agent without replacing an existing diff --git a/internal/api/client_test.go b/internal/api/client_test.go index 82edbe5..834241c 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -80,3 +80,69 @@ func TestCredentialLifecycleUsesPrismAPIWithoutLeakingSecretsInURL(t *testing.T) t.Fatalf("usage = %#v, err = %v", usage, err) } } + +func TestInferenceMapsAPIPathAndProviderHeader(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/v1/messages" { + t.Errorf("path = %q", request.URL.Path) + } + if request.Header.Get("Authorization") != "Bearer test-token" { + t.Errorf("authorization = %q", request.Header.Get("Authorization")) + } + if request.Header.Get("X-Prism-Provider") != "anthropic" { + t.Errorf("provider = %q", request.Header.Get("X-Prism-Provider")) + } + response.Header().Set("Content-Type", "application/json") + _, _ = response.Write([]byte(`{"content":[{"type":"text","text":"ok"}]}`)) + })) + defer server.Close() + + client := Client{BaseURL: server.URL, Token: "test-token", HTTPClient: server.Client()} + response, err := client.Inference(context.Background(), InferenceRequest{ + API: "messages", Provider: "anthropic", Body: []byte(`{"model":"claude-test"}`), + }) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("status = %d", response.StatusCode) + } +} + +func TestInferencePathMapsEverySupportedAPI(t *testing.T) { + for apiName, want := range map[string]string{ + "chat": "/v1/chat/completions", + "completions": "/v1/completions", + "responses": "/v1/responses", + "messages": "/v1/messages", + } { + if got, ok := InferencePath(apiName); !ok || got != want { + t.Fatalf("InferencePath(%q) = %q, %v; want %q", apiName, got, ok, want) + } + } + if _, ok := InferencePath("unknown"); ok { + t.Fatal("unknown API was accepted") + } +} + +func TestInferenceRejectsNonHTTPSAndClearsLegacyTotalTimeout(t *testing.T) { + client := Client{BaseURL: "http://example.com", Token: "test-token"} + if _, err := client.Inference(context.Background(), InferenceRequest{API: "chat", Body: []byte(`{}`)}); err == nil { + t.Fatal("HTTP endpoint was accepted") + } + + server := httptest.NewTLSServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + response.Header().Set("Content-Type", "application/json") + _, _ = response.Write([]byte(`{}`)) + })) + defer server.Close() + legacy := *server.Client() + legacy.Timeout = 1 + client = Client{BaseURL: server.URL, Token: "test-token", HTTPClient: &legacy} + response, err := client.Inference(context.Background(), InferenceRequest{API: "chat", Body: []byte(`{}`)}) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() +} diff --git a/internal/api/inference.go b/internal/api/inference.go new file mode 100644 index 0000000..71571eb --- /dev/null +++ b/internal/api/inference.go @@ -0,0 +1,100 @@ +package api + +import ( + "bytes" + "context" + "errors" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" +) + +// InferenceRequest describes one raw Prism inference request. The caller owns +// response-body parsing because the four Prism APIs have different event and +// text shapes. +type InferenceRequest struct { + API string + Provider string + Body []byte +} + +const ( + InferenceConnectTimeout = 10 * time.Second + InferenceFirstByteTimeout = 30 * time.Second + InferenceIdleTimeout = 2 * time.Minute +) + +var inferencePaths = map[string]string{ + "chat": "/v1/chat/completions", + "completions": "/v1/completions", + "responses": "/v1/responses", + "messages": "/v1/messages", +} + +func InferencePath(apiName string) (string, bool) { + path, ok := inferencePaths[apiName] + return path, ok +} + +// Inference sends a request without a total timeout. The default transport +// limits connection establishment and response headers; stream readers must +// enforce the idle timeout while consuming the body. +func (c Client) Inference(ctx context.Context, request InferenceRequest) (*http.Response, error) { + path, ok := InferencePath(request.API) + if !ok { + return nil, errors.New("unsupported Prism API") + } + endpoint, err := url.Parse(c.BaseURL) + if err != nil || endpoint.Scheme != "https" || endpoint.Host == "" { + return nil, errors.New("Prism URL is invalid") + } + if strings.TrimSpace(c.Token) == "" || strings.ContainsAny(c.Token, " \t\r\n") { + return nil, errors.New("Circles credential is invalid") + } + requestURL := strings.TrimSuffix(c.BaseURL, "/") + path + httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL, bytes.NewReader(request.Body)) + if err != nil { + return nil, errors.New("could not create the Prism request") + } + httpRequest.Header.Set("Authorization", "Bearer "+c.Token) + httpRequest.Header.Set("Content-Type", "application/json") + if request.Provider != "" { + httpRequest.Header.Set("X-Prism-Provider", request.Provider) + } + + client := c.HTTPClient + if client == nil { + client = &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{Timeout: InferenceConnectTimeout, KeepAlive: 30 * time.Second}).DialContext, + TLSHandshakeTimeout: InferenceConnectTimeout, + ResponseHeaderTimeout: InferenceFirstByteTimeout, + IdleConnTimeout: 90 * time.Second, + }, + } + } else { + // A caller-provided client may have a legacy total timeout. Inference + // streams are allowed to run longer, so clear that one setting while + // preserving the caller's transport, jar, and redirect policy. + copy := *client + copy.Timeout = 0 + client = © + } + response, err := client.Do(httpRequest) + if err != nil { + return nil, err + } + return response, nil +} + +func CloseResponse(response *http.Response) { + if response == nil || response.Body == nil { + return + } + _, _ = io.Copy(io.Discard, response.Body) + _ = response.Body.Close() +} diff --git a/internal/cli/exec.go b/internal/cli/exec.go new file mode 100644 index 0000000..bc49f9b --- /dev/null +++ b/internal/cli/exec.go @@ -0,0 +1,661 @@ +package cli + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + credentials "github.com/circlesac/credentials/go" + "github.com/circlesac/prism-cli/internal/api" +) + +const ( + exitUsage = 2 + exitAuthentication = 3 + exitAuthorization = 4 + exitTransport = 5 + exitHTTP4xx = 6 + exitRateLimit = 7 + exitHTTP5xx = 8 + exitMalformed = 9 + exitInterrupted = 130 +) + +type commandError struct { + code int + err error +} + +func (e *commandError) Error() string { return e.err.Error() } +func (e *commandError) Unwrap() error { return e.err } + +// ExitCode returns the documented process exit code for a CLI error. +func ExitCode(err error) int { + var coded *commandError + if errors.As(err, &coded) { + return coded.code + } + return 1 +} + +func usageError(format string, args ...any) error { + return &commandError{code: exitUsage, err: fmt.Errorf(format, args...)} +} + +func authError(err error) error { + return &commandError{code: exitAuthentication, err: err} +} + +func transportError(err error) error { + return &commandError{code: exitTransport, err: err} +} + +func malformedError(err error) error { + return &commandError{code: exitMalformed, err: err} +} + +type execOptions struct { + apiName string + bodyPath string + bodySet bool + model string + provider string + outputFormat string + outputSet bool + jsonSet bool +} + +var resolveInferenceClient = prismInferenceClient + +func hasOption(args []string, option string) bool { + for _, argument := range args { + if argument == option { + return true + } + } + return false +} + +func printExecHelp(output io.Writer) { + fmt.Fprintln(output, `Usage: + prism exec --api chat|completions|responses|messages [options] + +Options: + --api Prism API wire format (required) + --body JSON request body; use - for stdin + --model Override the request model + --provider Send an X-Prism-Provider routing hint + --output-format text|json|stream-json Select stdout format (default: json) + --json Shorthand for --output-format json + +Without --body, the request body is read from stdin. Credentials are read from +the existing Circles profile or CIRCLES_AUTH_TOKEN/CRCL_AUTH_TOKEN.`) +} + +func runExecCommand(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) error { + return runExecCommandWithIO(ctx, args, os.Stdin, stdout, stderr) +} + +func runExecCommandWithIO(ctx context.Context, args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) error { + options, err := parseExecOptions(args) + if err != nil { + return err + } + body, err := readExecBody(options, stdin) + if err != nil { + return err + } + body, err = prepareExecBody(body, options) + if err != nil { + return err + } + client, err := resolveInferenceClient(ctx) + if err != nil { + return err + } + response, err := client.Inference(ctx, api.InferenceRequest{ + API: options.apiName, + Provider: options.provider, + Body: body, + }) + if err != nil { + if errors.Is(err, context.Canceled) { + return &commandError{code: exitInterrupted, err: errors.New("request interrupted")} + } + return transportError(errors.New("Prism could not be reached")) + } + defer api.CloseResponse(response) + if response.StatusCode < 200 || response.StatusCode >= 300 { + return inferenceHTTPError(response) + } + + payload, err := readResponseIdle(ctx, response.Body, api.InferenceIdleTimeout) + if err != nil { + if errors.Is(err, context.Canceled) { + return &commandError{code: exitInterrupted, err: errors.New("request interrupted")} + } + return transportError(err) + } + stream := isEventStream(response.Header.Get("Content-Type"), payload) + switch options.outputFormat { + case "json": + if stream { + return malformedError(errors.New("JSON output cannot represent an SSE response; use --output-format stream-json or text")) + } + if !json.Valid(payload) { + return malformedError(errors.New("Prism returned invalid JSON")) + } + _, err = stdout.Write(payload) + return err + case "text": + if stream { + return writeStreamText(stdout, options.apiName, payload) + } + return writeJSONText(stdout, options.apiName, payload) + case "stream-json": + if !stream { + return malformedError(errors.New("stream-json output requires an SSE response")) + } + return writeStreamJSON(stdout, options.apiName, payload) + default: + return usageError("unsupported output format %q", options.outputFormat) + } +} + +func parseExecOptions(args []string) (execOptions, error) { + options := execOptions{outputFormat: "json"} + for index := 0; index < len(args); index++ { + argument := args[index] + switch { + case argument == "--api": + if index+1 >= len(args) || args[index+1] == "" { + return execOptions{}, usageError("--api requires a value") + } + options.apiName = strings.ToLower(args[index+1]) + index++ + case strings.HasPrefix(argument, "--api="): + options.apiName = strings.ToLower(strings.TrimPrefix(argument, "--api=")) + case argument == "--body": + if options.bodySet || index+1 >= len(args) || args[index+1] == "" { + return execOptions{}, usageError("--body requires one path (or - for stdin)") + } + options.bodyPath = args[index+1] + options.bodySet = true + index++ + case strings.HasPrefix(argument, "--body="): + if options.bodySet { + return execOptions{}, usageError("--body may be supplied only once") + } + options.bodyPath = strings.TrimPrefix(argument, "--body=") + if options.bodyPath == "" { + return execOptions{}, usageError("--body requires one path (or - for stdin)") + } + options.bodySet = true + case argument == "--model": + if index+1 >= len(args) || args[index+1] == "" { + return execOptions{}, usageError("--model requires a value") + } + options.model = args[index+1] + index++ + case strings.HasPrefix(argument, "--model="): + options.model = strings.TrimPrefix(argument, "--model=") + case argument == "--provider": + if index+1 >= len(args) || args[index+1] == "" { + return execOptions{}, usageError("--provider requires a value") + } + options.provider = args[index+1] + index++ + case strings.HasPrefix(argument, "--provider="): + options.provider = strings.TrimPrefix(argument, "--provider=") + case argument == "--output-format": + if index+1 >= len(args) || args[index+1] == "" { + return execOptions{}, usageError("--output-format requires text, json, or stream-json") + } + options.outputFormat = args[index+1] + options.outputSet = true + index++ + case strings.HasPrefix(argument, "--output-format="): + options.outputFormat = strings.TrimPrefix(argument, "--output-format=") + options.outputSet = true + case argument == "--json": + if options.jsonSet || options.outputSet { + return execOptions{}, usageError("--json conflicts with another output format") + } + options.jsonSet = true + options.outputFormat = "json" + case argument == "--help" || argument == "-h": + return execOptions{}, usageError("Usage: prism exec --api chat|completions|responses|messages [--body |-] [--model ] [--provider ] [--output-format text|json|stream-json]") + case strings.HasPrefix(argument, "-"): + return execOptions{}, usageError("unknown option %q", argument) + default: + return execOptions{}, usageError("unexpected argument %q", argument) + } + } + if options.apiName == "" { + return execOptions{}, usageError("--api is required") + } + if _, ok := api.InferencePath(options.apiName); !ok { + return execOptions{}, usageError("unsupported API %q; use chat, completions, responses, or messages", options.apiName) + } + if options.outputFormat != "text" && options.outputFormat != "json" && options.outputFormat != "stream-json" { + return execOptions{}, usageError("unsupported output format %q", options.outputFormat) + } + return options, nil +} + +func readExecBody(options execOptions, stdin io.Reader) ([]byte, error) { + if options.bodySet && options.bodyPath != "-" { + if file, ok := stdin.(*os.File); ok { + if info, err := file.Stat(); err == nil && info.Mode()&os.ModeCharDevice == 0 { + return nil, usageError("request body was supplied by both --body and stdin") + } + } + body, err := os.ReadFile(options.bodyPath) + if err != nil { + return nil, usageError("could not read request body: %v", err) + } + return body, nil + } + body, err := io.ReadAll(stdin) + if err != nil { + return nil, usageError("could not read request body: %v", err) + } + if len(bytes.TrimSpace(body)) == 0 { + return nil, usageError("request body is empty") + } + return body, nil +} + +func prepareExecBody(body []byte, options execOptions) ([]byte, error) { + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil || payload == nil { + return nil, malformedError(errors.New("request body must be a JSON object")) + } + if options.model != "" { + payload["model"] = options.model + } + if options.outputFormat == "stream-json" { + payload["stream"] = true + } + return json.Marshal(payload) +} + +func prismInferenceClient(ctx context.Context) (api.Client, error) { + provider, err := credentials.New() + if err != nil { + return api.Client{}, authError(err) + } + credential, err := provider.Resolve(ctx) + if err != nil { + return api.Client{}, authError(err) + } + baseURL := strings.TrimSpace(os.Getenv("PRISM_BASE_URL")) + if baseURL == "" { + var profile *credentials.StoredProfile + if credential.Source.Type == credentials.SourceProfile { + profile, err = provider.GetProfile(ctx) + if err != nil { + return api.Client{}, authError(err) + } + } + baseURL, err = prismURLForProfile(profile) + if err != nil { + return api.Client{}, usageError("could not determine Prism endpoint: %v", err) + } + } else { + baseURL = strings.TrimSuffix(baseURL, "/") + if baseURL != "https://prism.circles.ac" && baseURL != "https://prism-dev.circles.ac" { + return api.Client{}, usageError("PRISM_BASE_URL must be https://prism.circles.ac or https://prism-dev.circles.ac") + } + } + return api.Client{BaseURL: strings.TrimSuffix(baseURL, "/"), Token: credential.Value}, nil +} + +func inferenceHTTPError(response *http.Response) error { + body, _ := io.ReadAll(io.LimitReader(response.Body, 64<<10)) + message := strings.TrimSpace(string(body)) + if message == "" { + message = http.StatusText(response.StatusCode) + } + var code int + switch { + case response.StatusCode == http.StatusUnauthorized: + code = exitAuthentication + case response.StatusCode == http.StatusForbidden: + code = exitAuthorization + case response.StatusCode == http.StatusTooManyRequests: + code = exitRateLimit + case response.StatusCode >= 400 && response.StatusCode < 500: + code = exitHTTP4xx + case response.StatusCode >= 500: + code = exitHTTP5xx + default: + code = exitTransport + } + return &commandError{code: code, err: fmt.Errorf("Prism returned HTTP %d: %s", response.StatusCode, compactResponseError(message))} +} + +func compactResponseError(message string) string { + var structured struct { + Error any `json:"error"` + } + if json.Unmarshal([]byte(message), &structured) == nil && structured.Error != nil { + if text, ok := structured.Error.(string); ok && text != "" { + return text + } + encoded, _ := json.Marshal(structured.Error) + return string(encoded) + } + return strings.Join(strings.Fields(message), " ") +} + +func readResponseIdle(ctx context.Context, reader io.Reader, idle time.Duration) ([]byte, error) { + type result struct { + data []byte + err error + } + results := make(chan result, 1) + go func() { + buffer := make([]byte, 32*1024) + for { + count, err := reader.Read(buffer) + if count > 0 { + chunk := append([]byte(nil), buffer[:count]...) + results <- result{data: chunk} + } + if err != nil { + results <- result{err: err} + return + } + } + }() + var output bytes.Buffer + timer := time.NewTimer(idle) + defer timer.Stop() + for { + select { + case <-ctx.Done(): + if closer, ok := reader.(io.Closer); ok { + _ = closer.Close() + } + return nil, ctx.Err() + case <-timer.C: + if closer, ok := reader.(io.Closer); ok { + _ = closer.Close() + } + return nil, errors.New("Prism stream idle timeout") + case result := <-results: + if len(result.data) > 0 { + _, _ = output.Write(result.data) + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + timer.Reset(idle) + } + if result.err != nil { + if errors.Is(result.err, io.EOF) { + return output.Bytes(), nil + } + return nil, result.err + } + } + } +} + +func isEventStream(contentType string, payload []byte) bool { + trimmed := bytes.TrimSpace(payload) + return strings.Contains(strings.ToLower(contentType), "text/event-stream") || bytes.HasPrefix(trimmed, []byte("data:")) || bytes.HasPrefix(trimmed, []byte("event:")) +} + +type sseEvent struct { + Event string + Data string +} + +func parseSSE(payload []byte) ([]sseEvent, error) { + if !bytes.HasSuffix(payload, []byte("\n\n")) && !bytes.HasSuffix(payload, []byte("\r\n\r\n")) { + return nil, malformedError(errors.New("SSE response ended with a truncated event")) + } + scanner := bufio.NewScanner(bytes.NewReader(payload)) + scanner.Buffer(make([]byte, 4096), 4<<20) + var events []sseEvent + var eventName string + var data []string + flush := func() { + if len(data) == 0 && eventName == "" { + return + } + events = append(events, sseEvent{Event: eventName, Data: strings.Join(data, "\n")}) + eventName = "" + data = nil + } + for scanner.Scan() { + line := strings.TrimSuffix(scanner.Text(), "\r") + if line == "" { + flush() + continue + } + if strings.HasPrefix(line, ":") { + continue + } + switch { + case strings.HasPrefix(line, "event:"): + eventName = strings.TrimSpace(strings.TrimPrefix(line, "event:")) + case strings.HasPrefix(line, "data:"): + data = append(data, strings.TrimPrefix(strings.TrimPrefix(line, "data:"), " ")) + case strings.HasPrefix(line, "id:") || strings.HasPrefix(line, "retry:"): + continue + default: + return nil, malformedError(fmt.Errorf("malformed SSE line %q", line)) + } + } + if err := scanner.Err(); err != nil { + return nil, malformedError(errors.New("could not parse SSE response")) + } + flush() + if len(events) == 0 { + return nil, malformedError(errors.New("Prism returned an empty SSE response")) + } + return events, nil +} + +func decodeEventData(data string) (any, error) { + if data == "[DONE]" { + return "[DONE]", nil + } + var value any + if json.Unmarshal([]byte(data), &value) == nil { + return value, nil + } + return nil, malformedError(errors.New("SSE event data is not valid JSON")) +} + +func eventIsTerminal(event sseEvent) bool { + if event.Data == "[DONE]" { + return true + } + name := strings.ToLower(event.Event) + return strings.Contains(name, "done") || strings.Contains(name, "completed") || strings.Contains(name, "message_stop") +} + +func writeStreamJSON(stdout io.Writer, apiName string, payload []byte) error { + events, err := parseSSE(payload) + if err != nil { + return err + } + terminal := false + for _, event := range events { + name := event.Event + if name == "" { + name = "message" + } + data, decodeErr := decodeEventData(event.Data) + if decodeErr != nil { + return decodeErr + } + line, marshalErr := json.Marshal(map[string]any{ + "api": apiName, + "event": name, + "data": data, + }) + if marshalErr != nil { + return malformedError(errors.New("could not encode stream event")) + } + if _, err := fmt.Fprintln(stdout, string(line)); err != nil { + return err + } + terminal = terminal || eventIsTerminal(event) + } + if !terminal { + return malformedError(errors.New("SSE response ended without a terminal event")) + } + return nil +} + +func writeJSONText(stdout io.Writer, apiName string, payload []byte) error { + var value any + if err := json.Unmarshal(payload, &value); err != nil { + return malformedError(errors.New("Prism returned invalid JSON")) + } + text := extractText(apiName, value) + _, err := io.WriteString(stdout, text) + return err +} + +func writeStreamText(stdout io.Writer, apiName string, payload []byte) error { + events, err := parseSSE(payload) + if err != nil { + return err + } + terminal := false + for _, event := range events { + terminal = terminal || eventIsTerminal(event) + if event.Data == "[DONE]" { + continue + } + var value any + if json.Unmarshal([]byte(event.Data), &value) != nil { + return malformedError(errors.New("SSE event data is not valid JSON")) + } + if _, err := io.WriteString(stdout, extractStreamText(apiName, value)); err != nil { + return err + } + } + if !terminal { + return malformedError(errors.New("SSE response ended without a terminal event")) + } + return nil +} + +func extractText(apiName string, value any) string { + root, ok := value.(map[string]any) + if !ok { + return "" + } + switch apiName { + case "chat": + return firstChoiceText(root, "message", "content") + case "completions": + return firstChoiceText(root, "", "text") + case "responses": + if text, ok := root["output_text"].(string); ok { + return text + } + return recursiveText(root["output"]) + case "messages": + return recursiveText(root["content"]) + default: + return "" + } +} + +func firstChoiceText(root map[string]any, parentKey string, textKey string) string { + choices, _ := root["choices"].([]any) + var output strings.Builder + for _, choice := range choices { + item, _ := choice.(map[string]any) + if parentKey != "" { + item, _ = item[parentKey].(map[string]any) + } + if item == nil { + continue + } + if text, ok := item[textKey].(string); ok { + output.WriteString(text) + } else { + output.WriteString(recursiveText(item[textKey])) + } + } + return output.String() +} + +func recursiveText(value any) string { + var output strings.Builder + var walk func(any) + walk = func(current any) { + switch item := current.(type) { + case string: + output.WriteString(item) + case []any: + for _, child := range item { + walk(child) + } + case map[string]any: + if text, ok := item["text"].(string); ok { + output.WriteString(text) + return + } + if text, ok := item["output_text"].(string); ok { + output.WriteString(text) + return + } + if content, ok := item["content"]; ok { + walk(content) + } + } + } + walk(value) + return output.String() +} + +func extractStreamText(apiName string, value any) string { + root, _ := value.(map[string]any) + if root == nil { + return "" + } + switch apiName { + case "chat": + return firstChoiceText(root, "delta", "content") + case "completions": + return firstChoiceText(root, "", "text") + case "responses": + if text, ok := root["delta"].(string); ok { + return text + } + if text, ok := root["output_text"]; ok { + return recursiveText(text) + } + if response, ok := root["response"]; ok { + return extractStreamText(apiName, response) + } + case "messages": + if delta, ok := root["delta"]; ok { + return recursiveText(delta) + } + if block, ok := root["content_block"]; ok { + return recursiveText(block) + } + } + return "" +} diff --git a/internal/cli/exec_test.go b/internal/cli/exec_test.go new file mode 100644 index 0000000..145798f --- /dev/null +++ b/internal/cli/exec_test.go @@ -0,0 +1,156 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/circlesac/prism-cli/internal/api" +) + +func TestParseExecOptionsRequiresAPIAndRejectsConflictingOutput(t *testing.T) { + if _, err := parseExecOptions(nil); err == nil || ExitCode(err) != exitUsage { + t.Fatalf("missing api error = %v", err) + } + if _, err := parseExecOptions([]string{"--api", "chat", "--output-format", "text", "--json"}); err == nil || ExitCode(err) != exitUsage { + t.Fatalf("conflicting output error = %v", err) + } + options, err := parseExecOptions([]string{"--api=responses", "--body=-", "--model", "gpt-test", "--provider", "chatgpt", "--output-format", "stream-json"}) + if err != nil { + t.Fatal(err) + } + if options.apiName != "responses" || options.bodyPath != "-" || options.model != "gpt-test" || options.provider != "chatgpt" || options.outputFormat != "stream-json" { + t.Fatalf("options = %#v", options) + } +} + +func TestExecSendsMappedEndpointHeadersAndModelOverride(t *testing.T) { + var requestBody map[string]any + var requestPath string + var providerHeader string + server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + requestPath = request.URL.Path + providerHeader = request.Header.Get("X-Prism-Provider") + if err := json.NewDecoder(request.Body).Decode(&requestBody); err != nil { + t.Fatal(err) + } + writer.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(writer, `{"id":"resp_1","output_text":"hello"}`) + })) + defer server.Close() + + original := resolveInferenceClient + resolveInferenceClient = func(context.Context) (api.Client, error) { + return api.Client{BaseURL: server.URL, Token: "test-token", HTTPClient: server.Client()}, nil + } + defer func() { resolveInferenceClient = original }() + + var output bytes.Buffer + err := runExecCommandWithIO(context.Background(), []string{ + "--api", "responses", "--model", "gpt-override", "--provider", "chatgpt", + }, strings.NewReader(`{"model":"gpt-original","input":"hello"}`), &output, io.Discard) + if err != nil { + t.Fatal(err) + } + if requestPath != "/v1/responses" || providerHeader != "chatgpt" || requestBody["model"] != "gpt-override" { + t.Fatalf("path=%q provider=%q body=%#v", requestPath, providerHeader, requestBody) + } + if output.String() != `{"id":"resp_1","output_text":"hello"}` { + t.Fatalf("output = %q", output.String()) + } +} + +func TestExecTextAndStreamJSONOutputs(t *testing.T) { + original := resolveInferenceClient + defer func() { resolveInferenceClient = original }() + + server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(writer, "event: response.output_text.delta\ndata: {\"delta\":\"hel\"}\n\n") + _, _ = io.WriteString(writer, "event: response.output_text.delta\ndata: {\"delta\":\"lo\"}\n\n") + _, _ = io.WriteString(writer, "data: [DONE]\n\n") + })) + defer server.Close() + resolveInferenceClient = func(context.Context) (api.Client, error) { + return api.Client{BaseURL: server.URL, Token: "test-token", HTTPClient: server.Client()}, nil + } + + var text bytes.Buffer + if err := runExecCommandWithIO(context.Background(), []string{"--api", "responses", "--output-format", "text"}, strings.NewReader(`{"input":"hello"}`), &text, io.Discard); err != nil { + t.Fatal(err) + } + if text.String() != "hello" { + t.Fatalf("text = %q", text.String()) + } + + var stream bytes.Buffer + if err := runExecCommandWithIO(context.Background(), []string{"--api", "responses", "--output-format", "stream-json"}, strings.NewReader(`{"input":"hello"}`), &stream, io.Discard); err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(stream.String()), "\n") + if len(lines) != 3 || !strings.Contains(lines[0], `"api":"responses"`) || !strings.Contains(lines[0], `"event":"response.output_text.delta"`) || !strings.Contains(lines[2], `"data":"[DONE]"`) { + t.Fatalf("stream lines = %#v", lines) + } +} + +func TestExecRejectsSSEInJSONModeAndMissingTerminalEvent(t *testing.T) { + original := resolveInferenceClient + defer func() { resolveInferenceClient = original }() + server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(writer, "data: {\"delta\":\"partial\"}\n\n") + })) + defer server.Close() + resolveInferenceClient = func(context.Context) (api.Client, error) { + return api.Client{BaseURL: server.URL, Token: "test-token", HTTPClient: server.Client()}, nil + } + for _, format := range []string{"json", "stream-json"} { + var output bytes.Buffer + err := runExecCommandWithIO(context.Background(), []string{"--api", "chat", "--output-format", format}, strings.NewReader(`{"messages":[]}`), &output, io.Discard) + if err == nil || ExitCode(err) != exitMalformed { + t.Fatalf("format=%s err=%v", format, err) + } + } +} + +func TestExecHTTPFailuresUseStableExitCodes(t *testing.T) { + original := resolveInferenceClient + defer func() { resolveInferenceClient = original }() + for status, wantCode := range map[int]int{ + http.StatusUnauthorized: exitAuthentication, + http.StatusForbidden: exitAuthorization, + http.StatusBadRequest: exitHTTP4xx, + http.StatusTooManyRequests: exitRateLimit, + http.StatusInternalServerError: exitHTTP5xx, + } { + server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(status) + _, _ = io.WriteString(writer, `{"error":"test failure"}`) + })) + resolveInferenceClient = func(context.Context) (api.Client, error) { + return api.Client{BaseURL: server.URL, Token: "test-token", HTTPClient: server.Client()}, nil + } + var output bytes.Buffer + err := runExecCommandWithIO(context.Background(), []string{"--api", "chat"}, strings.NewReader(`{"messages":[]}`), &output, io.Discard) + server.Close() + if err == nil || ExitCode(err) != wantCode { + t.Fatalf("status=%d err=%v code=%d want=%d", status, err, ExitCode(err), wantCode) + } + } +} + +func TestExecHelpIsPublicAndStable(t *testing.T) { + var output bytes.Buffer + printExecHelp(&output) + for _, value := range []string{"prism exec", "--api", "--body", "--output-format", "CIRCLES_AUTH_TOKEN"} { + if !strings.Contains(output.String(), value) { + t.Fatalf("help missing %q: %s", value, output.String()) + } + } +} diff --git a/internal/cli/run.go b/internal/cli/run.go index 1c47445..5b27d9e 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -73,6 +73,13 @@ func Run( if args[0] == "usage" { return runCombinedUsage(ctx, args[1:], stdout) } + if args[0] == "exec" { + if hasOption(args[1:], "--help") || hasOption(args[1:], "-h") { + printExecHelp(stdout) + return nil + } + return runExecCommand(ctx, args[1:], stdout, stderr) + } if args[0] == "claude" { return runClaudeCommand(ctx, args[1:], stdout, stderr) } @@ -788,6 +795,7 @@ func printHelp(output io.Writer) { fmt.Fprintln(output, `Prism provider credential manager and client configuration tool Usage: + prism exec --api chat|completions|responses|messages [options] prism claude [--account ] [claude arguments...] prism codex enable|disable|status prism cursor [--account ] [cursor arguments...] diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index a849952..31860a8 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -21,7 +21,7 @@ func TestHelpDocumentsSupportedCommandsWithoutInternalDetails(t *testing.T) { t.Fatal(err) } output := stdout.String() - for _, command := range []string{"prism claude", "prism codex", "prism cursor", "prism usage", "chatgpt usage", "anthropic auth login", "opencode-go usage", "auth login", "auth list", "auth remove"} { + for _, command := range []string{"prism exec", "prism claude", "prism codex", "prism cursor", "prism usage", "chatgpt usage", "anthropic auth login", "opencode-go usage", "auth login", "auth list", "auth remove"} { if !strings.Contains(output, command) { t.Fatalf("help did not contain %q", command) } diff --git a/main.go b/main.go index 5795b99..1896a31 100644 --- a/main.go +++ b/main.go @@ -11,6 +11,6 @@ import ( func main() { if err := cli.Run(context.Background(), os.Args[1:], os.Stdout, os.Stderr, Version); err != nil { fmt.Fprintln(os.Stderr, "prism:", err) - os.Exit(1) + os.Exit(cli.ExitCode(err)) } }