diff --git a/commands/cc_statusline.go b/commands/cc_statusline.go index 668f84b..1efbd76 100644 --- a/commands/cc_statusline.go +++ b/commands/cc_statusline.go @@ -214,8 +214,8 @@ func formatStatuslineOutput(p statuslineParams) string { parts = append(parts, color.Gray.Sprint("📊 -")) } - // Quota utilization (macOS only - requires Keychain for OAuth token) - if runtime.GOOS == "darwin" { + // Quota utilization (macOS: Keychain, Linux: ~/.claude/.credentials.json) + if runtime.GOOS == "darwin" || runtime.GOOS == "linux" { parts = append(parts, formatQuotaPart(p.FiveHourUtil, p.SevenDayUtil, p.QuotaError)) } @@ -279,7 +279,7 @@ func formatQuotaPart(fiveHourUtil, sevenDayUtil *float64, quotaError string) str } func outputFallback() { - if runtime.GOOS == "darwin" { + if runtime.GOOS == "darwin" || runtime.GOOS == "linux" { quotaPart := wrapOSC8Link(claudeUsageURL, "🚦 -") fmt.Println(color.Gray.Sprint("🌿 - | 🤖 - | 💰 - | 📊 - | " + quotaPart + " | ⏱️ - | 📈 -%")) } else { diff --git a/commands/cc_statusline_test.go b/commands/cc_statusline_test.go index a9376e5..c3330a1 100644 --- a/commands/cc_statusline_test.go +++ b/commands/cc_statusline_test.go @@ -522,7 +522,7 @@ func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_WithQuota() { SevenDayUtil: &sd, }) - if runtime.GOOS == "darwin" { + if runtime.GOOS == "darwin" || runtime.GOOS == "linux" { assert.Contains(s.T(), output, "5h:45%") assert.Contains(s.T(), output, "7d:23%") assert.Contains(s.T(), output, "🚦") @@ -541,7 +541,7 @@ func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_WithoutQuota() { GitBranch: "main", }) - if runtime.GOOS == "darwin" { + if runtime.GOOS == "darwin" || runtime.GOOS == "linux" { assert.Contains(s.T(), output, "🚦 -") } else { assert.NotContains(s.T(), output, "🚦") diff --git a/daemon/anthropic_ratelimit.go b/daemon/anthropic_ratelimit.go index 88ef051..1077ffe 100644 --- a/daemon/anthropic_ratelimit.go +++ b/daemon/anthropic_ratelimit.go @@ -5,7 +5,9 @@ import ( "encoding/json" "fmt" "net/http" + "os" "os/exec" + "path/filepath" "runtime" "sync" "time" @@ -40,12 +42,12 @@ type anthropicUsageBucket struct { ResetsAt string `json:"resets_at"` } -// keychainCredentials maps the JSON stored in macOS Keychain for Claude Code -type keychainCredentials struct { - ClaudeAiOauth *keychainOAuthEntry `json:"claudeAiOauth"` +// claudeCodeCredentials maps the JSON stored in macOS Keychain or ~/.claude/.credentials.json +type claudeCodeCredentials struct { + ClaudeAiOauth *claudeCodeOAuthEntry `json:"claudeAiOauth"` } -type keychainOAuthEntry struct { +type claudeCodeOAuthEntry struct { AccessToken string `json:"accessToken"` RefreshToken string `json:"refreshToken"` ExpiresAt int64 `json:"expiresAt"` @@ -54,25 +56,55 @@ type keychainOAuthEntry struct { RateLimitTier any `json:"rateLimitTier"` } -// fetchClaudeCodeOAuthToken reads the OAuth token from macOS Keychain. -// Returns ("", nil) on non-macOS platforms. +// fetchClaudeCodeOAuthToken reads the OAuth token from the platform-specific credential store. +// macOS: reads from Keychain via `security` command. +// Linux: reads from ~/.claude/.credentials.json file. +// Returns ("", nil) on unsupported platforms. func fetchClaudeCodeOAuthToken() (string, error) { - if runtime.GOOS != "darwin" { + switch runtime.GOOS { + case "darwin": + return fetchOAuthTokenFromKeychain() + case "linux": + return fetchOAuthTokenFromCredentialsFile() + default: return "", nil } +} +// fetchOAuthTokenFromKeychain reads the OAuth token from macOS Keychain. +func fetchOAuthTokenFromKeychain() (string, error) { out, err := exec.Command("security", "find-generic-password", "-s", "Claude Code-credentials", "-w").Output() if err != nil { return "", fmt.Errorf("keychain lookup failed: %w", err) } - var creds keychainCredentials - if err := json.Unmarshal(out, &creds); err != nil { - return "", fmt.Errorf("failed to parse keychain JSON: %w", err) + return parseOAuthTokenFromJSON(out) +} + +// fetchOAuthTokenFromCredentialsFile reads the OAuth token from ~/.claude/.credentials.json. +func fetchOAuthTokenFromCredentialsFile() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + + data, err := os.ReadFile(filepath.Join(homeDir, ".claude", ".credentials.json")) + if err != nil { + return "", fmt.Errorf("credentials file read failed: %w", err) + } + + return parseOAuthTokenFromJSON(data) +} + +// parseOAuthTokenFromJSON parses Claude Code credentials JSON and extracts the OAuth access token. +func parseOAuthTokenFromJSON(data []byte) (string, error) { + var creds claudeCodeCredentials + if err := json.Unmarshal(data, &creds); err != nil { + return "", fmt.Errorf("failed to parse credentials JSON: %w", err) } if creds.ClaudeAiOauth == nil || creds.ClaudeAiOauth.AccessToken == "" { - return "", fmt.Errorf("no OAuth access token found in keychain") + return "", fmt.Errorf("no OAuth access token found in credentials") } return creds.ClaudeAiOauth.AccessToken, nil diff --git a/daemon/anthropic_ratelimit_test.go b/daemon/anthropic_ratelimit_test.go index 1f9564d..04e037d 100644 --- a/daemon/anthropic_ratelimit_test.go +++ b/daemon/anthropic_ratelimit_test.go @@ -6,6 +6,8 @@ import ( "fmt" "net/http" "net/http/httptest" + "os" + "path/filepath" "testing" "github.com/malamtime/cli/model" @@ -76,7 +78,7 @@ func TestFetchAnthropicUsage_NonOKStatus(t *testing.T) { func TestParseKeychainJSON(t *testing.T) { raw := `{"claudeAiOauth":{"accessToken":"sk-ant-test-token-123"}}` - var creds keychainCredentials + var creds claudeCodeCredentials err := json.Unmarshal([]byte(raw), &creds) assert.NoError(t, err) assert.NotNil(t, creds.ClaudeAiOauth) @@ -86,7 +88,7 @@ func TestParseKeychainJSON(t *testing.T) { func TestParseKeychainJSON_MissingOAuth(t *testing.T) { raw := `{"someOtherKey":"value"}` - var creds keychainCredentials + var creds claudeCodeCredentials err := json.Unmarshal([]byte(raw), &creds) assert.NoError(t, err) assert.Nil(t, creds.ClaudeAiOauth) @@ -95,7 +97,7 @@ func TestParseKeychainJSON_MissingOAuth(t *testing.T) { func TestParseKeychainJSON_EmptyAccessToken(t *testing.T) { raw := `{"claudeAiOauth":{"accessToken":""}}` - var creds keychainCredentials + var creds claudeCodeCredentials err := json.Unmarshal([]byte(raw), &creds) assert.NoError(t, err) assert.NotNil(t, creds.ClaudeAiOauth) @@ -164,3 +166,78 @@ func TestAnthropicRateLimitCache_GetCachedRateLimit_ReturnsCopy(t *testing.T) { assert.Equal(t, 0.5, service.rateLimitCache.usage.FiveHourUtilization) service.rateLimitCache.mu.RUnlock() } + +func TestParseOAuthTokenFromJSON_Valid(t *testing.T) { + raw := `{"claudeAiOauth":{"accessToken":"sk-ant-test-token-123","refreshToken":"sk-ref","expiresAt":1773399176544,"scopes":["user:inference"],"subscriptionType":"max","rateLimitTier":"default_claude_max_5x"}}` + token, err := parseOAuthTokenFromJSON([]byte(raw)) + assert.NoError(t, err) + assert.Equal(t, "sk-ant-test-token-123", token) +} + +func TestParseOAuthTokenFromJSON_MissingOAuth(t *testing.T) { + raw := `{"someOtherKey":"value"}` + token, err := parseOAuthTokenFromJSON([]byte(raw)) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no OAuth access token found") + assert.Empty(t, token) +} + +func TestParseOAuthTokenFromJSON_EmptyToken(t *testing.T) { + raw := `{"claudeAiOauth":{"accessToken":""}}` + token, err := parseOAuthTokenFromJSON([]byte(raw)) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no OAuth access token found") + assert.Empty(t, token) +} + +func TestParseOAuthTokenFromJSON_InvalidJSON(t *testing.T) { + raw := `not-json` + token, err := parseOAuthTokenFromJSON([]byte(raw)) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse credentials JSON") + assert.Empty(t, token) +} + +func TestFetchOAuthTokenFromCredentialsFile_Valid(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + + claudeDir := filepath.Join(tmpDir, ".claude") + err := os.MkdirAll(claudeDir, 0700) + assert.NoError(t, err) + + content := `{"claudeAiOauth":{"accessToken":"sk-test-linux-token","refreshToken":"sk-ref","expiresAt":1773399176544}}` + err = os.WriteFile(filepath.Join(claudeDir, ".credentials.json"), []byte(content), 0600) + assert.NoError(t, err) + + token, err := fetchOAuthTokenFromCredentialsFile() + assert.NoError(t, err) + assert.Equal(t, "sk-test-linux-token", token) +} + +func TestFetchOAuthTokenFromCredentialsFile_MissingFile(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + + token, err := fetchOAuthTokenFromCredentialsFile() + assert.Error(t, err) + assert.Contains(t, err.Error(), "credentials file read failed") + assert.Empty(t, token) +} + +func TestFetchOAuthTokenFromCredentialsFile_InvalidJSON(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + + claudeDir := filepath.Join(tmpDir, ".claude") + err := os.MkdirAll(claudeDir, 0700) + assert.NoError(t, err) + + err = os.WriteFile(filepath.Join(claudeDir, ".credentials.json"), []byte("not-json"), 0600) + assert.NoError(t, err) + + token, err := fetchOAuthTokenFromCredentialsFile() + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse credentials JSON") + assert.Empty(t, token) +} diff --git a/daemon/cc_info_timer.go b/daemon/cc_info_timer.go index 29448e9..448defd 100644 --- a/daemon/cc_info_timer.go +++ b/daemon/cc_info_timer.go @@ -393,9 +393,9 @@ func (s *CCInfoTimerService) cleanupStaleGitCache() { } // fetchRateLimit fetches Anthropic rate limit data if cache is stale. -// Only runs on macOS where Keychain access is available. +// Supported on macOS (Keychain) and Linux (~/.claude/.credentials.json). func (s *CCInfoTimerService) fetchRateLimit(ctx context.Context) { - if runtime.GOOS != "darwin" { + if runtime.GOOS != "darwin" && runtime.GOOS != "linux" { return }