Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
85 changes: 77 additions & 8 deletions internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
Expand Down Expand Up @@ -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
}
Expand All @@ -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":
Expand Down Expand Up @@ -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 <name>]")
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":
Expand Down Expand Up @@ -588,6 +656,7 @@ func printHelp(output io.Writer) {
Usage:
prism claude [claude arguments...]
prism codex enable|disable|status
prism usage [--profile <name>]
prism chatgpt usage [--profile <name>]
prism opencode-go usage
prism chatgpt auth login [--profile <name>]
Expand Down
86 changes: 85 additions & 1 deletion internal/cli/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cli
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"strings"
Expand All @@ -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)
}
Expand All @@ -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 }()
Expand Down
Loading