Skip to content
Closed
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
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -183,4 +183,4 @@
"@types/vscode": "^1.74.0",
"typescript": "^5.8.3"
}
}
}
52 changes: 50 additions & 2 deletions src/claudeApiClient.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -58,16 +59,63 @@ export class ClaudeApiClient {
return null;
}
this.credentials = parsed;
this.credentialsSource = 'file';
return parsed;
} catch (e) {
this.log(`credentials: read failed: ${(e as Error).message}`);
return null;
}
}

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<void> {
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 {
Expand Down
5 changes: 3 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -184,4 +185,4 @@ export interface ClaudeApiUsageResponse {
five_hour?: ClaudeUsageLimit;
seven_day?: ClaudeUsageLimit;
seven_day_opus?: ClaudeUsageLimit;
}
}