diff --git a/README.md b/README.md index e48872e..4d33c63 100644 --- a/README.md +++ b/README.md @@ -22,14 +22,33 @@ crcl login ## Usage -Show ChatGPT and OpenCode Go usage together: +Show ChatGPT, Anthropic, 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. +usage from the other providers. + +## Anthropic + +Register each Claude subscription account separately and show its current quota: + +```sh +prism anthropic auth login +prism anthropic auth list +prism anthropic usage +prism anthropic auth remove +``` + +`prism claude login` is a short alias for `prism anthropic auth login`. The +browser handles account selection, SSO, and MFA. Prism does not import the +credential used by an existing Claude Code login. + +This first release stores and refreshes Anthropic credentials for account and +usage management. `prism claude` inference continues to use Prism's existing +providers until native Anthropic routing is added separately. ## ChatGPT diff --git a/internal/anthropic/oauth.go b/internal/anthropic/oauth.go new file mode 100644 index 0000000..1caf531 --- /dev/null +++ b/internal/anthropic/oauth.go @@ -0,0 +1,182 @@ +package anthropic + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "html" + "net" + "net/http" + "net/url" + "os/exec" + "runtime" + "strings" + "sync" + "time" +) + +const clientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" +const scope = "user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload" + +type AuthorizationGrant struct { + AuthorizationCode string `json:"authorization_code"` + CodeVerifier string `json:"code_verifier"` + RedirectURI string `json:"redirect_uri"` + State string `json:"state"` +} + +type BrowserOpener func(string) error + +type OAuth struct { + OpenBrowser BrowserOpener + Timeout time.Duration +} + +type loginResult struct { + grant AuthorizationGrant + err error +} + +func (o OAuth) Login(ctx context.Context) (AuthorizationGrant, error) { + timeout := o.Timeout + if timeout == 0 { + timeout = 5 * time.Minute + } + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + verifier, challenge, err := generatePKCE() + if err != nil { + return AuthorizationGrant{}, fmt.Errorf("generate PKCE: %w", err) + } + state, err := randomBase64URL(32) + if err != nil { + return AuthorizationGrant{}, fmt.Errorf("generate OAuth state: %w", err) + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return AuthorizationGrant{}, errors.New("could not start the Anthropic OAuth callback") + } + defer listener.Close() + + port := listener.Addr().(*net.TCPAddr).Port + redirectURI := fmt.Sprintf("http://localhost:%d/callback", port) + expectedHost := fmt.Sprintf("localhost:%d", port) + result := make(chan loginResult, 1) + var once sync.Once + mux := http.NewServeMux() + server := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second} + mux.HandleFunc("/callback", func(response http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet || !strings.EqualFold(request.Host, expectedHost) { + http.Error(response, "Invalid callback", http.StatusBadRequest) + return + } + query := request.URL.Query() + if providerError := query.Get("error"); providerError != "" { + message := query.Get("error_description") + if message == "" { + message = providerError + } + once.Do(func() { result <- loginResult{err: fmt.Errorf("Anthropic authorization failed: %s", message)} }) + writeCallbackPage(response, "Login failed", "Anthropic authorization did not complete.") + return + } + if query.Get("state") != state { + once.Do(func() { result <- loginResult{err: errors.New("OAuth callback state did not match")} }) + http.Error(response, "Invalid callback state", http.StatusBadRequest) + return + } + code := query.Get("code") + if code == "" { + once.Do(func() { result <- loginResult{err: errors.New("OAuth callback did not include a code")} }) + http.Error(response, "Missing authorization code", http.StatusBadRequest) + return + } + once.Do(func() { + result <- loginResult{grant: AuthorizationGrant{ + AuthorizationCode: code, + CodeVerifier: verifier, + RedirectURI: redirectURI, + State: state, + }} + }) + writeCallbackPage(response, "Login complete", "Return to Prism to finish saving this account.") + }) + go func() { _ = server.Serve(listener) }() + defer func() { + shutdown, stop := context.WithTimeout(context.Background(), time.Second) + defer stop() + _ = server.Shutdown(shutdown) + }() + + opener := o.OpenBrowser + if opener == nil { + opener = openBrowser + } + if err := opener(authorizeURL(redirectURI, challenge, state)); err != nil { + return AuthorizationGrant{}, fmt.Errorf("open Anthropic login: %w", err) + } + + select { + case outcome := <-result: + return outcome.grant, outcome.err + case <-ctx.Done(): + return AuthorizationGrant{}, errors.New("Anthropic login timed out or was cancelled") + } +} + +func authorizeURL(redirectURI string, challenge string, state string) string { + query := url.Values{ + "code": {"true"}, + "client_id": {clientID}, + "response_type": {"code"}, + "redirect_uri": {redirectURI}, + "scope": {scope}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + "state": {state}, + } + return "https://claude.com/cai/oauth/authorize?" + query.Encode() +} + +func generatePKCE() (string, string, error) { + verifier, err := randomBase64URL(48) + if err != nil { + return "", "", err + } + sum := sha256.Sum256([]byte(verifier)) + return verifier, base64.RawURLEncoding.EncodeToString(sum[:]), nil +} + +func randomBase64URL(size int) (string, error) { + value := make([]byte, size) + if _, err := rand.Read(value); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(value), nil +} + +func openBrowser(target string) error { + var command *exec.Cmd + switch runtime.GOOS { + case "darwin": + command = exec.Command("open", target) + case "windows": + command = exec.Command("rundll32", "url.dll,FileProtocolHandler", target) + default: + command = exec.Command("xdg-open", target) + } + return command.Run() +} + +func writeCallbackPage(response http.ResponseWriter, title string, message string) { + response.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = fmt.Fprintf( + response, + "%s

%s

%s

", + html.EscapeString(title), html.EscapeString(title), html.EscapeString(message), + ) +} diff --git a/internal/anthropic/oauth_test.go b/internal/anthropic/oauth_test.go new file mode 100644 index 0000000..9672096 --- /dev/null +++ b/internal/anthropic/oauth_test.go @@ -0,0 +1,87 @@ +package anthropic + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "io" + "net/http" + "net/url" + "strings" + "testing" + "time" +) + +func TestLoginCapturesAValidatedLoopbackAuthorizationCode(t *testing.T) { + var authorize *url.URL + oauth := OAuth{ + Timeout: time.Second, + OpenBrowser: func(target string) error { + var err error + authorize, err = url.Parse(target) + if err != nil { + return err + } + redirect := authorize.Query().Get("redirect_uri") + state := authorize.Query().Get("state") + go func() { + response, requestErr := http.Get(redirect + "?code=one-time-code&state=" + url.QueryEscape(state)) + if requestErr == nil { + _, _ = io.Copy(io.Discard, response.Body) + _ = response.Body.Close() + } + }() + return nil + }, + } + + grant, err := oauth.Login(context.Background()) + if err != nil { + t.Fatal(err) + } + if authorize.Host != "claude.com" || authorize.Path != "/cai/oauth/authorize" { + t.Fatalf("authorize URL = %s", authorize) + } + query := authorize.Query() + for key, want := range map[string]string{ + "code": "true", + "client_id": clientID, + "response_type": "code", + "code_challenge_method": "S256", + "scope": scope, + } { + if query.Get(key) != want { + t.Fatalf("%s = %q, want %q", key, query.Get(key), want) + } + } + if grant.AuthorizationCode != "one-time-code" || grant.State != query.Get("state") || grant.RedirectURI != query.Get("redirect_uri") { + t.Fatalf("grant = %#v", grant) + } + if !strings.HasPrefix(grant.RedirectURI, "http://localhost:") || !strings.HasSuffix(grant.RedirectURI, "/callback") { + t.Fatalf("redirect URI = %q", grant.RedirectURI) + } + sum := sha256.Sum256([]byte(grant.CodeVerifier)) + if query.Get("code_challenge") != base64.RawURLEncoding.EncodeToString(sum[:]) { + t.Fatal("PKCE challenge does not match the verifier") + } +} + +func TestLoginRejectsACallbackWithTheWrongState(t *testing.T) { + oauth := OAuth{ + Timeout: 100 * time.Millisecond, + OpenBrowser: func(target string) error { + authorize, _ := url.Parse(target) + go func() { + response, err := http.Get(authorize.Query().Get("redirect_uri") + "?code=one-time-code&state=wrong") + if err == nil { + _ = response.Body.Close() + } + }() + return nil + }, + } + _, err := oauth.Login(context.Background()) + if err == nil || !strings.Contains(err.Error(), "state did not match") { + t.Fatalf("error = %v", err) + } +} diff --git a/internal/api/client.go b/internal/api/client.go index c25cc14..4eb2d15 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -45,6 +45,7 @@ type UsageAccount struct { Name string `json:"name"` Plan *string `json:"plan"` ObservedAt string `json:"observed_at"` + Status string `json:"status"` Limits []UsageLimit `json:"limits"` Error *UsageError `json:"error"` } @@ -55,7 +56,7 @@ type ProviderUsage struct { } var providers = map[string]struct{}{ - "chatgpt": {}, "copilot": {}, "gemini": {}, "gemini-ai": {}, + "chatgpt": {}, "anthropic": {}, "copilot": {}, "gemini": {}, "gemini-ai": {}, "groq": {}, "mistral": {}, "deepseek": {}, "opencode-go": {}, "cloudflare": {}, "vercel": {}, "gemini-app": {}, } diff --git a/internal/cli/claude.go b/internal/cli/claude.go index bb28693..22218a1 100644 --- a/internal/cli/claude.go +++ b/internal/cli/claude.go @@ -30,6 +30,20 @@ func runClaudeCommand(ctx context.Context, args []string, stdout io.Writer, stde printClaudeHelp(stdout) return nil } + if len(args) > 0 && args[0] == "login" { + options, positionals, err := parseCommonOptions(args[1:]) + if err != nil { + return err + } + if len(positionals) != 0 || options.name != "" || options.providerAccountID != "" || options.ownerID != "" { + return errors.New("usage: prism claude login [--profile ]") + } + client, err := prismClient(ctx, options) + if err != nil { + return err + } + return loginProvider(ctx, "anthropic", client, stdout) + } client, err := prismClient(ctx, commonOptions{}) if err != nil { return err @@ -160,6 +174,7 @@ func claudeEnvironment(environment []string, baseURL string, credential string) func printClaudeHelp(output io.Writer) { _, _ = fmt.Fprintln(output, `Usage: + prism claude login [--profile ] prism claude [claude arguments...] Pass --model with any model supported by Prism. diff --git a/internal/cli/claude_test.go b/internal/cli/claude_test.go index 8e128ee..a0f6743 100644 --- a/internal/cli/claude_test.go +++ b/internal/cli/claude_test.go @@ -17,6 +17,7 @@ func TestClaudeHelpDoesNotResolveCredentials(t *testing.T) { t.Fatal(err) } if !strings.Contains(stdout.String(), "prism claude") || + !strings.Contains(stdout.String(), "prism claude login") || !strings.Contains(stdout.String(), "crcl use ") || !strings.Contains(stdout.String(), "claude --help") || strings.Contains(stdout.String(), "prism claude [--profile") { diff --git a/internal/cli/run.go b/internal/cli/run.go index ff8ab06..de7c58c 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -13,6 +13,7 @@ import ( "unicode/utf8" credentials "github.com/circlesac/credentials/go" + "github.com/circlesac/prism-cli/internal/anthropic" "github.com/circlesac/prism-cli/internal/api" "github.com/circlesac/prism-cli/internal/chatgpt" "github.com/circlesac/prism-cli/internal/copilot" @@ -29,6 +30,13 @@ var fetchChatGPTUsage = func(ctx context.Context, options commonOptions) (api.Pr } return client.Usage(ctx, "chatgpt") } +var fetchAnthropicUsage = 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, "anthropic") +} type commonOptions struct { profile string @@ -98,6 +106,8 @@ func Run( var usage api.ProviderUsage if providerName == "opencode-go" { usage, err = fetchOpenCodeGoUsage(ctx) + } else if providerName == "anthropic" { + usage, err = fetchAnthropicUsage(ctx, options) } else { usage, err = fetchChatGPTUsage(ctx, options) } @@ -169,11 +179,16 @@ func runCombinedUsage(ctx context.Context, args []string, output io.Writer) erro err error } chatGPTResults := make(chan usageResult, 1) + anthropicResults := 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 := fetchAnthropicUsage(ctx, options) + anthropicResults <- usageResult{usage: usage, err: fetchErr} + }() go func() { usage, fetchErr := fetchOpenCodeGoUsage(ctx) openCodeResults <- usageResult{usage: usage, err: fetchErr} @@ -184,6 +199,7 @@ func runCombinedUsage(ctx context.Context, args []string, output io.Writer) erro result usageResult }{ {name: "ChatGPT", result: <-chatGPTResults}, + {name: "Claude", result: <-anthropicResults}, {name: "OpenCode", result: <-openCodeResults}, } succeeded := 0 @@ -203,7 +219,7 @@ func runCombinedUsage(ctx context.Context, args []string, output io.Writer) erro } printUsageTableAt(output, providers, time.Now(), true) if succeeded == 0 { - return fmt.Errorf("usage is unavailable for %s", strings.Join(failures, " and ")) + return fmt.Errorf("usage is unavailable for %s", joinNames(failures)) } return nil } @@ -211,7 +227,7 @@ func runCombinedUsage(ctx context.Context, args []string, output io.Writer) erro func validateCommand(provider string, command string, positionals []string, options commonOptions) error { switch command { case "usage": - if provider != "chatgpt" && provider != "opencode-go" { + if provider != "chatgpt" && provider != "anthropic" && provider != "opencode-go" { return fmt.Errorf("%s usage is not supported", provider) } if len(positionals) != 0 { @@ -221,7 +237,7 @@ func validateCommand(provider string, command string, positionals []string, opti return errors.New("usage accepts only --profile") } case "login": - if provider != "chatgpt" && provider != "copilot" && provider != "gemini" { + if provider != "chatgpt" && provider != "anthropic" && provider != "copilot" && provider != "gemini" { return fmt.Errorf("%s uses 'auth add', not 'auth login'", provider) } if len(positionals) != 0 { @@ -338,6 +354,19 @@ func appendUsageRow(rows [][]string, showProvider bool, values ...string) [][]st return append(rows, values) } +func joinNames(values []string) string { + switch len(values) { + case 0: + return "" + case 1: + return values[0] + case 2: + return values[0] + " and " + values[1] + default: + return strings.Join(values[:len(values)-1], ", ") + ", and " + values[len(values)-1] + } +} + func usagePace(limit api.UsageLimit, now time.Time) string { if limit.LimitReached || limit.UsedPercent >= 100 { return "⛔ LIMIT REACHED" @@ -530,6 +559,17 @@ func loginProvider(ctx context.Context, provider string, client api.Client, outp return err } fmt.Fprintf(output, "Saved Gemini credential %s (%s).\n", saved.Name, saved.ID) + case "anthropic": + fmt.Fprintln(output, "Opening a browser for Anthropic login...") + grant, err := (anthropic.OAuth{}).Login(ctx) + if err != nil { + return err + } + saved, err := client.Save(ctx, "anthropic", "", grant) + if err != nil { + return err + } + fmt.Fprintf(output, "Saved Anthropic credential %s (%s).\n", saved.Name, saved.ID) } return nil } @@ -703,6 +743,8 @@ Usage: prism chatgpt usage [--profile ] prism opencode-go usage prism chatgpt auth login [--profile ] + prism anthropic auth login [--profile ] + prism claude login [--profile ] prism copilot auth login [--profile ] prism gemini auth login [--profile ] prism auth add [--name ] [provider options] @@ -719,7 +761,7 @@ Run 'crcl login' before using Prism.`) } func printProviderAuthHelp(output io.Writer, provider string) { - if provider == "chatgpt" || provider == "copilot" || provider == "gemini" { + if provider == "chatgpt" || provider == "anthropic" || provider == "copilot" || provider == "gemini" { fmt.Fprintf(output, `Usage: prism %s auth login [--profile ] prism %s auth list [--profile ] diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index d748d78..7819a01 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -20,7 +20,7 @@ func TestHelpDocumentsSupportedCommandsWithoutInternalDetails(t *testing.T) { t.Fatal(err) } output := stdout.String() - for _, command := range []string{"prism claude", "prism codex", "prism usage", "chatgpt usage", "opencode-go usage", "auth login", "auth list", "auth remove"} { + for _, command := range []string{"prism claude", "prism codex", "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) } @@ -35,11 +35,13 @@ func TestHelpDocumentsSupportedCommandsWithoutInternalDetails(t *testing.T) { } } -func TestCombinedUsageShowsChatGPTAndOpenCodeGo(t *testing.T) { +func TestCombinedUsageShowsChatGPTAnthropicAndOpenCodeGo(t *testing.T) { originalChatGPT := fetchChatGPTUsage + originalAnthropic := fetchAnthropicUsage originalOpenCode := fetchOpenCodeGoUsage defer func() { fetchChatGPTUsage = originalChatGPT + fetchAnthropicUsage = originalAnthropic fetchOpenCodeGoUsage = originalOpenCode }() plan := "pro" @@ -51,6 +53,16 @@ func TestCombinedUsageShowsChatGPTAndOpenCodeGo(t *testing.T) { Limits: []api.UsageLimit{{Name: "default", Window: "7d", UsedPercent: 10, RemainingPercent: 90}}, }}}, nil } + claudePlan := "Max 20x" + fetchAnthropicUsage = func(_ context.Context, options commonOptions) (api.ProviderUsage, error) { + if options.profile != "work-admin" || !options.profileSet { + t.Fatalf("Anthropic options = %+v", options) + } + return api.ProviderUsage{Provider: "anthropic", Accounts: []api.UsageAccount{{ + Name: "Max 20x", Plan: &claudePlan, Status: "fresh", + Limits: []api.UsageLimit{{Name: "default", Window: "5h", UsedPercent: 20, RemainingPercent: 80}}, + }}}, nil + } fetchOpenCodeGoUsage = func(context.Context) (api.ProviderUsage, error) { return api.ProviderUsage{Provider: "opencode-go", Accounts: []api.UsageAccount{{ Name: "-", Limits: []api.UsageLimit{{Name: "rolling", Window: "5h", UsedPercent: 2, RemainingPercent: 98}}, @@ -65,21 +77,26 @@ func TestCombinedUsageShowsChatGPTAndOpenCodeGo(t *testing.T) { t.Fatalf("ChatGPT options = %+v", chatGPTOptions) } text := output.String() - if strings.Count(text, "┌") != 1 || !strings.Contains(text, "│ PROVIDER │ ACCOUNT") || !strings.Contains(text, "ChatGPT") || !strings.Contains(text, "OpenCode") || !strings.Contains(text, "person@example.com") || strings.Contains(text, "OpenCode workspace") { + if strings.Count(text, "┌") != 1 || !strings.Contains(text, "│ PROVIDER │ ACCOUNT") || !strings.Contains(text, "ChatGPT") || !strings.Contains(text, "Claude") || !strings.Contains(text, "OpenCode") || !strings.Contains(text, "Max 20x") || !strings.Contains(text, "person@example.com") || strings.Contains(text, "OpenCode workspace") { t.Fatalf("output = %q", text) } } func TestCombinedUsageKeepsPartialResults(t *testing.T) { originalChatGPT := fetchChatGPTUsage + originalAnthropic := fetchAnthropicUsage originalOpenCode := fetchOpenCodeGoUsage defer func() { fetchChatGPTUsage = originalChatGPT + fetchAnthropicUsage = originalAnthropic fetchOpenCodeGoUsage = originalOpenCode }() fetchChatGPTUsage = func(context.Context, commonOptions) (api.ProviderUsage, error) { return api.ProviderUsage{}, errors.New("ChatGPT login unavailable") } + fetchAnthropicUsage = func(context.Context, commonOptions) (api.ProviderUsage, error) { + return api.ProviderUsage{}, errors.New("Anthropic login unavailable") + } fetchOpenCodeGoUsage = func(context.Context) (api.ProviderUsage, error) { return api.ProviderUsage{Provider: "opencode-go", Accounts: []api.UsageAccount{{ Name: "-", Limits: []api.UsageLimit{{Name: "weekly", Window: "7d"}}, @@ -97,21 +114,26 @@ func TestCombinedUsageKeepsPartialResults(t *testing.T) { func TestCombinedUsageFailsOnlyWhenEveryProviderFails(t *testing.T) { originalChatGPT := fetchChatGPTUsage + originalAnthropic := fetchAnthropicUsage originalOpenCode := fetchOpenCodeGoUsage defer func() { fetchChatGPTUsage = originalChatGPT + fetchAnthropicUsage = originalAnthropic fetchOpenCodeGoUsage = originalOpenCode }() 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") + } 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" { + if err == nil || err.Error() != "usage is unavailable for ChatGPT, Claude, and OpenCode" { t.Fatalf("error = %v", err) } }