From adc1ebb085385133d493439d9dc0745784712760 Mon Sep 17 00:00:00 2001 From: Ailuras Date: Thu, 11 Jun 2026 11:13:40 +0800 Subject: [PATCH] Support macOS Keychain Claude credentials --- README.md | 10 ++++---- package.json | 4 ++-- src/claudeApiClient.ts | 52 ++++++++++++++++++++++++++++++++++++++++-- src/types.ts | 5 ++-- 4 files changed, 61 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index e175bed5..a92187e5 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,8 @@ on the warning prompt to see a static example first.* ## What's new in 2.0 - **Real 5-hour and weekly quota** in the status bar — reads Claude Code's - existing OAuth session at `~/.claude/.credentials.json`, zero config. + existing OAuth session from `~/.claude/.credentials.json` or the macOS + Keychain, zero config. Adapted from upstream [PR #9](https://github.com/jack21/ClaudeCodeUsage/pull/9) by [@Dobidop](https://github.com/Dobidop). - **Four new tabs**: Sessions, Projects, Content, Branches — all sortable. @@ -203,9 +204,10 @@ authoritative. `~/.claude/projects` and `~/.config/claude/projects`. **Quota row shows `5h:--% wk:--%`** -- The OAuth token at `~/.claude/.credentials.json` is missing or expired. - Log in to Claude Code once; the extension reads its credential file - read-only and refreshes the bearer if needed. +- Claude Code's OAuth token is missing or expired. Log in to Claude Code + once; the extension reads `~/.claude/.credentials.json` where present, or + the macOS Keychain entry used by Claude Code, and refreshes the bearer if + needed. **`Get AI Usage Advice` returns 404** - DeepSeek's current endpoint does **not** use a `/v1` prefix. Use diff --git a/package.json b/package.json index 2fe57d55..4ac378d3 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,7 @@ "claudeCodeUsage.usageLimitTracking": { "type": "boolean", "default": true, - "description": "Show real 5-hour and weekly limit utilisation in the status bar. Uses Claude Code's existing sign-in (~/.claude/.credentials.json) — no configuration needed." + "description": "Show real 5-hour and weekly limit utilisation in the status bar. Uses Claude Code's existing sign-in (~/.claude/.credentials.json or macOS Keychain) — no configuration needed." }, "claudeCodeUsage.advice.apiKey": { "type": "string", @@ -183,4 +183,4 @@ "@types/vscode": "^1.74.0", "typescript": "^5.8.3" } -} \ No newline at end of file +} diff --git a/src/claudeApiClient.ts b/src/claudeApiClient.ts index 497115ad..5d29a2b6 100644 --- a/src/claudeApiClient.ts +++ b/src/claudeApiClient.ts @@ -1,4 +1,4 @@ -import { spawn } from 'child_process'; +import { execFileSync, spawn } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -27,6 +27,7 @@ interface HttpResponse { export class ClaudeApiClient { private readonly credentialsPath: string; private credentials: ClaudeCredentials | null = null; + private credentialsSource: 'file' | 'keychain' | null = null; private rateLimitedUntil: number = 0; private out: vscode.OutputChannel | null; // Once curl has succeeded after fetch failed, remember so we don't keep @@ -49,7 +50,7 @@ export class ClaudeApiClient { try { if (!fs.existsSync(this.credentialsPath)) { this.log(`credentials: missing at ${this.credentialsPath}`); - return null; + return this.loadCredentialsFromKeychain(); } const content = await fs.promises.readFile(this.credentialsPath, 'utf-8'); const parsed = JSON.parse(content) as ClaudeCredentials; @@ -58,6 +59,7 @@ export class ClaudeApiClient { return null; } this.credentials = parsed; + this.credentialsSource = 'file'; return parsed; } catch (e) { this.log(`credentials: read failed: ${(e as Error).message}`); @@ -65,9 +67,55 @@ export class ClaudeApiClient { } } + private loadCredentialsFromKeychain(): ClaudeCredentials | null { + if (process.platform !== 'darwin') { + return null; + } + try { + const content = execFileSync('/usr/bin/security', [ + 'find-generic-password', + '-s', + 'Claude Code-credentials', + '-w' + ], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); + const parsed = JSON.parse(content) as ClaudeCredentials; + if (!parsed || !parsed.claudeAiOauth || !parsed.claudeAiOauth.accessToken) { + this.log('credentials: keychain item present but no claudeAiOauth.accessToken'); + return null; + } + this.log('credentials: loaded from macOS Keychain'); + this.credentials = parsed; + this.credentialsSource = 'keychain'; + return parsed; + } catch (e) { + this.log(`credentials: keychain read failed: ${(e as Error).message}`); + return null; + } + } + private async saveCredentials(credentials: ClaudeCredentials): Promise { + if (this.credentialsSource === 'keychain' && process.platform === 'darwin') { + try { + execFileSync('/usr/bin/security', [ + 'add-generic-password', + '-a', + os.userInfo().username, + '-s', + 'Claude Code-credentials', + '-w', + JSON.stringify(credentials), + '-U' + ], { stdio: ['ignore', 'ignore', 'pipe'] }); + this.credentials = credentials; + this.log('credentials: refreshed in macOS Keychain'); + return; + } catch (e) { + this.log(`credentials: keychain write failed: ${(e as Error).message}`); + } + } await fs.promises.writeFile(this.credentialsPath, JSON.stringify(credentials), 'utf-8'); this.credentials = credentials; + this.credentialsSource = 'file'; } private isTokenExpired(credentials: ClaudeCredentials): boolean { diff --git a/src/types.ts b/src/types.ts index f4335a2f..afbf2524 100644 --- a/src/types.ts +++ b/src/types.ts @@ -164,7 +164,8 @@ export interface BranchUsage { data: UsageData; } -// OAuth credentials written by Claude Code at ~/.claude/.credentials.json. +// OAuth credentials stored by Claude Code at ~/.claude/.credentials.json or in +// the macOS Keychain. export interface ClaudeCredentials { claudeAiOauth: { accessToken: string; @@ -184,4 +185,4 @@ export interface ClaudeApiUsageResponse { five_hour?: ClaudeUsageLimit; seven_day?: ClaudeUsageLimit; seven_day_opus?: ClaudeUsageLimit; -} \ No newline at end of file +}