From 4f37374db9fa0d06421b5a89feea25e7298460ec Mon Sep 17 00:00:00 2001 From: YG Park Date: Thu, 17 Sep 2026 17:59:38 +0900 Subject: [PATCH] feat: add --json output to prism usage prism usage --json prints a machine-readable document with generated_at and a providers array in table order; each provider carries provider, display_name, accounts (the API UsageAccount objects as-is, nil limits normalized to []) and error. prism usage --json prints one provider object, including the self-parsed cursor and gemini usage paths. --json is rejected on other commands. Verified with go test -mod=vendor -race ./... and a live prism usage --json run against all six providers. Co-Authored-By: Claude Fable 5.1 --- README.md | 37 +++++++ internal/cli/cursor.go | 18 +++- internal/cli/gemini.go | 9 +- internal/cli/gemini_usage.go | 5 +- internal/cli/run.go | 64 +++++++++---- internal/cli/run_test.go | 180 +++++++++++++++++++++++++++++++++++ internal/cli/usage_json.go | 68 +++++++++++++ 7 files changed, 353 insertions(+), 28 deletions(-) create mode 100644 internal/cli/usage_json.go diff --git a/README.md b/README.md index 7b3d146..fb3b8b3 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,43 @@ prism usage Each provider is fetched independently, so an unavailable login does not hide usage from the other providers. +Add `--json` to print machine-readable output instead of the table. The document +has `generated_at` (RFC 3339, UTC) and a `providers` array in the same order as +the table, where each entry has `provider`, `display_name`, `accounts`, and +`error`. A provider that could not be fetched has an empty `accounts` array and +an `error` object; every other provider has `error: null`. The same flag works +for a single provider, such as `prism chatgpt usage --json`, which prints one +provider object: + +```json +{ + "provider": "chatgpt", + "display_name": "ChatGPT", + "accounts": [ + { + "id": "cred_example", + "name": "person@example.com", + "plan": "pro", + "observed_at": "2026-09-17T08:30:00Z", + "status": "fresh", + "limits": [ + { + "name": "default", + "window": "7d", + "used_percent": 12.5, + "remaining_percent": 87.5, + "limit_reached": false, + "reset_at": "2026-09-22T00:00:00Z", + "window_seconds": 604800 + } + ], + "error": null + } + ], + "error": null +} +``` + ChatGPT accounts also show available earned reset credits and each credit's expiry time when the provider returns that detail. diff --git a/internal/cli/cursor.go b/internal/cli/cursor.go index b8222f8..04fc55f 100644 --- a/internal/cli/cursor.go +++ b/internal/cli/cursor.go @@ -46,13 +46,25 @@ func runCursorCommand(ctx context.Context, args []string, stdout io.Writer, stde if err != nil { return err } - if len(remaining) != 0 { - return errors.New("usage: prism cursor usage [--account ]") + asJSON := false + unexpected := make([]string, 0, len(remaining)) + for _, argument := range remaining { + if argument == "--json" { + asJSON = true + continue + } + unexpected = append(unexpected, argument) + } + if len(unexpected) != 0 { + return errors.New("usage: prism cursor usage [--account ] [--json]") } usage, err := fetchAllCursorUsage(ctx, account) if err != nil { return err } + if asJSON { + return printUsageJSON(stdout, usageJSONProviderFor("cursor", usage, nil)) + } printUsage(stdout, usage) return nil } @@ -398,7 +410,7 @@ func printCursorHelp(output io.Writer) { prism cursor auth list|remove prism cursor status prism cursor models - prism cursor usage [--account ] + prism cursor usage [--account ] [--json] prism cursor [--account ] [Cursor Agent arguments...] Install and run the official Cursor Agent without replacing ~/.local/bin/agent. diff --git a/internal/cli/gemini.go b/internal/cli/gemini.go index c4c8962..a24dd0e 100644 --- a/internal/cli/gemini.go +++ b/internal/cli/gemini.go @@ -27,10 +27,11 @@ func isGeminiCLIInvocation([]string) bool { func runGeminiCommand(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) error { if len(args) > 0 && args[0] == "usage" { - if len(args) != 1 { - return errors.New("usage: prism gemini usage") + asJSON := len(args) == 2 && args[1] == "--json" + if len(args) != 1 && !asJSON { + return errors.New("usage: prism gemini usage [--json]") } - return runGeminiUsage(ctx, stdout, stderr) + return runGeminiUsage(ctx, stdout, stderr, asJSON) } if len(args) > 0 && args[0] == "auth" { return errors.New("Antigravity CLI signs in automatically on the first 'prism gemini' run; it has no auth subcommand") @@ -174,7 +175,7 @@ func withDefaultGeminiModel(args []string) []string { func printGeminiHelp(output io.Writer) { _, _ = fmt.Fprintln(output, `Usage: prism gemini [Antigravity CLI arguments...] - prism gemini usage + prism gemini usage [--json] Runs the official Antigravity CLI (agy) inside the Prism command surface. The signed-in Google subscription remains managed by agy; Prism never copies diff --git a/internal/cli/gemini_usage.go b/internal/cli/gemini_usage.go index 1f8a960..3f1aaf5 100644 --- a/internal/cli/gemini_usage.go +++ b/internal/cli/gemini_usage.go @@ -34,11 +34,14 @@ type antigravityUsageEnvelope struct { var fetchGeminiUsage = fetchAntigravityUsage -func runGeminiUsage(ctx context.Context, stdout io.Writer, stderr io.Writer) error { +func runGeminiUsage(ctx context.Context, stdout io.Writer, stderr io.Writer, asJSON bool) error { usage, err := fetchGeminiUsage(ctx) if err != nil { return err } + if asJSON { + return printUsageJSON(stdout, usageJSONProviderFor("gemini", usage, nil)) + } printUsage(stdout, usage) return nil } diff --git a/internal/cli/run.go b/internal/cli/run.go index c1ad20a..32ab92d 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -55,6 +55,7 @@ type commonOptions struct { confirm bool providerAccountID string ownerID string + json bool help bool } @@ -145,6 +146,9 @@ func Run( if err != nil { return err } + if options.json { + return printUsageJSON(stdout, usageJSONProviderFor(providerName, usage, nil)) + } printUsage(stdout, usage) return nil } @@ -223,14 +227,14 @@ func runCombinedUsage(ctx context.Context, args []string, output io.Writer) erro return err } if options.help { - fmt.Fprintln(output, "Usage:\n prism usage [--profile ]") + fmt.Fprintln(output, "Usage:\n prism usage [--profile ] [--json]") return nil } if len(positionals) != 0 { return fmt.Errorf("unexpected argument %q", positionals[0]) } if options.name != "" || options.providerAccountID != "" || options.ownerID != "" { - return errors.New("usage accepts only --profile") + return errors.New("usage accepts only --profile and --json") } type usageResult struct { @@ -269,32 +273,47 @@ func runCombinedUsage(ctx context.Context, args []string, output io.Writer) erro }() results := []struct { - name string - result usageResult + provider string + result usageResult }{ - {name: "ChatGPT", result: <-chatGPTResults}, - {name: "Claude", result: <-anthropicResults}, - {name: "Copilot", result: <-copilotResults}, - {name: "OpenCode", result: <-openCodeResults}, - {name: "Cursor", result: <-cursorResults}, - {name: "Gemini", result: <-geminiResults}, + {provider: "chatgpt", result: <-chatGPTResults}, + {provider: "anthropic", result: <-anthropicResults}, + {provider: "copilot", result: <-copilotResults}, + {provider: "opencode-go", result: <-openCodeResults}, + {provider: "cursor", result: <-cursorResults}, + {provider: "gemini", result: <-geminiResults}, } succeeded := 0 var failures []string providers := make([]usageTableProvider, 0, len(results)) + document := usageJSONDocument{ + GeneratedAt: time.Now().UTC().Format(time.RFC3339), + Providers: make([]usageJSONProvider, 0, len(results)), + } for _, provider := range results { + displayName := usageProviderDisplayName(provider.provider) providers = append(providers, usageTableProvider{ - name: provider.name, + name: displayName, usage: provider.result.usage, err: provider.result.err, }) + document.Providers = append( + document.Providers, + usageJSONProviderFor(provider.provider, provider.result.usage, provider.result.err), + ) if provider.result.err != nil { - failures = append(failures, provider.name) + failures = append(failures, displayName) continue } succeeded++ } - printUsageTableAt(output, providers, time.Now(), true) + if options.json { + if err := printUsageJSON(output, document); err != nil { + return err + } + } else { + printUsageTableAt(output, providers, time.Now(), true) + } if succeeded == 0 { return fmt.Errorf("usage is unavailable for %s", joinNames(failures)) } @@ -302,6 +321,9 @@ func runCombinedUsage(ctx context.Context, args []string, output io.Writer) erro } func validateCommand(provider string, command string, positionals []string, options commonOptions) error { + if options.json && command != "usage" { + return errors.New("--json is accepted only by 'prism usage' and 'prism usage'") + } switch command { case "usage": if provider != "chatgpt" && provider != "anthropic" && provider != "copilot" && provider != "opencode-go" && provider != "gemini" { @@ -311,7 +333,7 @@ func validateCommand(provider string, command string, positionals []string, opti return fmt.Errorf("unexpected argument %q", positionals[0]) } if options.name != "" || options.providerAccountID != "" || options.ownerID != "" { - return errors.New("usage accepts only --profile") + return errors.New("usage accepts only --profile and --json") } case "reset": if provider != "chatgpt" { @@ -854,6 +876,8 @@ func parseCommonOptions(args []string) (commonOptions, []string, error) { options.creditID = strings.TrimPrefix(argument, "--credit-id=") case argument == "--confirm": options.confirm = true + case argument == "--json": + options.json = true case argument == "--provider-account-id": index++ if index >= len(args) || args[index] == "" { @@ -899,11 +923,11 @@ Usage: prism claude [--account ] [claude arguments...] prism codex enable|disable|status prism cursor [--account ] [cursor arguments...] - prism usage [--profile ] - prism chatgpt usage [--profile ] + prism usage [--profile ] [--json] + prism chatgpt usage [--profile ] [--json] prism chatgpt reset --account [--credit-id ] --confirm [--profile ] - prism copilot usage [--profile ] - prism opencode-go usage + prism copilot usage [--profile ] [--json] + prism opencode-go usage [--json] prism chatgpt auth login [--profile ] prism anthropic auth login [--profile ] prism claude login [--profile ] @@ -946,8 +970,8 @@ proxy Antigravity OAuth credentials.`) func printProviderUsageHelp(output io.Writer, provider string) { if provider == "chatgpt" { - fmt.Fprintln(output, "Usage:\n prism chatgpt usage [--profile ]\n prism chatgpt reset --account [--credit-id ] --confirm [--profile ]") + fmt.Fprintln(output, "Usage:\n prism chatgpt usage [--profile ] [--json]\n prism chatgpt reset --account [--credit-id ] --confirm [--profile ]") return } - fmt.Fprintf(output, "Usage:\n prism %s usage [--profile ]\n", provider) + fmt.Fprintf(output, "Usage:\n prism %s usage [--profile ] [--json]\n", provider) } diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index 3933bd4..da37e29 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -3,6 +3,7 @@ package cli import ( "bytes" "context" + "encoding/json" "errors" "os" "path/filepath" @@ -654,3 +655,182 @@ func TestOtherProviderRemovalMessageIsUnchanged(t *testing.T) { t.Fatalf("output = %q", output.String()) } } + +func TestCombinedUsageJSONReportsAccountsAndProviderErrors(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + originalChatGPT := fetchChatGPTUsage + originalAnthropic := fetchAnthropicUsage + originalCopilot := fetchCopilotUsage + originalOpenCode := fetchOpenCodeGoUsage + originalCursor := fetchCursorUsage + originalGemini := fetchGeminiUsage + defer func() { + fetchChatGPTUsage = originalChatGPT + fetchAnthropicUsage = originalAnthropic + fetchCopilotUsage = originalCopilot + fetchOpenCodeGoUsage = originalOpenCode + fetchCursorUsage = originalCursor + fetchGeminiUsage = originalGemini + }() + plan := "pro" + resetAt := "2026-09-22T00:00:00Z" + fetchChatGPTUsage = func(context.Context, commonOptions) (api.ProviderUsage, error) { + return api.ProviderUsage{Provider: "chatgpt", Accounts: []api.UsageAccount{{ + ID: "01j00000000000000000000001", Name: "person@example.com", Plan: &plan, + ObservedAt: "2026-09-17T08:30:00Z", Status: "fresh", + Limits: []api.UsageLimit{{Name: "default", Window: "7d", UsedPercent: 12.5, RemainingPercent: 87.5, ResetAt: &resetAt}}, + }}}, nil + } + fetchAnthropicUsage = func(context.Context, commonOptions) (api.ProviderUsage, error) { + return api.ProviderUsage{Provider: "anthropic"}, nil + } + fetchCopilotUsage = func(context.Context, commonOptions) (api.ProviderUsage, error) { + return api.ProviderUsage{Provider: "copilot"}, nil + } + fetchOpenCodeGoUsage = func(context.Context) (api.ProviderUsage, error) { + return api.ProviderUsage{}, errors.New("OpenCode returned malformed Go usage data") + } + fetchCursorUsage = func(context.Context, prismcursor.UsageOptions) (api.ProviderUsage, error) { + return api.ProviderUsage{Provider: "cursor"}, nil + } + fetchGeminiUsage = func(context.Context) (api.ProviderUsage, error) { + return api.ProviderUsage{Provider: "gemini"}, nil + } + + var output bytes.Buffer + if err := Run(context.Background(), []string{"usage", "--json"}, &output, &bytes.Buffer{}, "test"); err != nil { + t.Fatal(err) + } + if strings.Contains(output.String(), "┌") || !strings.HasSuffix(output.String(), "}\n") { + t.Fatalf("output = %q", output.String()) + } + var document usageJSONDocument + if err := json.Unmarshal(output.Bytes(), &document); err != nil { + t.Fatalf("output = %q, error = %v", output.String(), err) + } + if _, err := time.Parse(time.RFC3339, document.GeneratedAt); err != nil { + t.Fatalf("generated_at = %q", document.GeneratedAt) + } + names := make([]string, 0, len(document.Providers)) + for _, provider := range document.Providers { + names = append(names, provider.Provider+"="+provider.DisplayName) + } + want := "chatgpt=ChatGPT anthropic=Claude copilot=Copilot opencode-go=OpenCode cursor=Cursor gemini=Gemini" + if strings.Join(names, " ") != want { + t.Fatalf("providers = %q", strings.Join(names, " ")) + } + chatGPT := document.Providers[0] + if chatGPT.Error != nil || len(chatGPT.Accounts) != 1 { + t.Fatalf("chatgpt = %+v", chatGPT) + } + limit := chatGPT.Accounts[0].Limits[0] + if chatGPT.Accounts[0].Name != "person@example.com" || limit.UsedPercent != 12.5 || limit.ResetAt == nil || *limit.ResetAt != resetAt { + t.Fatalf("chatgpt account = %+v", chatGPT.Accounts[0]) + } + openCode := document.Providers[3] + if openCode.Error == nil || openCode.Error.Code != "usage_unavailable" || openCode.Error.Message != "OpenCode returned malformed Go usage data" { + t.Fatalf("opencode-go = %+v", openCode) + } + if openCode.Accounts == nil || len(openCode.Accounts) != 0 || !strings.Contains(output.String(), `"accounts": []`) { + t.Fatalf("opencode-go accounts = %#v, output = %q", openCode.Accounts, output.String()) + } +} + +func TestCombinedUsageJSONStillPrintsWhenEveryProviderFails(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + originalChatGPT := fetchChatGPTUsage + originalAnthropic := fetchAnthropicUsage + originalCopilot := fetchCopilotUsage + originalOpenCode := fetchOpenCodeGoUsage + originalCursor := fetchCursorUsage + originalGemini := fetchGeminiUsage + defer func() { + fetchChatGPTUsage = originalChatGPT + fetchAnthropicUsage = originalAnthropic + fetchCopilotUsage = originalCopilot + fetchOpenCodeGoUsage = originalOpenCode + fetchCursorUsage = originalCursor + fetchGeminiUsage = originalGemini + }() + fetchChatGPTUsage = func(context.Context, commonOptions) (api.ProviderUsage, error) { + return api.ProviderUsage{}, errors.New("unavailable") + } + fetchAnthropicUsage = func(context.Context, commonOptions) (api.ProviderUsage, error) { + return api.ProviderUsage{}, errors.New("unavailable") + } + fetchCopilotUsage = func(context.Context, commonOptions) (api.ProviderUsage, error) { + return api.ProviderUsage{}, errors.New("unavailable") + } + fetchOpenCodeGoUsage = func(context.Context) (api.ProviderUsage, error) { + return api.ProviderUsage{}, errors.New("unavailable") + } + fetchCursorUsage = func(context.Context, prismcursor.UsageOptions) (api.ProviderUsage, error) { + return api.ProviderUsage{}, errors.New("unavailable") + } + fetchGeminiUsage = func(context.Context) (api.ProviderUsage, error) { + return api.ProviderUsage{}, errors.New("unavailable") + } + + var output bytes.Buffer + err := Run(context.Background(), []string{"usage", "--json"}, &output, &bytes.Buffer{}, "test") + if err == nil || err.Error() != "usage is unavailable for ChatGPT, Claude, Copilot, OpenCode, Cursor, and Gemini" { + t.Fatalf("error = %v", err) + } + var document usageJSONDocument + if err := json.Unmarshal(output.Bytes(), &document); err != nil { + t.Fatalf("output = %q, error = %v", output.String(), err) + } + for _, provider := range document.Providers { + if provider.Error == nil || provider.Error.Code != "usage_unavailable" || len(provider.Accounts) != 0 { + t.Fatalf("provider %s = %+v", provider.Provider, provider) + } + } +} + +func TestProviderUsageJSONPrintsASingleProviderObject(t *testing.T) { + original := fetchOpenCodeGoUsage + defer func() { fetchOpenCodeGoUsage = original }() + plan := "Go" + fetchOpenCodeGoUsage = func(context.Context) (api.ProviderUsage, error) { + return api.ProviderUsage{Provider: "opencode-go", Accounts: []api.UsageAccount{{ + ID: "wrk_EXAMPLE", Name: "-", Plan: &plan, Status: "fresh", + Limits: []api.UsageLimit{{Name: "rolling", Window: "5h", UsedPercent: 2, RemainingPercent: 98}}, + }}}, nil + } + t.Setenv("HOME", t.TempDir()) + t.Setenv("CIRCLES_AUTH_TOKEN", "") + var output bytes.Buffer + if err := Run(context.Background(), []string{"opencode-go", "usage", "--json"}, &output, &bytes.Buffer{}, "test"); err != nil { + t.Fatal(err) + } + if strings.Contains(output.String(), "┌") { + t.Fatalf("output = %q", output.String()) + } + var provider usageJSONProvider + if err := json.Unmarshal(output.Bytes(), &provider); err != nil { + t.Fatalf("output = %q, error = %v", output.String(), err) + } + if provider.Provider != "opencode-go" || provider.DisplayName != "OpenCode" || provider.Error != nil { + t.Fatalf("provider = %+v", provider) + } + if len(provider.Accounts) != 1 || provider.Accounts[0].ID != "wrk_EXAMPLE" || provider.Accounts[0].Limits[0].RemainingPercent != 98 { + t.Fatalf("accounts = %+v", provider.Accounts) + } +} + +func TestJSONIsRejectedOutsideUsageCommands(t *testing.T) { + for _, args := range [][]string{ + {"chatgpt", "auth", "list", "--json"}, + {"chatgpt", "reset", "--account", "person@example.com", "--confirm", "--json"}, + {"anthropic", "auth", "login", "--json"}, + } { + var stdout, stderr bytes.Buffer + err := Run(context.Background(), args, &stdout, &stderr, "test") + if err == nil || !strings.Contains(err.Error(), "--json is accepted only by") { + t.Fatalf("args %v error = %v", args, err) + } + if stdout.Len() != 0 { + t.Fatalf("args %v printed %q", args, stdout.String()) + } + } +} diff --git a/internal/cli/usage_json.go b/internal/cli/usage_json.go new file mode 100644 index 0000000..508f1f1 --- /dev/null +++ b/internal/cli/usage_json.go @@ -0,0 +1,68 @@ +package cli + +import ( + "encoding/json" + "errors" + "io" + + "github.com/circlesac/prism-cli/internal/api" +) + +type usageJSONProvider struct { + Provider string `json:"provider"` + DisplayName string `json:"display_name"` + Accounts []api.UsageAccount `json:"accounts"` + Error *api.UsageError `json:"error"` +} + +type usageJSONDocument struct { + GeneratedAt string `json:"generated_at"` + Providers []usageJSONProvider `json:"providers"` +} + +func usageProviderDisplayName(provider string) string { + switch provider { + case "chatgpt": + return "ChatGPT" + case "anthropic": + return "Claude" + case "copilot": + return "Copilot" + case "opencode-go": + return "OpenCode" + case "cursor": + return "Cursor" + case "gemini": + return "Gemini" + } + return provider +} + +func usageJSONProviderFor(provider string, usage api.ProviderUsage, err error) usageJSONProvider { + entry := usageJSONProvider{ + Provider: provider, + DisplayName: usageProviderDisplayName(provider), + Accounts: []api.UsageAccount{}, + } + if err != nil { + entry.Error = &api.UsageError{Code: "usage_unavailable", Message: err.Error()} + return entry + } + for _, account := range usage.Accounts { + if account.Limits == nil { + account.Limits = []api.UsageLimit{} + } + entry.Accounts = append(entry.Accounts, account) + } + return entry +} + +func printUsageJSON(output io.Writer, document any) error { + encoder := json.NewEncoder(output) + encoder.SetEscapeHTML(false) + encoder.SetIndent("", " ") + if err := encoder.Encode(document); err != nil { + return errors.New("could not encode the usage JSON document") + } + return nil +}