@zhangferry-dev/tokendash 1.6.1 → 1.7.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.
Files changed (61) hide show
  1. package/README.md +148 -84
  2. package/dist/client/assets/index-Bw503sNp.css +1 -0
  3. package/dist/client/index.html +2 -2
  4. package/dist/daemon.cjs +3411 -0
  5. package/dist/daemon.cjs.map +7 -0
  6. package/dist/electron-server.cjs +1124 -28
  7. package/dist/electron-server.cjs.map +4 -4
  8. package/dist/server/ccusage.d.ts +7 -0
  9. package/dist/server/ccusage.js +69 -0
  10. package/dist/server/daemon.d.ts +12 -0
  11. package/dist/server/daemon.js +176 -0
  12. package/dist/server/index.js +23 -11
  13. package/dist/server/insightsCalculator.d.ts +15 -0
  14. package/dist/server/insightsCalculator.js +276 -0
  15. package/dist/server/quota/adapter.d.ts +49 -0
  16. package/dist/server/quota/adapter.js +41 -0
  17. package/dist/server/quota/adapters/claude.d.ts +4 -0
  18. package/dist/server/quota/adapters/claude.js +152 -0
  19. package/dist/server/quota/adapters/codex.d.ts +16 -0
  20. package/dist/server/quota/adapters/codex.js +226 -0
  21. package/dist/server/quota/adapters/glm.d.ts +2 -0
  22. package/dist/server/quota/adapters/glm.js +139 -0
  23. package/dist/server/quota/adapters/kimi.d.ts +2 -0
  24. package/dist/server/quota/adapters/kimi.js +186 -0
  25. package/dist/server/quota/adapters/minimax.d.ts +2 -0
  26. package/dist/server/quota/adapters/minimax.js +82 -0
  27. package/dist/server/quota/cache.d.ts +20 -0
  28. package/dist/server/quota/cache.js +44 -0
  29. package/dist/server/quota/credentialsFile.d.ts +13 -0
  30. package/dist/server/quota/credentialsFile.js +23 -0
  31. package/dist/server/quota/helpers.d.ts +39 -0
  32. package/dist/server/quota/helpers.js +93 -0
  33. package/dist/server/quota/index.d.ts +5 -0
  34. package/dist/server/quota/index.js +23 -0
  35. package/dist/server/quota/quotaService.d.ts +43 -0
  36. package/dist/server/quota/quotaService.js +163 -0
  37. package/dist/server/quota/schemas.d.ts +358 -0
  38. package/dist/server/quota/schemas.js +53 -0
  39. package/dist/server/quota/types.d.ts +76 -0
  40. package/dist/server/quota/types.js +10 -0
  41. package/dist/server/routes/api.js +34 -0
  42. package/dist/server/routes/insights.d.ts +2 -0
  43. package/dist/server/routes/insights.js +155 -0
  44. package/package.json +9 -11
  45. package/resources/entitlements.mac.plist +10 -0
  46. package/resources/icon-1024.png +0 -0
  47. package/resources/icon.icns +0 -0
  48. package/resources/icon.png +0 -0
  49. package/resources/product_menu.png +0 -0
  50. package/resources/readme-hero.png +0 -0
  51. package/dist/client/assets/index-_yA9tOzZ.css +0 -1
  52. package/electron/main.cjs +0 -516
  53. package/electron/npmSync.cjs +0 -62
  54. package/electron/preload.cjs +0 -36
  55. package/electron/serverReuse.cjs +0 -59
  56. package/electron/trayBadge.cjs +0 -27
  57. package/electron/trayHelper +0 -0
  58. package/electron/trayHelper.swift +0 -152
  59. package/electron/updateService.cjs +0 -220
  60. package/electron-builder.yml +0 -20
  61. /package/dist/client/assets/{index-CY4G_b0x.js → index-C913wKtU.js} +0 -0
@@ -0,0 +1,49 @@
1
+ import type { QuotaCredentialInput, QuotaProviderId, QuotaSnapshot, QuotaProviderStatus } from './types.js';
2
+ /**
3
+ * Structured quota error. Adapters throw this (not generic Error) so the
4
+ * service can classify the status without inspecting message strings.
5
+ * Messages must never contain secrets — they reach the API response.
6
+ */
7
+ export declare class QuotaError extends Error {
8
+ readonly status: QuotaProviderStatus;
9
+ constructor(status: QuotaProviderStatus);
10
+ }
11
+ /**
12
+ * One provider adapter. Responsible only for:
13
+ * 1. detecting whether the provider is configured (credentials present)
14
+ * 2. invoking the upstream interface
15
+ * 3. validating the response
16
+ * 4. converting to a normalized snapshot
17
+ *
18
+ * It does NOT cache, deduplicate, or time out — the service owns those.
19
+ * Detecting a provider as configured means it appears in the dashboard;
20
+ * fetch() may still fail with an auth/error status.
21
+ */
22
+ export interface QuotaAdapter {
23
+ readonly provider: QuotaProviderId;
24
+ readonly displayName: string;
25
+ /** True when credentials/config exist for this provider locally. Cheap, no network. */
26
+ isConfigured(): Promise<boolean>;
27
+ /**
28
+ * Fetch a fresh normalized snapshot. Throws QuotaError on any failure.
29
+ * Must NOT include secrets in any field of the returned snapshot.
30
+ */
31
+ fetch(options?: {
32
+ credential?: QuotaCredentialInput;
33
+ }): Promise<QuotaSnapshot>;
34
+ }
35
+ /**
36
+ * Registry of all known adapters, keyed by provider id.
37
+ * Adding a quota provider = ship one adapter + register it here.
38
+ */
39
+ export declare class QuotaAdapterRegistry {
40
+ private readonly adapters;
41
+ register(adapter: QuotaAdapter): void;
42
+ get(provider: QuotaProviderId): QuotaAdapter | undefined;
43
+ list(): QuotaAdapter[];
44
+ }
45
+ /** Build a baseline snapshot with shared fields filled in. */
46
+ export declare function baseSnapshot(provider: QuotaProviderId, displayName: string, opts?: {
47
+ planName?: string;
48
+ windows?: QuotaSnapshot['windows'];
49
+ }): Pick<QuotaSnapshot, 'provider' | 'displayName' | 'planName' | 'fetchedAt' | 'freshness' | 'windows'>;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Structured quota error. Adapters throw this (not generic Error) so the
3
+ * service can classify the status without inspecting message strings.
4
+ * Messages must never contain secrets — they reach the API response.
5
+ */
6
+ export class QuotaError extends Error {
7
+ status;
8
+ constructor(status) {
9
+ const msg = 'message' in status && status.message ? status.message : '';
10
+ super(msg ? `${status.state}: ${msg}` : status.state);
11
+ this.name = 'QuotaError';
12
+ this.status = status;
13
+ }
14
+ }
15
+ /**
16
+ * Registry of all known adapters, keyed by provider id.
17
+ * Adding a quota provider = ship one adapter + register it here.
18
+ */
19
+ export class QuotaAdapterRegistry {
20
+ adapters = new Map();
21
+ register(adapter) {
22
+ this.adapters.set(adapter.provider, adapter);
23
+ }
24
+ get(provider) {
25
+ return this.adapters.get(provider);
26
+ }
27
+ list() {
28
+ return Array.from(this.adapters.values());
29
+ }
30
+ }
31
+ /** Build a baseline snapshot with shared fields filled in. */
32
+ export function baseSnapshot(provider, displayName, opts = {}) {
33
+ return {
34
+ provider,
35
+ displayName,
36
+ planName: opts.planName,
37
+ fetchedAt: new Date().toISOString(),
38
+ freshness: 'live',
39
+ windows: opts.windows ?? [],
40
+ };
41
+ }
@@ -0,0 +1,4 @@
1
+ import type { QuotaAdapter } from '../adapter.js';
2
+ export declare const claudeAdapter: QuotaAdapter;
3
+ export declare function claudeKeychainServiceNames(configDir?: string): string[];
4
+ export declare function extractClaudeAccessToken(value: unknown): string | null;
@@ -0,0 +1,152 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir, userInfo } from 'node:os';
4
+ import { execFileSync } from 'node:child_process';
5
+ import { createHash } from 'node:crypto';
6
+ import { QuotaError, baseSnapshot } from '../adapter.js';
7
+ import { fetchJsonWithTimeout, HttpError, classifyHttpError, windowFromPercent } from '../helpers.js';
8
+ export const claudeAdapter = {
9
+ provider: 'claude',
10
+ displayName: 'Claude Code',
11
+ async isConfigured() {
12
+ const token = readClaudeToken();
13
+ return !!token;
14
+ },
15
+ async fetch() {
16
+ const token = readClaudeToken();
17
+ if (!token) {
18
+ throw new QuotaError({ state: 'not_configured', message: 'no Claude Code OAuth credential found' });
19
+ }
20
+ let data;
21
+ try {
22
+ data = (await fetchJsonWithTimeout('https://api.anthropic.com/api/oauth/usage', {
23
+ headers: {
24
+ Authorization: `Bearer ${token}`,
25
+ 'anthropic-beta': 'oauth-2025-04-20',
26
+ 'Content-Type': 'application/json',
27
+ },
28
+ }));
29
+ }
30
+ catch (err) {
31
+ throw classifyFetchError(err);
32
+ }
33
+ const windows = [];
34
+ if (data.five_hour) {
35
+ windows.push(windowFromPercent('five_hour', '5-Hour Window', data.five_hour.utilization ?? 0, {
36
+ durationMins: 300,
37
+ resetsAt: normalizeIso(data.five_hour.resets_at),
38
+ }));
39
+ }
40
+ if (data.seven_day) {
41
+ windows.push(windowFromPercent('seven_day', 'Weekly', data.seven_day.utilization ?? 0, {
42
+ durationMins: 10080,
43
+ resetsAt: normalizeIso(data.seven_day.resets_at),
44
+ }));
45
+ }
46
+ if (data.seven_day_opus?.utilization !== undefined && data.seven_day_opus?.utilization !== null) {
47
+ windows.push(windowFromPercent('seven_day_opus', 'Weekly · Opus', data.seven_day_opus.utilization, {
48
+ durationMins: 10080,
49
+ resetsAt: normalizeIso(data.seven_day_opus.resets_at),
50
+ }));
51
+ }
52
+ const snap = baseSnapshot('claude', 'Claude Code', { windows });
53
+ return { ...snap, status: { state: 'ok' } };
54
+ },
55
+ };
56
+ function classifyFetchError(err) {
57
+ if (err instanceof HttpError) {
58
+ const c = classifyHttpError(err);
59
+ return new QuotaError(c);
60
+ }
61
+ const msg = err instanceof Error ? err.message : String(err);
62
+ return new QuotaError({ state: 'upstream_unavailable', message: msg.slice(0, 200) });
63
+ }
64
+ /** The OAuth response returns ISO strings; pass through (normalize Z suffix). */
65
+ function normalizeIso(s) {
66
+ return s ? new Date(s).toISOString() : undefined;
67
+ }
68
+ /** Read the Claude Code OAuth access token from keychain (macOS) or file. */
69
+ function readClaudeToken() {
70
+ if (process.platform === 'darwin') {
71
+ const token = readFromKeychain();
72
+ if (token)
73
+ return token;
74
+ // Fall through to file for headless/SSH sessions where keychain is locked.
75
+ }
76
+ const configDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
77
+ const credPath = join(configDir, '.credentials.json');
78
+ if (existsSync(credPath)) {
79
+ try {
80
+ const parsed = JSON.parse(readFileSync(credPath, 'utf8'));
81
+ return extractClaudeAccessToken(parsed);
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ }
87
+ return null;
88
+ }
89
+ function readFromKeychain() {
90
+ const candidates = claudeKeychainServiceNames(process.env.CLAUDE_CONFIG_DIR);
91
+ try {
92
+ const list = execFileSync('security', ['dump-keychain'], { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' });
93
+ for (const m of list.matchAll(/"svce"<blob>="([^"]*Claude Code-credentials[^"]*)"/g)) {
94
+ if (m[1] && !candidates.includes(m[1]))
95
+ candidates.push(m[1]);
96
+ }
97
+ }
98
+ catch {
99
+ // dump-keychain may fail; deterministic service names are still tried.
100
+ }
101
+ const accounts = [safeUsername(), undefined];
102
+ for (const name of candidates) {
103
+ for (const account of accounts) {
104
+ try {
105
+ const args = ['find-generic-password', '-s', name];
106
+ if (account)
107
+ args.push('-a', account);
108
+ args.push('-w');
109
+ const raw = execFileSync('/usr/bin/security', args, {
110
+ stdio: ['ignore', 'pipe', 'ignore'],
111
+ encoding: 'utf8',
112
+ timeout: 2_000,
113
+ }).trim();
114
+ if (!raw)
115
+ continue;
116
+ const token = extractClaudeAccessToken(JSON.parse(raw));
117
+ if (token)
118
+ return token;
119
+ }
120
+ catch {
121
+ continue;
122
+ }
123
+ }
124
+ }
125
+ return null;
126
+ }
127
+ export function claudeKeychainServiceNames(configDir) {
128
+ if (!configDir)
129
+ return ['Claude Code-credentials'];
130
+ const hash = createHash('sha256').update(configDir).digest('hex').slice(0, 8);
131
+ return [`Claude Code-credentials-${hash}`, 'Claude Code-credentials'];
132
+ }
133
+ export function extractClaudeAccessToken(value) {
134
+ if (!value || typeof value !== 'object')
135
+ return null;
136
+ const root = value;
137
+ const nested = root.claudeAiOauth;
138
+ const credentials = nested && typeof nested === 'object'
139
+ ? nested
140
+ : root;
141
+ return typeof credentials.accessToken === 'string' && credentials.accessToken
142
+ ? credentials.accessToken
143
+ : null;
144
+ }
145
+ function safeUsername() {
146
+ try {
147
+ return userInfo().username?.trim() || undefined;
148
+ }
149
+ catch {
150
+ return undefined;
151
+ }
152
+ }
@@ -0,0 +1,16 @@
1
+ import type { QuotaAdapter } from '../adapter.js';
2
+ export declare const codexAdapter: QuotaAdapter;
3
+ interface CodexBinaryResolutionOptions {
4
+ home?: string;
5
+ path?: string;
6
+ explicitBinary?: string;
7
+ isExecutable?: (candidate: string) => boolean;
8
+ nvmVersions?: string[];
9
+ }
10
+ /**
11
+ * Resolve Codex independently of the launch environment. Finder-launched apps
12
+ * normally receive only /usr/bin:/bin:/usr/sbin:/sbin, while Codex is commonly
13
+ * installed in Codex.app, Homebrew, Volta, or an NVM version directory.
14
+ */
15
+ export declare function resolveCodexBinary(options?: CodexBinaryResolutionOptions): string | null;
16
+ export {};
@@ -0,0 +1,226 @@
1
+ import { existsSync, readdirSync, accessSync, constants } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { spawn } from 'node:child_process';
5
+ import { QuotaError, baseSnapshot } from '../adapter.js';
6
+ import { unixToIso, windowFromPercent } from '../helpers.js';
7
+ function codexHome() {
8
+ return process.env.CODEX_HOME || join(homedir(), '.codex');
9
+ }
10
+ export const codexAdapter = {
11
+ provider: 'codex',
12
+ displayName: 'OpenAI Codex',
13
+ async isConfigured() {
14
+ // Only surface Codex when the official login file and a runnable official
15
+ // CLI are both present. Finder-launched apps have a minimal PATH, so binary
16
+ // discovery must not rely on `which codex`.
17
+ return existsSync(join(codexHome(), 'auth.json')) && resolveCodexBinary() !== null;
18
+ },
19
+ async fetch() {
20
+ const result = await queryRateLimits();
21
+ const buckets = result.rateLimitsByLimitId ?? (result.rateLimits ? { primary: result.rateLimits } : {});
22
+ const windows = [];
23
+ for (const [key, bucket] of Object.entries(buckets)) {
24
+ if (bucket.primary) {
25
+ windows.push(windowFromPercent(`codex_${key}_primary`, labelForBucket(key, 'primary', bucket), bucket.primary.usedPercent ?? 0, {
26
+ durationMins: bucket.primary.windowDurationMins,
27
+ resetsAt: unixToIso(bucket.primary.resetsAt),
28
+ }));
29
+ }
30
+ if (bucket.secondary) {
31
+ windows.push(windowFromPercent(`codex_${key}_secondary`, labelForBucket(key, 'secondary', bucket), bucket.secondary.usedPercent ?? 0, {
32
+ durationMins: bucket.secondary.windowDurationMins,
33
+ resetsAt: unixToIso(bucket.secondary.resetsAt),
34
+ }));
35
+ }
36
+ }
37
+ const snap = baseSnapshot('codex', 'OpenAI Codex', {
38
+ planName: result.planType ? capitalize(result.planType) : undefined,
39
+ windows,
40
+ });
41
+ return { ...snap, status: { state: 'ok' } };
42
+ },
43
+ };
44
+ function labelForBucket(key, tier, bucket) {
45
+ const dur = tier === 'primary' ? bucket.primary?.windowDurationMins : bucket.secondary?.windowDurationMins;
46
+ const durLabel = dur ? durationLabel(dur) : capitalize(tier);
47
+ // Prefer an explicit limit name; fall back to the bucket key, then the tier.
48
+ const who = bucket.limitName || (key && key !== 'primary' ? key : '');
49
+ return who ? `${capitalize(who)} · ${durLabel}` : durLabel;
50
+ }
51
+ function durationLabel(mins) {
52
+ if (mins >= 10080)
53
+ return 'Weekly';
54
+ if (mins >= 1440)
55
+ return `${Math.round(mins / 1440)}-Day`;
56
+ if (mins >= 60)
57
+ return `${Math.round(mins / 60)}-Hour`;
58
+ return `${mins}m`;
59
+ }
60
+ function capitalize(s) {
61
+ return s.charAt(0).toUpperCase() + s.slice(1);
62
+ }
63
+ // --- JSON-RPC over the codex app-server ---
64
+ async function queryRateLimits() {
65
+ const codexBinary = resolveCodexBinary();
66
+ if (!codexBinary) {
67
+ throw new QuotaError({ state: 'not_configured', message: 'official Codex CLI not found' });
68
+ }
69
+ const binaryDir = dirname(codexBinary);
70
+ const childPath = [binaryDir, process.env.PATH].filter(Boolean).join(':');
71
+ const proc = spawn(codexBinary, ['app-server'], {
72
+ stdio: ['pipe', 'pipe', 'pipe'],
73
+ env: { ...process.env, PATH: childPath },
74
+ });
75
+ const client = new JsonRpcClient(proc);
76
+ try {
77
+ await client.request('initialize', {
78
+ protocolVersion: '2025-03-26',
79
+ clientInfo: { name: 'tokendash', version: '1.0.0' },
80
+ });
81
+ client.notify('initialized', {});
82
+ const res = await client.request('account/rateLimits/read', {});
83
+ return res;
84
+ }
85
+ catch (err) {
86
+ throw toQuotaError(err);
87
+ }
88
+ finally {
89
+ client.dispose();
90
+ try {
91
+ proc.kill('SIGKILL');
92
+ }
93
+ catch { /* already gone */ }
94
+ }
95
+ }
96
+ function toQuotaError(err) {
97
+ const msg = err instanceof Error ? err.message : String(err);
98
+ if (/not found|ENOENT|spawn/i.test(msg)) {
99
+ return new QuotaError({ state: 'not_configured', message: 'codex app-server unavailable' });
100
+ }
101
+ if (/401|403|unauthor|auth/i.test(msg)) {
102
+ return new QuotaError({ state: 'auth_failed', message: 'codex session not authenticated' });
103
+ }
104
+ return new QuotaError({ state: 'upstream_unavailable', message: msg.slice(0, 200) });
105
+ }
106
+ /** Minimal line-delimited JSON-RPC 2.0 client over a child process stdio. */
107
+ class JsonRpcClient {
108
+ proc;
109
+ id = 0;
110
+ buffer = '';
111
+ pending = new Map();
112
+ disposed = false;
113
+ constructor(proc) {
114
+ this.proc = proc;
115
+ proc.stdout.setEncoding('utf8');
116
+ proc.stdout.on('data', (chunk) => this.onData(chunk));
117
+ proc.on('error', (err) => this.failAll(err));
118
+ proc.on('close', () => this.failAll(new Error('codex app-server closed unexpectedly')));
119
+ }
120
+ request(method, params) {
121
+ if (this.disposed)
122
+ return Promise.reject(new Error('client disposed'));
123
+ const id = ++this.id;
124
+ const msg = JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n';
125
+ return new Promise((resolve, reject) => {
126
+ this.pending.set(id, { resolve: resolve, reject });
127
+ this.proc.stdin.write(msg, (err) => {
128
+ if (err)
129
+ reject(err instanceof Error ? err : new Error(String(err)));
130
+ });
131
+ });
132
+ }
133
+ notify(method, params) {
134
+ if (this.disposed)
135
+ return;
136
+ const msg = JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n';
137
+ this.proc.stdin.write(msg, () => { });
138
+ }
139
+ dispose() {
140
+ this.disposed = true;
141
+ this.failAll(new Error('disposed'));
142
+ }
143
+ onData(chunk) {
144
+ this.buffer += chunk;
145
+ let idx;
146
+ while ((idx = this.buffer.indexOf('\n')) >= 0) {
147
+ const line = this.buffer.slice(0, idx).trim();
148
+ this.buffer = this.buffer.slice(idx + 1);
149
+ if (!line)
150
+ continue;
151
+ let msg;
152
+ try {
153
+ msg = JSON.parse(line);
154
+ }
155
+ catch {
156
+ continue; // skip non-JSON framing lines
157
+ }
158
+ if (msg.id === undefined)
159
+ continue; // notifications
160
+ const entry = this.pending.get(msg.id);
161
+ if (!entry)
162
+ continue;
163
+ this.pending.delete(msg.id);
164
+ if (msg.error)
165
+ entry.reject(new Error(msg.error.message || 'codex JSON-RPC error'));
166
+ else
167
+ entry.resolve(msg.result);
168
+ }
169
+ }
170
+ failAll(err) {
171
+ for (const [, entry] of this.pending)
172
+ entry.reject(err);
173
+ this.pending.clear();
174
+ }
175
+ }
176
+ /**
177
+ * Resolve Codex independently of the launch environment. Finder-launched apps
178
+ * normally receive only /usr/bin:/bin:/usr/sbin:/sbin, while Codex is commonly
179
+ * installed in Codex.app, Homebrew, Volta, or an NVM version directory.
180
+ */
181
+ export function resolveCodexBinary(options = {}) {
182
+ const home = options.home ?? homedir();
183
+ const path = options.path ?? process.env.PATH ?? '';
184
+ const explicitBinary = options.explicitBinary ?? process.env.CODEX_BIN;
185
+ const isExecutable = options.isExecutable ?? defaultIsExecutable;
186
+ const nvmRoot = join(home, '.nvm', 'versions', 'node');
187
+ const nvmVersions = options.nvmVersions ?? readDirectoryNames(nvmRoot);
188
+ const candidates = [
189
+ explicitBinary,
190
+ '/Applications/Codex.app/Contents/Resources/codex',
191
+ join(home, 'Applications', 'Codex.app', 'Contents', 'Resources', 'codex'),
192
+ '/opt/homebrew/bin/codex',
193
+ '/usr/local/bin/codex',
194
+ join(home, '.local', 'bin', 'codex'),
195
+ join(home, '.volta', 'bin', 'codex'),
196
+ join(home, '.bun', 'bin', 'codex'),
197
+ ...nvmVersions
198
+ .sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))
199
+ .map((version) => join(nvmRoot, version, 'bin', 'codex')),
200
+ ...path.split(':').filter(Boolean).map((directory) => join(directory, 'codex')),
201
+ ];
202
+ for (const candidate of candidates) {
203
+ if (candidate && isExecutable(candidate))
204
+ return candidate;
205
+ }
206
+ return null;
207
+ }
208
+ function defaultIsExecutable(candidate) {
209
+ try {
210
+ accessSync(candidate, constants.X_OK);
211
+ return true;
212
+ }
213
+ catch {
214
+ return false;
215
+ }
216
+ }
217
+ function readDirectoryNames(directory) {
218
+ try {
219
+ return readdirSync(directory, { withFileTypes: true })
220
+ .filter((entry) => entry.isDirectory())
221
+ .map((entry) => entry.name);
222
+ }
223
+ catch {
224
+ return [];
225
+ }
226
+ }
@@ -0,0 +1,2 @@
1
+ import type { QuotaAdapter } from '../adapter.js';
2
+ export declare const glmAdapter: QuotaAdapter;
@@ -0,0 +1,139 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { QuotaError, baseSnapshot } from '../adapter.js';
5
+ import { fetchJsonWithTimeout, HttpError, classifyHttpError, windowFromPercent, windowFromCounts, unixToIso } from '../helpers.js';
6
+ import { readStoredCredential } from '../credentialsFile.js';
7
+ export const glmAdapter = {
8
+ provider: 'glm',
9
+ displayName: 'GLM Coding Plan',
10
+ async isConfigured() {
11
+ return !!resolveCredential();
12
+ },
13
+ async fetch(options) {
14
+ const cred = resolveCredential(options?.credential);
15
+ if (!cred) {
16
+ throw new QuotaError({ state: 'not_configured', message: 'set ZAI_API_KEY or ZHIPU_API_KEY' });
17
+ }
18
+ let data;
19
+ try {
20
+ data = (await fetchJsonWithTimeout(`${cred.base}/api/monitor/usage/quota/limit`, {
21
+ headers: {
22
+ // GLM wants the raw key, NOT "Bearer <key>".
23
+ Authorization: cred.key,
24
+ 'Accept-Language': 'en-US,en',
25
+ 'Content-Type': 'application/json',
26
+ },
27
+ }));
28
+ }
29
+ catch (err) {
30
+ throw classifyFetchError(err);
31
+ }
32
+ if (!data?.success && data?.code !== 200) {
33
+ throw new QuotaError({ state: 'upstream_unavailable', message: data?.msg || 'GLM quota request failed' });
34
+ }
35
+ const limits = data.data?.limits ?? [];
36
+ const windows = [];
37
+ // TOKENS_LIMIT: two entries (5h + weekly). Sort by reset time so the
38
+ // shorter window is labeled first.
39
+ const tokenLimits = limits
40
+ .filter((l) => l.type === 'TOKENS_LIMIT' && typeof l.percentage === 'number')
41
+ .sort((a, b) => (a.nextResetTime ?? 0) - (b.nextResetTime ?? 0));
42
+ tokenLimits.forEach((l, i) => {
43
+ const isShort = i === 0; // first after sort = nearer reset = 5h
44
+ windows.push(windowFromPercent(isShort ? 'glm_5h' : 'glm_weekly', isShort ? '5-Hour Window' : 'Weekly', l.percentage ?? 0, { durationMins: isShort ? 300 : 10080, resetsAt: unixToIso(l.nextResetTime) }));
45
+ });
46
+ // TIME_LIMIT: monthly MCP usage, reported with absolute counts.
47
+ const timeLimit = limits.find((l) => l.type === 'TIME_LIMIT');
48
+ if (timeLimit && typeof timeLimit.usage === 'number') {
49
+ windows.push(windowFromCounts('glm_mcp_monthly', 'MCP · Monthly', timeLimit.currentValue ?? 0, timeLimit.usage));
50
+ }
51
+ const snap = baseSnapshot('glm', 'GLM Coding Plan', {
52
+ planName: data.data?.level ? capitalize(data.data.level) : undefined,
53
+ windows,
54
+ });
55
+ return { ...snap, status: { state: 'ok' } };
56
+ },
57
+ };
58
+ function classifyFetchError(err) {
59
+ if (err instanceof HttpError) {
60
+ const c = classifyHttpError(err);
61
+ return new QuotaError(c);
62
+ }
63
+ const msg = err instanceof Error ? err.message : String(err);
64
+ return new QuotaError({ state: 'upstream_unavailable', message: msg.slice(0, 200) });
65
+ }
66
+ function resolveCredential(proposed) {
67
+ if (proposed?.apiKey) {
68
+ return {
69
+ key: proposed.apiKey,
70
+ base: proposed.baseUrl || 'https://open.bigmodel.cn',
71
+ };
72
+ }
73
+ // 0. Key entered in-app (via the credential sheet) — highest priority.
74
+ const stored = readStoredCredential('glm');
75
+ if (stored)
76
+ return { key: stored.apiKey, base: stored.baseUrl || 'https://open.bigmodel.cn' };
77
+ // 1. Explicit GLM Coding Plan API keys.
78
+ const zai = envOrConfig('ZAI_API_KEY');
79
+ if (zai)
80
+ return { key: zai, base: envOrConfig('ZAI_BASE_URL') || 'https://api.z.ai' };
81
+ const zhipu = envOrConfig('ZHIPU_API_KEY');
82
+ if (zhipu)
83
+ return { key: zhipu, base: envOrConfig('ZHIPU_BASE_URL') || 'https://open.bigmodel.cn' };
84
+ // 2. cc-switch / Claude Code scenario: ANTHROPIC_BASE_URL is pointed at a
85
+ // GLM domain (open.bigmodel.cn or api.z.ai) and the plan token lives in
86
+ // ANTHROPIC_AUTH_TOKEN / ANTHROPIC_API_KEY. The monitor endpoint shares the
87
+ // same token; we just swap the path from /api/anthropic to /api/monitor/...
88
+ const anthropicBase = envOrConfig('ANTHROPIC_BASE_URL');
89
+ if (anthropicBase && isGlmHost(anthropicBase)) {
90
+ const origin = originOf(anthropicBase);
91
+ const token = envOrConfig('ANTHROPIC_AUTH_TOKEN') || envOrConfig('ANTHROPIC_API_KEY');
92
+ if (origin && token)
93
+ return { key: token, base: origin };
94
+ }
95
+ return null;
96
+ }
97
+ function isGlmHost(url) {
98
+ const h = url.toLowerCase();
99
+ return h.includes('bigmodel.cn') || h.includes('z.ai');
100
+ }
101
+ function originOf(url) {
102
+ try {
103
+ const u = new URL(url);
104
+ return `${u.protocol}//${u.host}`;
105
+ }
106
+ catch {
107
+ return null;
108
+ }
109
+ }
110
+ /**
111
+ * Read an env var, falling back to the Claude Code settings.json `env` block.
112
+ * Needed because the Swift app launches the daemon from Finder, where the
113
+ * ANTHROPIC_* vars Claude Code injects into its own process are absent — but
114
+ * they're persisted in ~/.claude/settings.json (how cc-switch writes them).
115
+ */
116
+ let claudeSettingsEnv;
117
+ function envOrConfig(key) {
118
+ if (process.env[key])
119
+ return process.env[key];
120
+ if (claudeSettingsEnv === undefined)
121
+ claudeSettingsEnv = loadClaudeSettingsEnv();
122
+ return claudeSettingsEnv?.[key];
123
+ }
124
+ function loadClaudeSettingsEnv() {
125
+ const configDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
126
+ try {
127
+ const path = join(configDir, 'settings.json');
128
+ if (!existsSync(path))
129
+ return null;
130
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
131
+ return parsed?.env && typeof parsed.env === 'object' ? parsed.env : null;
132
+ }
133
+ catch {
134
+ return null;
135
+ }
136
+ }
137
+ function capitalize(s) {
138
+ return s.charAt(0).toUpperCase() + s.slice(1);
139
+ }
@@ -0,0 +1,2 @@
1
+ import type { QuotaAdapter } from '../adapter.js';
2
+ export declare const kimiAdapter: QuotaAdapter;