Skip to content

Commit 48679a8

Browse files
authored
Merge pull request #258 from shelltime/claude/add-anthropic-usage-statusline-23SCL
Add Linux support for Claude Code OAuth token retrieval
2 parents 57e2f03 + 8fcbbbe commit 48679a8

5 files changed

Lines changed: 130 additions & 21 deletions

File tree

commands/cc_statusline.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -214,8 +214,8 @@ func formatStatuslineOutput(p statuslineParams) string {
214214
parts = append(parts, color.Gray.Sprint("📊 -"))
215215
}
216216

217-
// Quota utilization (macOS only - requires Keychain for OAuth token)
218-
if runtime.GOOS == "darwin" {
217+
// Quota utilization (macOS: Keychain, Linux: ~/.claude/.credentials.json)
218+
if runtime.GOOS == "darwin" || runtime.GOOS == "linux" {
219219
parts = append(parts, formatQuotaPart(p.FiveHourUtil, p.SevenDayUtil, p.QuotaError))
220220
}
221221

@@ -279,7 +279,7 @@ func formatQuotaPart(fiveHourUtil, sevenDayUtil *float64, quotaError string) str
279279
}
280280

281281
func outputFallback() {
282-
if runtime.GOOS == "darwin" {
282+
if runtime.GOOS == "darwin" || runtime.GOOS == "linux" {
283283
quotaPart := wrapOSC8Link(claudeUsageURL, "🚦 -")
284284
fmt.Println(color.Gray.Sprint("🌿 - | 🤖 - | 💰 - | 📊 - | " + quotaPart + " | ⏱️ - | 📈 -%"))
285285
} else {

commands/cc_statusline_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -522,7 +522,7 @@ func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_WithQuota() {
522522
SevenDayUtil: &sd,
523523
})
524524

525-
if runtime.GOOS == "darwin" {
525+
if runtime.GOOS == "darwin" || runtime.GOOS == "linux" {
526526
assert.Contains(s.T(), output, "5h:45%")
527527
assert.Contains(s.T(), output, "7d:23%")
528528
assert.Contains(s.T(), output, "🚦")
@@ -541,7 +541,7 @@ func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_WithoutQuota() {
541541
GitBranch: "main",
542542
})
543543

544-
if runtime.GOOS == "darwin" {
544+
if runtime.GOOS == "darwin" || runtime.GOOS == "linux" {
545545
assert.Contains(s.T(), output, "🚦 -")
546546
} else {
547547
assert.NotContains(s.T(), output, "🚦")

daemon/anthropic_ratelimit.go

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import (
55
"encoding/json"
66
"fmt"
77
"net/http"
8+
"os"
89
"os/exec"
10+
"path/filepath"
911
"runtime"
1012
"sync"
1113
"time"
@@ -40,12 +42,12 @@ type anthropicUsageBucket struct {
4042
ResetsAt string `json:"resets_at"`
4143
}
4244

43-
// keychainCredentials maps the JSON stored in macOS Keychain for Claude Code
44-
type keychainCredentials struct {
45-
ClaudeAiOauth *keychainOAuthEntry `json:"claudeAiOauth"`
45+
// claudeCodeCredentials maps the JSON stored in macOS Keychain or ~/.claude/.credentials.json
46+
type claudeCodeCredentials struct {
47+
ClaudeAiOauth *claudeCodeOAuthEntry `json:"claudeAiOauth"`
4648
}
4749

48-
type keychainOAuthEntry struct {
50+
type claudeCodeOAuthEntry struct {
4951
AccessToken string `json:"accessToken"`
5052
RefreshToken string `json:"refreshToken"`
5153
ExpiresAt int64 `json:"expiresAt"`
@@ -54,25 +56,55 @@ type keychainOAuthEntry struct {
5456
RateLimitTier any `json:"rateLimitTier"`
5557
}
5658

57-
// fetchClaudeCodeOAuthToken reads the OAuth token from macOS Keychain.
58-
// Returns ("", nil) on non-macOS platforms.
59+
// fetchClaudeCodeOAuthToken reads the OAuth token from the platform-specific credential store.
60+
// macOS: reads from Keychain via `security` command.
61+
// Linux: reads from ~/.claude/.credentials.json file.
62+
// Returns ("", nil) on unsupported platforms.
5963
func fetchClaudeCodeOAuthToken() (string, error) {
60-
if runtime.GOOS != "darwin" {
64+
switch runtime.GOOS {
65+
case "darwin":
66+
return fetchOAuthTokenFromKeychain()
67+
case "linux":
68+
return fetchOAuthTokenFromCredentialsFile()
69+
default:
6170
return "", nil
6271
}
72+
}
6373

74+
// fetchOAuthTokenFromKeychain reads the OAuth token from macOS Keychain.
75+
func fetchOAuthTokenFromKeychain() (string, error) {
6476
out, err := exec.Command("security", "find-generic-password", "-s", "Claude Code-credentials", "-w").Output()
6577
if err != nil {
6678
return "", fmt.Errorf("keychain lookup failed: %w", err)
6779
}
6880

69-
var creds keychainCredentials
70-
if err := json.Unmarshal(out, &creds); err != nil {
71-
return "", fmt.Errorf("failed to parse keychain JSON: %w", err)
81+
return parseOAuthTokenFromJSON(out)
82+
}
83+
84+
// fetchOAuthTokenFromCredentialsFile reads the OAuth token from ~/.claude/.credentials.json.
85+
func fetchOAuthTokenFromCredentialsFile() (string, error) {
86+
homeDir, err := os.UserHomeDir()
87+
if err != nil {
88+
return "", fmt.Errorf("failed to get home directory: %w", err)
89+
}
90+
91+
data, err := os.ReadFile(filepath.Join(homeDir, ".claude", ".credentials.json"))
92+
if err != nil {
93+
return "", fmt.Errorf("credentials file read failed: %w", err)
94+
}
95+
96+
return parseOAuthTokenFromJSON(data)
97+
}
98+
99+
// parseOAuthTokenFromJSON parses Claude Code credentials JSON and extracts the OAuth access token.
100+
func parseOAuthTokenFromJSON(data []byte) (string, error) {
101+
var creds claudeCodeCredentials
102+
if err := json.Unmarshal(data, &creds); err != nil {
103+
return "", fmt.Errorf("failed to parse credentials JSON: %w", err)
72104
}
73105

74106
if creds.ClaudeAiOauth == nil || creds.ClaudeAiOauth.AccessToken == "" {
75-
return "", fmt.Errorf("no OAuth access token found in keychain")
107+
return "", fmt.Errorf("no OAuth access token found in credentials")
76108
}
77109

78110
return creds.ClaudeAiOauth.AccessToken, nil

daemon/anthropic_ratelimit_test.go

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import (
66
"fmt"
77
"net/http"
88
"net/http/httptest"
9+
"os"
10+
"path/filepath"
911
"testing"
1012

1113
"github.com/malamtime/cli/model"
@@ -76,7 +78,7 @@ func TestFetchAnthropicUsage_NonOKStatus(t *testing.T) {
7678
func TestParseKeychainJSON(t *testing.T) {
7779
raw := `{"claudeAiOauth":{"accessToken":"sk-ant-test-token-123"}}`
7880

79-
var creds keychainCredentials
81+
var creds claudeCodeCredentials
8082
err := json.Unmarshal([]byte(raw), &creds)
8183
assert.NoError(t, err)
8284
assert.NotNil(t, creds.ClaudeAiOauth)
@@ -86,7 +88,7 @@ func TestParseKeychainJSON(t *testing.T) {
8688
func TestParseKeychainJSON_MissingOAuth(t *testing.T) {
8789
raw := `{"someOtherKey":"value"}`
8890

89-
var creds keychainCredentials
91+
var creds claudeCodeCredentials
9092
err := json.Unmarshal([]byte(raw), &creds)
9193
assert.NoError(t, err)
9294
assert.Nil(t, creds.ClaudeAiOauth)
@@ -95,7 +97,7 @@ func TestParseKeychainJSON_MissingOAuth(t *testing.T) {
9597
func TestParseKeychainJSON_EmptyAccessToken(t *testing.T) {
9698
raw := `{"claudeAiOauth":{"accessToken":""}}`
9799

98-
var creds keychainCredentials
100+
var creds claudeCodeCredentials
99101
err := json.Unmarshal([]byte(raw), &creds)
100102
assert.NoError(t, err)
101103
assert.NotNil(t, creds.ClaudeAiOauth)
@@ -164,3 +166,78 @@ func TestAnthropicRateLimitCache_GetCachedRateLimit_ReturnsCopy(t *testing.T) {
164166
assert.Equal(t, 0.5, service.rateLimitCache.usage.FiveHourUtilization)
165167
service.rateLimitCache.mu.RUnlock()
166168
}
169+
170+
func TestParseOAuthTokenFromJSON_Valid(t *testing.T) {
171+
raw := `{"claudeAiOauth":{"accessToken":"sk-ant-test-token-123","refreshToken":"sk-ref","expiresAt":1773399176544,"scopes":["user:inference"],"subscriptionType":"max","rateLimitTier":"default_claude_max_5x"}}`
172+
token, err := parseOAuthTokenFromJSON([]byte(raw))
173+
assert.NoError(t, err)
174+
assert.Equal(t, "sk-ant-test-token-123", token)
175+
}
176+
177+
func TestParseOAuthTokenFromJSON_MissingOAuth(t *testing.T) {
178+
raw := `{"someOtherKey":"value"}`
179+
token, err := parseOAuthTokenFromJSON([]byte(raw))
180+
assert.Error(t, err)
181+
assert.Contains(t, err.Error(), "no OAuth access token found")
182+
assert.Empty(t, token)
183+
}
184+
185+
func TestParseOAuthTokenFromJSON_EmptyToken(t *testing.T) {
186+
raw := `{"claudeAiOauth":{"accessToken":""}}`
187+
token, err := parseOAuthTokenFromJSON([]byte(raw))
188+
assert.Error(t, err)
189+
assert.Contains(t, err.Error(), "no OAuth access token found")
190+
assert.Empty(t, token)
191+
}
192+
193+
func TestParseOAuthTokenFromJSON_InvalidJSON(t *testing.T) {
194+
raw := `not-json`
195+
token, err := parseOAuthTokenFromJSON([]byte(raw))
196+
assert.Error(t, err)
197+
assert.Contains(t, err.Error(), "failed to parse credentials JSON")
198+
assert.Empty(t, token)
199+
}
200+
201+
func TestFetchOAuthTokenFromCredentialsFile_Valid(t *testing.T) {
202+
tmpDir := t.TempDir()
203+
t.Setenv("HOME", tmpDir)
204+
205+
claudeDir := filepath.Join(tmpDir, ".claude")
206+
err := os.MkdirAll(claudeDir, 0700)
207+
assert.NoError(t, err)
208+
209+
content := `{"claudeAiOauth":{"accessToken":"sk-test-linux-token","refreshToken":"sk-ref","expiresAt":1773399176544}}`
210+
err = os.WriteFile(filepath.Join(claudeDir, ".credentials.json"), []byte(content), 0600)
211+
assert.NoError(t, err)
212+
213+
token, err := fetchOAuthTokenFromCredentialsFile()
214+
assert.NoError(t, err)
215+
assert.Equal(t, "sk-test-linux-token", token)
216+
}
217+
218+
func TestFetchOAuthTokenFromCredentialsFile_MissingFile(t *testing.T) {
219+
tmpDir := t.TempDir()
220+
t.Setenv("HOME", tmpDir)
221+
222+
token, err := fetchOAuthTokenFromCredentialsFile()
223+
assert.Error(t, err)
224+
assert.Contains(t, err.Error(), "credentials file read failed")
225+
assert.Empty(t, token)
226+
}
227+
228+
func TestFetchOAuthTokenFromCredentialsFile_InvalidJSON(t *testing.T) {
229+
tmpDir := t.TempDir()
230+
t.Setenv("HOME", tmpDir)
231+
232+
claudeDir := filepath.Join(tmpDir, ".claude")
233+
err := os.MkdirAll(claudeDir, 0700)
234+
assert.NoError(t, err)
235+
236+
err = os.WriteFile(filepath.Join(claudeDir, ".credentials.json"), []byte("not-json"), 0600)
237+
assert.NoError(t, err)
238+
239+
token, err := fetchOAuthTokenFromCredentialsFile()
240+
assert.Error(t, err)
241+
assert.Contains(t, err.Error(), "failed to parse credentials JSON")
242+
assert.Empty(t, token)
243+
}

daemon/cc_info_timer.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -393,9 +393,9 @@ func (s *CCInfoTimerService) cleanupStaleGitCache() {
393393
}
394394

395395
// fetchRateLimit fetches Anthropic rate limit data if cache is stale.
396-
// Only runs on macOS where Keychain access is available.
396+
// Supported on macOS (Keychain) and Linux (~/.claude/.credentials.json).
397397
func (s *CCInfoTimerService) fetchRateLimit(ctx context.Context) {
398-
if runtime.GOOS != "darwin" {
398+
if runtime.GOOS != "darwin" && runtime.GOOS != "linux" {
399399
return
400400
}
401401

0 commit comments

Comments
 (0)