@davidbrianethier/cc-usage 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -0
- package/bin/cli.js +109 -0
- package/package.json +41 -0
- package/src/api.js +104 -0
- package/src/api.test.js +143 -0
- package/src/auth.js +56 -0
- package/src/auth.test.js +78 -0
- package/src/main.js +97 -0
package/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# cc-usage
|
|
2
|
+
|
|
3
|
+
Claude.ai usage monitor for macOS menubar. Shows your 5-hour and weekly usage limits as text in the menubar.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# From npm
|
|
9
|
+
npm install -g @davidbrianethier/cc-usage
|
|
10
|
+
|
|
11
|
+
# Or from GitHub
|
|
12
|
+
npm install -g github:dbe/cc-usage
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# First run - will prompt for sessionKey
|
|
19
|
+
cc-usage
|
|
20
|
+
|
|
21
|
+
# Re-authenticate
|
|
22
|
+
cc-usage --reauth
|
|
23
|
+
|
|
24
|
+
# Show help
|
|
25
|
+
cc-usage --help
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Getting Your Session Key
|
|
29
|
+
|
|
30
|
+
1. Log into [claude.ai](https://claude.ai) in your browser
|
|
31
|
+
2. Open DevTools: `Cmd+Option+I`
|
|
32
|
+
3. Go to **Application** tab > **Cookies** > **claude.ai**
|
|
33
|
+
4. Find `sessionKey` and copy its value
|
|
34
|
+
5. Paste when prompted by cc-usage
|
|
35
|
+
|
|
36
|
+
## Features
|
|
37
|
+
|
|
38
|
+
- **Menubar display**: Shows `45%|72%` (5-hour | 7-day usage)
|
|
39
|
+
- **Auto-refresh**: Updates every 10 seconds
|
|
40
|
+
- **Power-aware**: Pauses during system sleep
|
|
41
|
+
- **Auto-start**: Launches on login
|
|
42
|
+
- **Context menu**: Click for Reauth/Quit options
|
|
43
|
+
|
|
44
|
+
## Requirements
|
|
45
|
+
|
|
46
|
+
- macOS 10.15+
|
|
47
|
+
- Node.js 18+
|
|
48
|
+
|
|
49
|
+
## License
|
|
50
|
+
|
|
51
|
+
ISC
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { spawn, execSync } = require('child_process');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const readline = require('readline');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
|
|
8
|
+
// Parse arguments
|
|
9
|
+
const args = process.argv.slice(2);
|
|
10
|
+
const showHelp = args.includes('--help') || args.includes('-h');
|
|
11
|
+
const forceReauth = args.includes('--reauth');
|
|
12
|
+
|
|
13
|
+
if (showHelp) {
|
|
14
|
+
console.log(`
|
|
15
|
+
cc-usage - Claude.ai Usage Monitor
|
|
16
|
+
|
|
17
|
+
Usage: cc-usage [options]
|
|
18
|
+
|
|
19
|
+
Options:
|
|
20
|
+
--help, -h Show this help message
|
|
21
|
+
--reauth Clear stored session and re-authenticate
|
|
22
|
+
|
|
23
|
+
First Run:
|
|
24
|
+
1. Log into claude.ai in your browser
|
|
25
|
+
2. Open DevTools (Cmd+Option+I) > Application > Cookies > claude.ai
|
|
26
|
+
3. Copy the value of 'sessionKey' cookie
|
|
27
|
+
4. Paste when prompted
|
|
28
|
+
|
|
29
|
+
The app will appear in your menubar showing usage limits.
|
|
30
|
+
`);
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function main() {
|
|
35
|
+
// Dynamic import for ES modules compatibility with keytar
|
|
36
|
+
const auth = require('../src/auth.js');
|
|
37
|
+
|
|
38
|
+
// Handle --reauth flag
|
|
39
|
+
if (forceReauth) {
|
|
40
|
+
await auth.clearSessionKey();
|
|
41
|
+
console.log('Session cleared. Please enter new session key.');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Check if we have a session key
|
|
45
|
+
let hasKey = await auth.hasSessionKey();
|
|
46
|
+
|
|
47
|
+
if (!hasKey) {
|
|
48
|
+
console.log('\n=== cc-usage Setup ===\n');
|
|
49
|
+
console.log('To monitor your Claude.ai usage, you need to provide your session key.');
|
|
50
|
+
console.log('');
|
|
51
|
+
console.log('How to get your session key:');
|
|
52
|
+
console.log(' 1. Open https://claude.ai in your browser and log in');
|
|
53
|
+
console.log(' 2. Open DevTools: Cmd+Option+I (Mac) or F12 (Windows)');
|
|
54
|
+
console.log(' 3. Go to Application tab > Cookies > claude.ai');
|
|
55
|
+
console.log(' 4. Find "sessionKey" and copy its value');
|
|
56
|
+
console.log('');
|
|
57
|
+
|
|
58
|
+
const rl = readline.createInterface({
|
|
59
|
+
input: process.stdin,
|
|
60
|
+
output: process.stdout
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const sessionKey = await new Promise((resolve) => {
|
|
64
|
+
rl.question('Enter sessionKey: ', (answer) => {
|
|
65
|
+
rl.close();
|
|
66
|
+
resolve(answer.trim());
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
if (!sessionKey) {
|
|
71
|
+
console.error('Error: Session key cannot be empty.');
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
await auth.setSessionKey(sessionKey);
|
|
76
|
+
console.log('\nSession key saved to keychain.');
|
|
77
|
+
console.log('Starting cc-usage...');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const electronPath = require('electron');
|
|
81
|
+
const appPath = path.join(__dirname, '..');
|
|
82
|
+
|
|
83
|
+
// macOS: clear quarantine attribute from Electron binary to avoid Gatekeeper issues
|
|
84
|
+
if (process.platform === 'darwin') {
|
|
85
|
+
const electronApp = path.join(appPath, 'node_modules', 'electron', 'dist', 'Electron.app');
|
|
86
|
+
if (fs.existsSync(electronApp)) {
|
|
87
|
+
try {
|
|
88
|
+
execSync(`xattr -cr "${electronApp}"`, { stdio: 'ignore' });
|
|
89
|
+
} catch (e) {
|
|
90
|
+
// Ignore errors - xattr may not be needed
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const child = spawn(electronPath, [appPath], {
|
|
96
|
+
detached: true,
|
|
97
|
+
stdio: 'ignore'
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
child.unref();
|
|
101
|
+
|
|
102
|
+
console.log('cc-usage is now running in your menubar.');
|
|
103
|
+
process.exit(0);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
main().catch((err) => {
|
|
107
|
+
console.error('Error:', err.message);
|
|
108
|
+
process.exit(1);
|
|
109
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@davidbrianethier/cc-usage",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Claude.ai usage monitor for macOS menubar - shows 5-hour and weekly limits",
|
|
5
|
+
"main": "src/main.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"cc-usage": "./bin/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "electron .",
|
|
11
|
+
"test": "node --test src/*.test.js"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"claude",
|
|
15
|
+
"anthropic",
|
|
16
|
+
"usage",
|
|
17
|
+
"menubar",
|
|
18
|
+
"macos",
|
|
19
|
+
"tray",
|
|
20
|
+
"monitor"
|
|
21
|
+
],
|
|
22
|
+
"author": "Brian Ethier",
|
|
23
|
+
"license": "ISC",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "https://github.com/dbe/cc-usage"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/dbe/cc-usage#readme",
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=18.0.0"
|
|
31
|
+
},
|
|
32
|
+
"os": [
|
|
33
|
+
"darwin"
|
|
34
|
+
],
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"keytar": "^7.9.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"electron": "^33.0.0"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/api.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
const BASE_URL = 'https://claude.ai/api/organizations';
|
|
2
|
+
|
|
3
|
+
let cachedOrgId = null;
|
|
4
|
+
|
|
5
|
+
function buildHeaders(sessionKey) {
|
|
6
|
+
return {
|
|
7
|
+
'accept': '*/*',
|
|
8
|
+
'content-type': 'application/json',
|
|
9
|
+
'anthropic-client-platform': 'web_claude_ai',
|
|
10
|
+
'anthropic-client-version': '1.0.0',
|
|
11
|
+
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
12
|
+
'origin': 'https://claude.ai',
|
|
13
|
+
'referer': 'https://claude.ai/settings/usage',
|
|
14
|
+
'Cookie': `sessionKey=${sessionKey}`
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function fetchOrganizationId(sessionKey) {
|
|
19
|
+
if (cachedOrgId) return cachedOrgId;
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const response = await fetch(BASE_URL, {
|
|
23
|
+
method: 'GET',
|
|
24
|
+
headers: buildHeaders(sessionKey)
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
if (!response.ok) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const orgs = await response.json();
|
|
32
|
+
if (orgs && orgs.length > 0) {
|
|
33
|
+
cachedOrgId = orgs[0].uuid;
|
|
34
|
+
return cachedOrgId;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
} catch (error) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function fetchUsage(sessionKey) {
|
|
43
|
+
const errorResult = {
|
|
44
|
+
fiveHour: null,
|
|
45
|
+
sevenDay: null,
|
|
46
|
+
sevenDayOpus: null,
|
|
47
|
+
sevenDaySonnet: null,
|
|
48
|
+
error: null
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const orgId = await fetchOrganizationId(sessionKey);
|
|
53
|
+
if (!orgId) {
|
|
54
|
+
return { ...errorResult, error: '401' };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const response = await fetch(`${BASE_URL}/${orgId}/usage`, {
|
|
58
|
+
method: 'GET',
|
|
59
|
+
headers: buildHeaders(sessionKey)
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
if (!response.ok) {
|
|
63
|
+
if (response.status === 401 || response.status === 403) {
|
|
64
|
+
cachedOrgId = null;
|
|
65
|
+
return { ...errorResult, error: '401' };
|
|
66
|
+
}
|
|
67
|
+
return { ...errorResult, error: `HTTP ${response.status}` };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const data = await response.json();
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
fiveHour: data.five_hour ? {
|
|
74
|
+
percent: data.five_hour.utilization,
|
|
75
|
+
resetsAt: data.five_hour.resets_at ? new Date(data.five_hour.resets_at) : null
|
|
76
|
+
} : null,
|
|
77
|
+
sevenDay: data.seven_day ? {
|
|
78
|
+
percent: data.seven_day.utilization,
|
|
79
|
+
resetsAt: data.seven_day.resets_at ? new Date(data.seven_day.resets_at) : null
|
|
80
|
+
} : null,
|
|
81
|
+
sevenDayOpus: data.seven_day_opus ? {
|
|
82
|
+
percent: data.seven_day_opus.utilization,
|
|
83
|
+
resetsAt: data.seven_day_opus.resets_at ? new Date(data.seven_day_opus.resets_at) : null
|
|
84
|
+
} : null,
|
|
85
|
+
sevenDaySonnet: data.seven_day_sonnet ? {
|
|
86
|
+
percent: data.seven_day_sonnet.utilization,
|
|
87
|
+
resetsAt: data.seven_day_sonnet.resets_at ? new Date(data.seven_day_sonnet.resets_at) : null
|
|
88
|
+
} : null,
|
|
89
|
+
error: null
|
|
90
|
+
};
|
|
91
|
+
} catch (error) {
|
|
92
|
+
return { ...errorResult, error: `Network error: ${error.message}` };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function clearCache() {
|
|
97
|
+
cachedOrgId = null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = {
|
|
101
|
+
fetchOrganizationId,
|
|
102
|
+
fetchUsage,
|
|
103
|
+
clearCache
|
|
104
|
+
};
|
package/src/api.test.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
const { describe, it, mock, beforeEach } = require('node:test');
|
|
2
|
+
const assert = require('node:assert');
|
|
3
|
+
|
|
4
|
+
describe('api', () => {
|
|
5
|
+
let api;
|
|
6
|
+
let mockFetch;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
// Reset module cache to allow fresh mocking
|
|
10
|
+
delete require.cache[require.resolve('./api.js')];
|
|
11
|
+
|
|
12
|
+
mockFetch = mock.fn();
|
|
13
|
+
global.fetch = mockFetch;
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe('fetchOrganizationId', () => {
|
|
17
|
+
it('returns organization ID on success', async () => {
|
|
18
|
+
mockFetch.mock.mockImplementation(() => Promise.resolve({
|
|
19
|
+
ok: true,
|
|
20
|
+
status: 200,
|
|
21
|
+
json: () => Promise.resolve([{ uuid: 'org-123', name: 'Personal' }])
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
api = require('./api.js');
|
|
25
|
+
const result = await api.fetchOrganizationId('sk-ant-test');
|
|
26
|
+
|
|
27
|
+
assert.strictEqual(result, 'org-123');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('returns null on 401', async () => {
|
|
31
|
+
mockFetch.mock.mockImplementation(() => Promise.resolve({
|
|
32
|
+
ok: false,
|
|
33
|
+
status: 401
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
api = require('./api.js');
|
|
37
|
+
const result = await api.fetchOrganizationId('bad-key');
|
|
38
|
+
|
|
39
|
+
assert.strictEqual(result, null);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('fetchUsage', () => {
|
|
44
|
+
it('returns usage data on success', async () => {
|
|
45
|
+
// First call for org ID, second for usage
|
|
46
|
+
let callCount = 0;
|
|
47
|
+
mockFetch.mock.mockImplementation(() => {
|
|
48
|
+
callCount++;
|
|
49
|
+
if (callCount === 1) {
|
|
50
|
+
return Promise.resolve({
|
|
51
|
+
ok: true,
|
|
52
|
+
status: 200,
|
|
53
|
+
json: () => Promise.resolve([{ uuid: 'org-123' }])
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return Promise.resolve({
|
|
57
|
+
ok: true,
|
|
58
|
+
status: 200,
|
|
59
|
+
json: () => Promise.resolve({
|
|
60
|
+
five_hour: { utilization: 45.5, resets_at: '2026-01-31T00:00:00Z' },
|
|
61
|
+
seven_day: { utilization: 68.2, resets_at: '2026-02-05T00:00:00Z' },
|
|
62
|
+
seven_day_opus: { utilization: 77.0, resets_at: '2026-02-05T00:00:00Z' },
|
|
63
|
+
seven_day_sonnet: { utilization: 30.0, resets_at: '2026-02-05T00:00:00Z' }
|
|
64
|
+
})
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
api = require('./api.js');
|
|
69
|
+
const result = await api.fetchUsage('sk-ant-test');
|
|
70
|
+
|
|
71
|
+
assert.strictEqual(result.error, null);
|
|
72
|
+
assert.strictEqual(result.fiveHour.percent, 45.5);
|
|
73
|
+
assert.strictEqual(result.sevenDay.percent, 68.2);
|
|
74
|
+
assert.strictEqual(result.sevenDayOpus.percent, 77.0);
|
|
75
|
+
assert.strictEqual(result.sevenDaySonnet.percent, 30.0);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('returns error object on 401', async () => {
|
|
79
|
+
mockFetch.mock.mockImplementation(() => Promise.resolve({
|
|
80
|
+
ok: false,
|
|
81
|
+
status: 401
|
|
82
|
+
}));
|
|
83
|
+
|
|
84
|
+
api = require('./api.js');
|
|
85
|
+
const result = await api.fetchUsage('bad-key');
|
|
86
|
+
|
|
87
|
+
assert.strictEqual(result.error, '401');
|
|
88
|
+
assert.strictEqual(result.fiveHour, null);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('returns error object on network error', async () => {
|
|
92
|
+
let callCount = 0;
|
|
93
|
+
mockFetch.mock.mockImplementation(() => {
|
|
94
|
+
callCount++;
|
|
95
|
+
if (callCount === 1) {
|
|
96
|
+
return Promise.resolve({
|
|
97
|
+
ok: true,
|
|
98
|
+
status: 200,
|
|
99
|
+
json: () => Promise.resolve([{ uuid: 'org-123' }])
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return Promise.reject(new Error('Network error'));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
api = require('./api.js');
|
|
106
|
+
const result = await api.fetchUsage('sk-ant-test');
|
|
107
|
+
|
|
108
|
+
assert.ok(result.error.includes('Network'));
|
|
109
|
+
assert.strictEqual(result.fiveHour, null);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('handles missing optional fields gracefully', async () => {
|
|
113
|
+
let callCount = 0;
|
|
114
|
+
mockFetch.mock.mockImplementation(() => {
|
|
115
|
+
callCount++;
|
|
116
|
+
if (callCount === 1) {
|
|
117
|
+
return Promise.resolve({
|
|
118
|
+
ok: true,
|
|
119
|
+
status: 200,
|
|
120
|
+
json: () => Promise.resolve([{ uuid: 'org-123' }])
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
return Promise.resolve({
|
|
124
|
+
ok: true,
|
|
125
|
+
status: 200,
|
|
126
|
+
json: () => Promise.resolve({
|
|
127
|
+
five_hour: { utilization: 50, resets_at: null },
|
|
128
|
+
seven_day: { utilization: 60, resets_at: null }
|
|
129
|
+
// No opus or sonnet fields
|
|
130
|
+
})
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
api = require('./api.js');
|
|
135
|
+
const result = await api.fetchUsage('sk-ant-test');
|
|
136
|
+
|
|
137
|
+
assert.strictEqual(result.error, null);
|
|
138
|
+
assert.strictEqual(result.fiveHour.percent, 50);
|
|
139
|
+
assert.strictEqual(result.sevenDayOpus, null);
|
|
140
|
+
assert.strictEqual(result.sevenDaySonnet, null);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
});
|
package/src/auth.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth module - Keychain-based session key storage
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
let keytar = require('keytar');
|
|
6
|
+
|
|
7
|
+
const SERVICE = 'cc-usage';
|
|
8
|
+
const ACCOUNT = 'sessionKey';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* For testing: inject mock keytar
|
|
12
|
+
* @param {object} mock - Mock keytar object
|
|
13
|
+
*/
|
|
14
|
+
function _setKeytar(mock) {
|
|
15
|
+
keytar = mock;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Get session key from keychain
|
|
20
|
+
* @returns {Promise<string|null>} Session key or null if not found
|
|
21
|
+
*/
|
|
22
|
+
async function getSessionKey() {
|
|
23
|
+
return await keytar.getPassword(SERVICE, ACCOUNT);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Store session key in keychain
|
|
28
|
+
* @param {string} key - Session key to store
|
|
29
|
+
*/
|
|
30
|
+
async function setSessionKey(key) {
|
|
31
|
+
await keytar.setPassword(SERVICE, ACCOUNT, key);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Remove session key from keychain
|
|
36
|
+
*/
|
|
37
|
+
async function clearSessionKey() {
|
|
38
|
+
await keytar.deletePassword(SERVICE, ACCOUNT);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Check if session key exists in keychain
|
|
43
|
+
* @returns {Promise<boolean>} True if key exists
|
|
44
|
+
*/
|
|
45
|
+
async function hasSessionKey() {
|
|
46
|
+
const key = await getSessionKey();
|
|
47
|
+
return key !== null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = {
|
|
51
|
+
getSessionKey,
|
|
52
|
+
setSessionKey,
|
|
53
|
+
clearSessionKey,
|
|
54
|
+
hasSessionKey,
|
|
55
|
+
_setKeytar
|
|
56
|
+
};
|
package/src/auth.test.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
const { describe, it, mock, beforeEach } = require('node:test');
|
|
2
|
+
const assert = require('node:assert');
|
|
3
|
+
|
|
4
|
+
// Mock keytar before requiring auth
|
|
5
|
+
const mockKeytar = {
|
|
6
|
+
getPassword: mock.fn(),
|
|
7
|
+
setPassword: mock.fn(),
|
|
8
|
+
deletePassword: mock.fn()
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// Use dynamic import pattern to allow mocking
|
|
12
|
+
let auth;
|
|
13
|
+
|
|
14
|
+
describe('auth', () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
mockKeytar.getPassword.mock.resetCalls();
|
|
17
|
+
mockKeytar.setPassword.mock.resetCalls();
|
|
18
|
+
mockKeytar.deletePassword.mock.resetCalls();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('getSessionKey', () => {
|
|
22
|
+
it('returns null when no key stored', async () => {
|
|
23
|
+
mockKeytar.getPassword.mock.mockImplementation(() => Promise.resolve(null));
|
|
24
|
+
const auth = require('./auth.js');
|
|
25
|
+
// Inject mock
|
|
26
|
+
auth._setKeytar(mockKeytar);
|
|
27
|
+
const result = await auth.getSessionKey();
|
|
28
|
+
assert.strictEqual(result, null);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('returns stored key when present', async () => {
|
|
32
|
+
mockKeytar.getPassword.mock.mockImplementation(() => Promise.resolve('sk-ant-123'));
|
|
33
|
+
const auth = require('./auth.js');
|
|
34
|
+
auth._setKeytar(mockKeytar);
|
|
35
|
+
const result = await auth.getSessionKey();
|
|
36
|
+
assert.strictEqual(result, 'sk-ant-123');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('setSessionKey', () => {
|
|
41
|
+
it('stores key in keychain', async () => {
|
|
42
|
+
mockKeytar.setPassword.mock.mockImplementation(() => Promise.resolve());
|
|
43
|
+
const auth = require('./auth.js');
|
|
44
|
+
auth._setKeytar(mockKeytar);
|
|
45
|
+
await auth.setSessionKey('sk-ant-456');
|
|
46
|
+
assert.strictEqual(mockKeytar.setPassword.mock.calls.length, 1);
|
|
47
|
+
assert.deepStrictEqual(mockKeytar.setPassword.mock.calls[0].arguments, ['cc-usage', 'sessionKey', 'sk-ant-456']);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('clearSessionKey', () => {
|
|
52
|
+
it('removes key from keychain', async () => {
|
|
53
|
+
mockKeytar.deletePassword.mock.mockImplementation(() => Promise.resolve(true));
|
|
54
|
+
const auth = require('./auth.js');
|
|
55
|
+
auth._setKeytar(mockKeytar);
|
|
56
|
+
await auth.clearSessionKey();
|
|
57
|
+
assert.strictEqual(mockKeytar.deletePassword.mock.calls.length, 1);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('hasSessionKey', () => {
|
|
62
|
+
it('returns true when key exists', async () => {
|
|
63
|
+
mockKeytar.getPassword.mock.mockImplementation(() => Promise.resolve('sk-ant-789'));
|
|
64
|
+
const auth = require('./auth.js');
|
|
65
|
+
auth._setKeytar(mockKeytar);
|
|
66
|
+
const result = await auth.hasSessionKey();
|
|
67
|
+
assert.strictEqual(result, true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('returns false when no key', async () => {
|
|
71
|
+
mockKeytar.getPassword.mock.mockImplementation(() => Promise.resolve(null));
|
|
72
|
+
const auth = require('./auth.js');
|
|
73
|
+
auth._setKeytar(mockKeytar);
|
|
74
|
+
const result = await auth.hasSessionKey();
|
|
75
|
+
assert.strictEqual(result, false);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
});
|
package/src/main.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
const { app, Menu, Tray, powerMonitor, nativeImage } = require('electron');
|
|
2
|
+
const { getSessionKey, clearSessionKey } = require('./auth.js');
|
|
3
|
+
const { fetchUsage, clearCache } = require('./api.js');
|
|
4
|
+
|
|
5
|
+
const POLL_INTERVAL = 10 * 1000;
|
|
6
|
+
const BACKOFF_DELAYS = [5000, 10000, 30000];
|
|
7
|
+
|
|
8
|
+
let tray = null;
|
|
9
|
+
let pollingInterval = null;
|
|
10
|
+
let retryCount = 0;
|
|
11
|
+
let isPaused = false;
|
|
12
|
+
|
|
13
|
+
function formatTitle(usage) {
|
|
14
|
+
if (!usage || usage.error) {
|
|
15
|
+
return usage?.error === '401' ? '⚠️ Auth' : '...';
|
|
16
|
+
}
|
|
17
|
+
const h5 = Math.round(usage.fiveHour?.percent ?? 0);
|
|
18
|
+
const d7 = Math.round(usage.sevenDay?.percent ?? 0);
|
|
19
|
+
return `${h5}%|${d7}%`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function updateUsage() {
|
|
23
|
+
if (isPaused) return;
|
|
24
|
+
|
|
25
|
+
const sessionKey = await getSessionKey();
|
|
26
|
+
if (!sessionKey) {
|
|
27
|
+
tray.setTitle(formatTitle({ error: '401' }));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const usage = await fetchUsage(sessionKey);
|
|
32
|
+
|
|
33
|
+
if (usage.error === '401') {
|
|
34
|
+
await clearSessionKey();
|
|
35
|
+
clearCache();
|
|
36
|
+
tray.setTitle(formatTitle(usage));
|
|
37
|
+
stopPolling();
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (usage.error) {
|
|
42
|
+
retryCount = Math.min(retryCount + 1, BACKOFF_DELAYS.length - 1);
|
|
43
|
+
tray.setTitle(formatTitle(usage));
|
|
44
|
+
setTimeout(updateUsage, BACKOFF_DELAYS[retryCount]);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
retryCount = 0;
|
|
49
|
+
tray.setTitle(formatTitle(usage));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function startPolling() {
|
|
53
|
+
if (pollingInterval) return;
|
|
54
|
+
isPaused = false;
|
|
55
|
+
updateUsage();
|
|
56
|
+
pollingInterval = setInterval(updateUsage, POLL_INTERVAL);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function stopPolling() {
|
|
60
|
+
if (pollingInterval) {
|
|
61
|
+
clearInterval(pollingInterval);
|
|
62
|
+
pollingInterval = null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function createContextMenu() {
|
|
67
|
+
return Menu.buildFromTemplate([
|
|
68
|
+
{
|
|
69
|
+
label: 'Reauth',
|
|
70
|
+
click: async () => {
|
|
71
|
+
await clearSessionKey();
|
|
72
|
+
clearCache();
|
|
73
|
+
app.quit();
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
{ type: 'separator' },
|
|
77
|
+
{ label: 'Quit', click: () => app.quit() }
|
|
78
|
+
]);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
app.whenReady().then(() => {
|
|
82
|
+
// 1x1 transparent PNG to hide icon, show only title text
|
|
83
|
+
const emptyIcon = nativeImage.createFromDataURL('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==');
|
|
84
|
+
|
|
85
|
+
tray = new Tray(emptyIcon);
|
|
86
|
+
tray.setTitle('...');
|
|
87
|
+
tray.setContextMenu(createContextMenu());
|
|
88
|
+
|
|
89
|
+
powerMonitor.on('suspend', () => { isPaused = true; });
|
|
90
|
+
powerMonitor.on('resume', () => { isPaused = false; updateUsage(); });
|
|
91
|
+
|
|
92
|
+
app.setLoginItemSettings({ openAtLogin: true, openAsHidden: true });
|
|
93
|
+
|
|
94
|
+
startPolling();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
app.on('window-all-closed', (e) => e.preventDefault());
|