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
6 changes: 3 additions & 3 deletions commands/cc_statusline.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 CLAUDE.md rule states quota is macOS-only but code now enables it on Linux without updating the rule

CLAUDE.md line 127 states: "CC statusline quota display is macOS-only (requires Keychain access to Claude Code OAuth token)". The new code at commands/cc_statusline.go:218 adds || runtime.GOOS == "linux" to enable quota display on Linux, directly contradicting this documented constraint. The same violation applies to the fallback output at commands/cc_statusline.go:282 and the daemon fetch guard at daemon/cc_info_timer.go:398. CLAUDE.md must be updated to reflect that quota is now supported on both macOS (Keychain) and Linux (~/.claude/.credentials.json).

Prompt for agents
Update CLAUDE.md line 127 in the Important Notes section to reflect the new Linux support. Change:

- CC statusline quota display is macOS-only (requires Keychain access to Claude Code OAuth token)

To something like:

- CC statusline quota display is supported on macOS (Keychain) and Linux (~/.claude/.credentials.json) for Claude Code OAuth token access

Also update docs/CC_STATUSLINE.md in multiple places:
- Line 15: Remove "macOS only" or change to "macOS and Linux"
- Line 81: Update parenthetical about macOS only
- Line 87: Update the Platform Note section to reflect Linux now shows quota
- Lines 200-204: Update the Requirements for Quota Utilization section
- Line 245: Update the troubleshooting entry

Also update README.md line 100: Remove "macOS only" from the quota row description.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

parts = append(parts, formatQuotaPart(p.FiveHourUtil, p.SevenDayUtil, p.QuotaError))
}

Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions commands/cc_statusline_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, "🚦")
Expand All @@ -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, "🚦")
Expand Down
54 changes: 43 additions & 11 deletions daemon/anthropic_ratelimit.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import (
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"sync"
"time"
Expand Down Expand Up @@ -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"`
Expand All @@ -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)
}
Comment on lines +85 to +97
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To improve maintainability and avoid hardcoding file path components, consider defining ".claude" and ".credentials.json" as constants. This makes the path easier to read and manage, especially if these values are used elsewhere. This aligns with best practices for platform-independent path handling.

func fetchOAuthTokenFromCredentialsFile() (string, error) {
	homeDir, err := os.UserHomeDir()
	if err != nil {
		return "", fmt.Errorf("failed to get home directory: %w", err)
	}

	const (
		claudeConfigDir       = ".claude"
		claudeCredentialsFile = ".credentials.json"
	)
	credentialsPath := filepath.Join(homeDir, claudeConfigDir, claudeCredentialsFile)
	data, err := os.ReadFile(credentialsPath)
	if err != nil {
		return "", fmt.Errorf("credentials file read failed: %w", err)
	}

	return parseOAuthTokenFromJSON(data)
}
References
  1. For platform-independent paths, use filepath.Join to combine segments and os.UserHomeDir() to get the home directory, rather than hardcoding path separators or environment variables like $HOME. Defining path components as constants further enhances maintainability and readability.


// 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
Expand Down
83 changes: 80 additions & 3 deletions daemon/anthropic_ratelimit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"

"github.com/malamtime/cli/model"
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
4 changes: 2 additions & 2 deletions daemon/cc_info_timer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Loading