diff --git a/README.md b/README.md index 7b3aa88..e48872e 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,17 @@ curl -fsSL https://github.com/circlesac/prism-cli/releases/latest/download/insta crcl login ``` +## Usage + +Show ChatGPT and OpenCode Go usage together: + +```sh +prism usage +``` + +Each provider is fetched independently, so an unavailable login does not hide +usage from the other provider. + ## ChatGPT ```sh diff --git a/internal/cli/run.go b/internal/cli/run.go index 82ee76f..ff84ae7 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -22,6 +22,13 @@ import ( ) var fetchOpenCodeGoUsage = opencodego.Fetch +var fetchChatGPTUsage = func(ctx context.Context, options commonOptions) (api.ProviderUsage, error) { + client, err := prismClient(ctx, options) + if err != nil { + return api.ProviderUsage{}, err + } + return client.Usage(ctx, "chatgpt") +} type commonOptions struct { profile string @@ -47,6 +54,9 @@ func Run( fmt.Fprintln(stdout, version) return nil } + if args[0] == "usage" { + return runCombinedUsage(ctx, args[1:], stdout) + } if args[0] == "claude" { return runClaudeCommand(ctx, args[1:], stdout, stderr) } @@ -84,8 +94,13 @@ func Run( if err := validateCommand(providerName, command, positionals, options); err != nil { return err } - if command == "usage" && providerName == "opencode-go" { - usage, err := fetchOpenCodeGoUsage(ctx) + if command == "usage" { + var usage api.ProviderUsage + if providerName == "opencode-go" { + usage, err = fetchOpenCodeGoUsage(ctx) + } else { + usage, err = fetchChatGPTUsage(ctx, options) + } if err != nil { return err } @@ -99,12 +114,6 @@ func Run( } switch command { - case "usage": - usage, err := client.Usage(ctx, providerName) - if err != nil { - return err - } - printUsage(stdout, usage) case "login": return loginProvider(ctx, providerName, client, stdout) case "add": @@ -139,6 +148,65 @@ func Run( return nil } +func runCombinedUsage(ctx context.Context, args []string, output io.Writer) error { + options, positionals, err := parseCommonOptions(args) + if err != nil { + return err + } + if options.help { + fmt.Fprintln(output, "Usage:\n prism usage [--profile ]") + 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") + } + + type usageResult struct { + usage api.ProviderUsage + err error + } + chatGPTResults := make(chan usageResult, 1) + openCodeResults := make(chan usageResult, 1) + go func() { + usage, fetchErr := fetchChatGPTUsage(ctx, options) + chatGPTResults <- usageResult{usage: usage, err: fetchErr} + }() + go func() { + usage, fetchErr := fetchOpenCodeGoUsage(ctx) + openCodeResults <- usageResult{usage: usage, err: fetchErr} + }() + + results := []struct { + name string + result usageResult + }{ + {name: "ChatGPT", result: <-chatGPTResults}, + {name: "OpenCode Go", result: <-openCodeResults}, + } + succeeded := 0 + var failures []string + for index, provider := range results { + if index > 0 { + fmt.Fprintln(output) + } + fmt.Fprintln(output, provider.name) + if provider.result.err != nil { + fmt.Fprintf(output, "ERROR: %s\n", provider.result.err) + failures = append(failures, provider.name) + continue + } + printUsage(output, provider.result.usage) + succeeded++ + } + if succeeded == 0 { + return fmt.Errorf("usage is unavailable for %s", strings.Join(failures, " and ")) + } + return nil +} + func validateCommand(provider string, command string, positionals []string, options commonOptions) error { switch command { case "usage": @@ -588,6 +656,7 @@ func printHelp(output io.Writer) { Usage: prism claude [claude arguments...] prism codex enable|disable|status + prism usage [--profile ] prism chatgpt usage [--profile ] prism opencode-go usage prism chatgpt auth login [--profile ] diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index 4950100..f875401 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -3,6 +3,7 @@ package cli import ( "bytes" "context" + "errors" "os" "path/filepath" "strings" @@ -19,7 +20,7 @@ func TestHelpDocumentsSupportedCommandsWithoutInternalDetails(t *testing.T) { t.Fatal(err) } output := stdout.String() - for _, command := range []string{"prism claude", "prism codex", "chatgpt usage", "opencode-go usage", "auth login", "auth list", "auth remove"} { + for _, command := range []string{"prism claude", "prism codex", "prism usage", "chatgpt usage", "opencode-go usage", "auth login", "auth list", "auth remove"} { if !strings.Contains(output, command) { t.Fatalf("help did not contain %q", command) } @@ -34,6 +35,89 @@ func TestHelpDocumentsSupportedCommandsWithoutInternalDetails(t *testing.T) { } } +func TestCombinedUsageShowsChatGPTAndOpenCodeGo(t *testing.T) { + originalChatGPT := fetchChatGPTUsage + originalOpenCode := fetchOpenCodeGoUsage + defer func() { + fetchChatGPTUsage = originalChatGPT + fetchOpenCodeGoUsage = originalOpenCode + }() + plan := "pro" + var chatGPTOptions commonOptions + fetchChatGPTUsage = func(_ context.Context, options commonOptions) (api.ProviderUsage, error) { + chatGPTOptions = options + return api.ProviderUsage{Provider: "chatgpt", Accounts: []api.UsageAccount{{ + Name: "person@example.com", Plan: &plan, + Limits: []api.UsageLimit{{Name: "default", Window: "7d", UsedPercent: 10, RemainingPercent: 90}}, + }}}, nil + } + fetchOpenCodeGoUsage = func(context.Context) (api.ProviderUsage, error) { + return api.ProviderUsage{Provider: "opencode-go", Accounts: []api.UsageAccount{{ + Name: "OpenCode workspace", Limits: []api.UsageLimit{{Name: "rolling", Window: "5h", UsedPercent: 2, RemainingPercent: 98}}, + }}}, nil + } + + var output bytes.Buffer + if err := Run(context.Background(), []string{"usage", "--profile", "work-admin"}, &output, &bytes.Buffer{}, "test"); err != nil { + t.Fatal(err) + } + if chatGPTOptions.profile != "work-admin" || !chatGPTOptions.profileSet { + t.Fatalf("ChatGPT options = %+v", chatGPTOptions) + } + text := output.String() + chatGPT := strings.Index(text, "ChatGPT\n") + openCode := strings.Index(text, "OpenCode Go\n") + if chatGPT < 0 || openCode < chatGPT || !strings.Contains(text, "person@example.com") || !strings.Contains(text, "OpenCode workspace") { + t.Fatalf("output = %q", text) + } +} + +func TestCombinedUsageKeepsPartialResults(t *testing.T) { + originalChatGPT := fetchChatGPTUsage + originalOpenCode := fetchOpenCodeGoUsage + defer func() { + fetchChatGPTUsage = originalChatGPT + fetchOpenCodeGoUsage = originalOpenCode + }() + fetchChatGPTUsage = func(context.Context, commonOptions) (api.ProviderUsage, error) { + return api.ProviderUsage{}, errors.New("ChatGPT login unavailable") + } + fetchOpenCodeGoUsage = func(context.Context) (api.ProviderUsage, error) { + return api.ProviderUsage{Provider: "opencode-go", Accounts: []api.UsageAccount{{ + Name: "OpenCode workspace", Limits: []api.UsageLimit{{Name: "weekly", Window: "7d"}}, + }}}, nil + } + + var output bytes.Buffer + if err := Run(context.Background(), []string{"usage"}, &output, &bytes.Buffer{}, "test"); err != nil { + t.Fatal(err) + } + if !strings.Contains(output.String(), "ERROR: ChatGPT login unavailable") || !strings.Contains(output.String(), "OpenCode workspace") { + t.Fatalf("output = %q", output.String()) + } +} + +func TestCombinedUsageFailsOnlyWhenEveryProviderFails(t *testing.T) { + originalChatGPT := fetchChatGPTUsage + originalOpenCode := fetchOpenCodeGoUsage + defer func() { + fetchChatGPTUsage = originalChatGPT + fetchOpenCodeGoUsage = originalOpenCode + }() + fetchChatGPTUsage = 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") + } + + var output bytes.Buffer + err := Run(context.Background(), []string{"usage"}, &output, &bytes.Buffer{}, "test") + if err == nil || err.Error() != "usage is unavailable for ChatGPT and OpenCode Go" { + t.Fatalf("error = %v", err) + } +} + func TestOpenCodeGoUsageUsesTheLocalBrowserWithoutCirclesCredentials(t *testing.T) { original := fetchOpenCodeGoUsage defer func() { fetchOpenCodeGoUsage = original }()