token-quota 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/LICENSE +21 -0
- package/README.md +159 -0
- package/bin/token-quota.js +105 -0
- package/package.json +49 -0
- package/src/cli.js +92 -0
- package/src/doctor.js +292 -0
- package/src/errors.js +23 -0
- package/src/format.js +54 -0
- package/src/panel.js +113 -0
- package/src/platforms/bailian.js +96 -0
- package/src/platforms/cursor.js +86 -0
- package/src/platforms/doubao.js +259 -0
- package/src/platforms/index.js +10 -0
- package/src/quota-axi.js +255 -0
- package/src/resolve-deps.js +240 -0
- package/src/setup.js +226 -0
- package/src/table.js +37 -0
- package/vendor/sqlite3-shim/sqlite3.exe +0 -0
package/src/doctor.js
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `token-quota doctor` — read-only health check.
|
|
3
|
+
*
|
|
4
|
+
* It answers "why is my panel empty?" without changing anything: Node version,
|
|
5
|
+
* the three data-source CLIs, the sqlite3 the Cursor row needs, and one real
|
|
6
|
+
* read-only query per platform, each classified as working / not logged in /
|
|
7
|
+
* CLI missing / query failed. Every failure prints the exact command that fixes
|
|
8
|
+
* it. It never starts a login flow, never writes a file and never prints a
|
|
9
|
+
* credential value.
|
|
10
|
+
*
|
|
11
|
+
* Exit code: 0 when every check and every selected platform is green, else 1.
|
|
12
|
+
*/
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
14
|
+
import { existsSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
|
|
17
|
+
import { commandLine, selectPlatforms } from './cli.js';
|
|
18
|
+
import { MissingCredential } from './errors.js';
|
|
19
|
+
import { platforms as allPlatforms } from './platforms/index.js';
|
|
20
|
+
import { shimDir } from './quota-axi.js';
|
|
21
|
+
import { PACKAGES, pathFallback, resolveDependencyCli } from './resolve-deps.js';
|
|
22
|
+
|
|
23
|
+
/** Node major version this package supports (package.json "engines"). */
|
|
24
|
+
export const MIN_NODE_MAJOR = 20;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The CLIs the panel depends on, in check order. `fallback` is the plain
|
|
28
|
+
* command name to try on PATH when the dependency tree has no copy.
|
|
29
|
+
*/
|
|
30
|
+
export const CLI_CHECKS = [
|
|
31
|
+
{
|
|
32
|
+
id: 'quota-axi',
|
|
33
|
+
label: 'quota-axi',
|
|
34
|
+
package: PACKAGES.quotaAxi,
|
|
35
|
+
fallback: 'quota-axi',
|
|
36
|
+
// Also the data source of the cursor and bailian rows, hence the note.
|
|
37
|
+
fix: 'npm i -g quota-axi',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: 'bailian',
|
|
41
|
+
label: 'bailian CLI (bl)',
|
|
42
|
+
package: PACKAGES.bailianCli,
|
|
43
|
+
fallback: 'bl',
|
|
44
|
+
fix: 'npm i -g bailian-cli',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: 'arkcli',
|
|
48
|
+
label: 'arkcli (Volcengine Ark)',
|
|
49
|
+
package: PACKAGES.arkCli,
|
|
50
|
+
fallback: 'arkcli',
|
|
51
|
+
fix: 'npm i -g @volcengine/ark-cli',
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const PROBE_TIMEOUT_MS = 20_000;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Which data-source CLIs each platform's row needs. Used to report an install
|
|
59
|
+
* command before ever trying (and failing) to query the platform.
|
|
60
|
+
*/
|
|
61
|
+
export const REQUIRED_CLIS = {
|
|
62
|
+
doubao: ['arkcli'],
|
|
63
|
+
bailian: ['quota-axi', 'bailian'],
|
|
64
|
+
cursor: ['quota-axi'],
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Runs `<command> --version` and reports success plus captured output.
|
|
69
|
+
* `shell: true` (Windows `.cmd` npm shims) uses a composed command line: passing
|
|
70
|
+
* args together with `shell` is deprecated, and every argument here is a fixed
|
|
71
|
+
* flag, never user input.
|
|
72
|
+
*/
|
|
73
|
+
export function defaultProbe(
|
|
74
|
+
command,
|
|
75
|
+
args,
|
|
76
|
+
{ env = process.env, timeout = PROBE_TIMEOUT_MS, shell = false } = {},
|
|
77
|
+
) {
|
|
78
|
+
const base = { encoding: 'utf8', timeout, windowsHide: true, env };
|
|
79
|
+
const result = shell
|
|
80
|
+
? spawnSync(commandLine(command, args), { ...base, shell: true })
|
|
81
|
+
: spawnSync(command, args, base);
|
|
82
|
+
return {
|
|
83
|
+
ok: !result.error && result.status === 0,
|
|
84
|
+
status: result.status ?? null,
|
|
85
|
+
stdout: result.stdout ?? '',
|
|
86
|
+
stderr: result.stderr ?? '',
|
|
87
|
+
error: result.error ?? null,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** First `x.y.z` token in the CLI's output, or null. */
|
|
92
|
+
export function extractVersion(stdout = '', stderr = '') {
|
|
93
|
+
const match = `${stdout}\n${stderr}`.match(/\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?/);
|
|
94
|
+
return match ? match[0] : null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Turns a platform query failure into a reportable classification.
|
|
99
|
+
* `not-logged-in` and `cli-missing` come from MissingCredential; everything else
|
|
100
|
+
* is an explicit query failure.
|
|
101
|
+
*/
|
|
102
|
+
export function classifyFailure(error) {
|
|
103
|
+
if (error instanceof MissingCredential) {
|
|
104
|
+
const missingCli = error.kind === 'cli-missing';
|
|
105
|
+
return {
|
|
106
|
+
kind: missingCli ? 'cli-missing' : 'not-logged-in',
|
|
107
|
+
label: missingCli ? 'CLI missing' : 'not logged in',
|
|
108
|
+
detail: missingCli ? error.credential : `${error.credential} (${error.howToFix})`,
|
|
109
|
+
fix: error.howToFix,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
kind: 'failed',
|
|
114
|
+
label: 'query failed',
|
|
115
|
+
detail: oneLine(error?.message ?? String(error)),
|
|
116
|
+
fix: null,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Resolves one data-source CLI and probes `--version`: dependency tree first,
|
|
122
|
+
* plain PATH command second.
|
|
123
|
+
* @returns {{ ok: boolean, version: string|null, source: string|null, detail: string|null }}
|
|
124
|
+
*/
|
|
125
|
+
export function checkCli(
|
|
126
|
+
check,
|
|
127
|
+
{ execPath, platform, arch, env, probe, resolveDependency = resolveDependencyCli },
|
|
128
|
+
) {
|
|
129
|
+
const resolved = resolveDependency(check.package, { execPath, platform, arch });
|
|
130
|
+
const candidates = [];
|
|
131
|
+
if (resolved) candidates.push({ ...resolved, source: 'package dependency' });
|
|
132
|
+
candidates.push({ ...pathFallback(check.fallback, { platform }), source: 'PATH' });
|
|
133
|
+
|
|
134
|
+
let detail = null;
|
|
135
|
+
for (const candidate of candidates) {
|
|
136
|
+
const result = probe(candidate.command, [...candidate.prefixArgs, '--version'], {
|
|
137
|
+
env,
|
|
138
|
+
shell: candidate.shell === true,
|
|
139
|
+
});
|
|
140
|
+
if (result.ok) {
|
|
141
|
+
return {
|
|
142
|
+
ok: true,
|
|
143
|
+
version: extractVersion(result.stdout, result.stderr),
|
|
144
|
+
source: candidate.source,
|
|
145
|
+
launch: candidate,
|
|
146
|
+
detail: null,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
detail = result.error
|
|
150
|
+
? `${candidate.source}: ${result.error.code === 'ENOENT' ? 'not found' : oneLine(result.error.message)}`
|
|
151
|
+
: `${candidate.source}: ${oneLine(result.stderr) || `exit ${result.status}`}`;
|
|
152
|
+
}
|
|
153
|
+
return { ok: false, version: null, source: null, launch: null, detail };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** `node <script>` for JS entries, the bare path for native executables. */
|
|
157
|
+
export function describeLaunch(launch, execPath = process.execPath) {
|
|
158
|
+
if (!launch) return 'not resolved';
|
|
159
|
+
return launch.prefixArgs.length > 0
|
|
160
|
+
? `${baseName(execPath)} ${launch.prefixArgs.join(' ')}`
|
|
161
|
+
: launch.command;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Runs the whole check suite and prints the report.
|
|
166
|
+
* @param {{ only?: string[]|null }} options parsed CLI options
|
|
167
|
+
* @param {object} [io] injectable environment (streams, probes, platform list)
|
|
168
|
+
* @returns {Promise<number>} exit code
|
|
169
|
+
*/
|
|
170
|
+
export async function runDoctor(options = {}, io = {}) {
|
|
171
|
+
const {
|
|
172
|
+
stdout = process.stdout,
|
|
173
|
+
stderr = process.stderr,
|
|
174
|
+
platforms = allPlatforms,
|
|
175
|
+
execPath = process.execPath,
|
|
176
|
+
env = process.env,
|
|
177
|
+
platform = process.platform,
|
|
178
|
+
arch = process.arch,
|
|
179
|
+
nodeVersion = process.version,
|
|
180
|
+
probe = defaultProbe,
|
|
181
|
+
vendorSqlite3 = join(shimDir(), 'sqlite3.exe'),
|
|
182
|
+
} = io;
|
|
183
|
+
|
|
184
|
+
let selected;
|
|
185
|
+
try {
|
|
186
|
+
selected = selectPlatforms(options.only, platforms);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
stderr.write(`token-quota: ${error.message}\n`);
|
|
189
|
+
return 2;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const lines = [
|
|
193
|
+
'token-quota doctor — read-only checks; nothing is signed in, written or refreshed',
|
|
194
|
+
'',
|
|
195
|
+
'Environment',
|
|
196
|
+
];
|
|
197
|
+
let failures = 0;
|
|
198
|
+
const check = (ok, text, fix = null) => {
|
|
199
|
+
lines.push(` [${ok ? 'ok' : 'fail'}] ${text}`);
|
|
200
|
+
if (!ok) {
|
|
201
|
+
failures += 1;
|
|
202
|
+
if (fix) lines.push(` fix: ${fix}`);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const major = Number(String(nodeVersion).replace(/^v/, '').split('.')[0]);
|
|
207
|
+
check(
|
|
208
|
+
Number.isFinite(major) && major >= MIN_NODE_MAJOR,
|
|
209
|
+
`node ${nodeVersion} (requires >= ${MIN_NODE_MAJOR})`,
|
|
210
|
+
`upgrade Node.js to >= ${MIN_NODE_MAJOR} (https://nodejs.org) and re-run`,
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
if (platform === 'win32') {
|
|
214
|
+
check(
|
|
215
|
+
existsSync(vendorSqlite3),
|
|
216
|
+
`sqlite3 (vendored for Windows) — ${vendorSqlite3}`,
|
|
217
|
+
'reinstall this package so vendor/sqlite3-shim/sqlite3.exe is present (the cursor row needs it)',
|
|
218
|
+
);
|
|
219
|
+
} else {
|
|
220
|
+
const sqlite = probe('sqlite3', ['--version'], { env });
|
|
221
|
+
check(
|
|
222
|
+
sqlite.ok,
|
|
223
|
+
`sqlite3 (system) — ${extractVersion(sqlite.stdout, sqlite.stderr) ?? 'not found'}`,
|
|
224
|
+
'install the sqlite3 CLI (e.g. `apt install sqlite3` / `brew install sqlite`); the cursor row needs it',
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
lines.push('', 'Data-source CLIs');
|
|
229
|
+
const cliState = {};
|
|
230
|
+
for (const cliCheck of CLI_CHECKS) {
|
|
231
|
+
const state = checkCli(cliCheck, { execPath, platform, arch, env, probe });
|
|
232
|
+
cliState[cliCheck.id] = state;
|
|
233
|
+
check(
|
|
234
|
+
state.ok,
|
|
235
|
+
state.ok
|
|
236
|
+
? `${cliCheck.label} ${state.version ?? '(version not reported)'} — ${state.source}: ${describeLaunch(state.launch, execPath)}`
|
|
237
|
+
: `${cliCheck.label} — ${state.detail}`,
|
|
238
|
+
cliCheck.fix,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
lines.push('', 'Platforms (real read-only queries)');
|
|
243
|
+
let usable = 0;
|
|
244
|
+
for (const platformCheck of selected) {
|
|
245
|
+
// A platform whose data-source CLI is missing never gets queried: report the
|
|
246
|
+
// install command instead of a confusing spawn error.
|
|
247
|
+
const required = REQUIRED_CLIS[platformCheck.id] ?? ['quota-axi'];
|
|
248
|
+
const missing = required
|
|
249
|
+
.map((id) => CLI_CHECKS.find((entry) => entry.id === id))
|
|
250
|
+
.filter((entry) => entry && !cliState[entry.id].ok);
|
|
251
|
+
if (missing.length > 0) {
|
|
252
|
+
check(
|
|
253
|
+
false,
|
|
254
|
+
`${platformCheck.id} — CLI missing (${missing.map((entry) => entry.label).join(', ')})`,
|
|
255
|
+
missing.map((entry) => entry.fix).join(' then '),
|
|
256
|
+
);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
try {
|
|
260
|
+
const { rows } = await platformCheck.query();
|
|
261
|
+
usable += 1;
|
|
262
|
+
check(true, `${platformCheck.id} — ${Array.isArray(rows) ? rows.length : 0} row(s) read`);
|
|
263
|
+
} catch (error) {
|
|
264
|
+
const classified = classifyFailure(error);
|
|
265
|
+
check(false, `${platformCheck.id} — ${classified.label}: ${classified.detail}`, classified.fix);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
lines.push(
|
|
270
|
+
'',
|
|
271
|
+
`${usable} of ${selected.length} platform(s) usable` +
|
|
272
|
+
(failures === 0
|
|
273
|
+
? ' — everything checks out; `token-quota` is ready.'
|
|
274
|
+
: ' — fix the [fail] items above, then run `token-quota doctor` again.'),
|
|
275
|
+
'',
|
|
276
|
+
);
|
|
277
|
+
stdout.write(`${lines.join('\n')}`);
|
|
278
|
+
return failures === 0 ? 0 : 1;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function oneLine(text) {
|
|
282
|
+
return String(text ?? '')
|
|
283
|
+
.split('\n')
|
|
284
|
+
.map((line) => line.trim())
|
|
285
|
+
.filter((line) => line !== '' && !line.startsWith('(node:'))
|
|
286
|
+
.join(' ')
|
|
287
|
+
.slice(0, 200);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function baseName(path) {
|
|
291
|
+
return String(path ?? '').split(/[\\/]/).pop() ?? String(path ?? '');
|
|
292
|
+
}
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raised when a platform's quota cannot be queried because a credential is missing
|
|
3
|
+
* (or present but rejected). The CLI turns this into an explicit "credential missing"
|
|
4
|
+
* table row plus a `needs credential` note, and never prints the credential value.
|
|
5
|
+
* `kind` distinguishes a missing sign-in from a missing CLI, which doctor and
|
|
6
|
+
* setup report differently (login command vs install command).
|
|
7
|
+
*/
|
|
8
|
+
export class MissingCredential extends Error {
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} credential short name of what is missing, e.g. "Cursor access token"
|
|
11
|
+
* @param {string} howToFix one-line instruction for obtaining it
|
|
12
|
+
* @param {'credential'|'cli-missing'} [kind] `cli-missing` when the underlying
|
|
13
|
+
* tool itself is not installed (doctor/setup then print install guidance
|
|
14
|
+
* instead of a login flow)
|
|
15
|
+
*/
|
|
16
|
+
constructor(credential, howToFix, kind = 'credential') {
|
|
17
|
+
super(`missing credential: ${credential}`);
|
|
18
|
+
this.name = 'MissingCredential';
|
|
19
|
+
this.credential = credential;
|
|
20
|
+
this.howToFix = howToFix;
|
|
21
|
+
this.kind = kind;
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/format.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** Value formatting shared by the platform fetchers. */
|
|
2
|
+
|
|
3
|
+
/** Placeholder used when a value is genuinely absent from the source data. */
|
|
4
|
+
export const NA = 'n/a';
|
|
5
|
+
|
|
6
|
+
/** Volcengine/Ark and Bailian report quota as percentages; print one decimal at most. */
|
|
7
|
+
export function formatPercent(value) {
|
|
8
|
+
if (value === null || value === undefined || !Number.isFinite(Number(value))) return NA;
|
|
9
|
+
const rounded = Math.round(Number(value) * 10) / 10;
|
|
10
|
+
return `${Number.isInteger(rounded) ? rounded : rounded.toFixed(1)}%`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Cursor reports money in cents. */
|
|
14
|
+
export function formatUsd(cents) {
|
|
15
|
+
if (cents === null || cents === undefined || !Number.isFinite(Number(cents))) return NA;
|
|
16
|
+
return `$${(Number(cents) / 100).toFixed(2)}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Local wall-clock timestamp, minute precision: `2026-09-19 11:30`. */
|
|
20
|
+
export function formatDateTime(input) {
|
|
21
|
+
const ms = toMillis(input);
|
|
22
|
+
if (ms === null) return NA;
|
|
23
|
+
const d = new Date(ms);
|
|
24
|
+
if (Number.isNaN(d.getTime())) return NA;
|
|
25
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
26
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Accepts epoch seconds, epoch milliseconds or an ISO 8601 string.
|
|
31
|
+
* Anything unparseable (including Volcengine's "no data" sentinel -1) becomes null.
|
|
32
|
+
*/
|
|
33
|
+
export function toMillis(input) {
|
|
34
|
+
if (input === null || input === undefined) return null;
|
|
35
|
+
if (typeof input === 'string') {
|
|
36
|
+
const parsed = Date.parse(input);
|
|
37
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
38
|
+
}
|
|
39
|
+
const value = Number(input);
|
|
40
|
+
if (!Number.isFinite(value) || value <= 0) return null;
|
|
41
|
+
// Anything below ~1e11 is seconds (1e11 ms ≈ 1973, 1e11 s ≈ year 5138).
|
|
42
|
+
return value < 1e11 ? Math.round(value * 1000) : Math.round(value);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Percentage of a whole, guarded against zero/absent totals. */
|
|
46
|
+
export function percentUsed(used, total) {
|
|
47
|
+
if (!Number.isFinite(Number(used)) || !Number.isFinite(Number(total)) || Number(total) <= 0) return null;
|
|
48
|
+
return (Number(used) / Number(total)) * 100;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function remainingPercent(usedPercent) {
|
|
52
|
+
if (!Number.isFinite(Number(usedPercent))) return null;
|
|
53
|
+
return Math.max(0, Math.round((100 - Number(usedPercent)) * 10) / 10);
|
|
54
|
+
}
|
package/src/panel.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The panel itself: query every selected platform, print the table (or JSON),
|
|
3
|
+
* print the notes, and return the exit code.
|
|
4
|
+
*
|
|
5
|
+
* Contract kept from the original quota-panel: read-only queries, print and
|
|
6
|
+
* exit — no daemon, no cache, no credential writes. A platform that cannot be
|
|
7
|
+
* queried still gets its own row, and one platform's failure never affects the
|
|
8
|
+
* others. `quota-axi` is always run with `--no-credential-refresh` (see
|
|
9
|
+
* src/quota-axi.js), so nothing here can trigger an interactive re-login.
|
|
10
|
+
*/
|
|
11
|
+
import { MissingCredential } from './errors.js';
|
|
12
|
+
import { NA } from './format.js';
|
|
13
|
+
import { platforms as allPlatforms } from './platforms/index.js';
|
|
14
|
+
import { renderTable } from './table.js';
|
|
15
|
+
import { selectPlatforms } from './cli.js';
|
|
16
|
+
|
|
17
|
+
/** Shown after the table on a first run where nothing is configured yet. */
|
|
18
|
+
export const FIRST_RUN_HINT =
|
|
19
|
+
'Nothing is configured yet — run "token-quota setup" (interactive) or "token-quota doctor" (checks only).';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Runs the selected platforms in parallel and classifies each outcome:
|
|
23
|
+
* usable data, `credentialMissing`, or `failed`.
|
|
24
|
+
* @param {Array<{id: string, label: string, query: () => Promise<{rows: Array<object>, notes?: string[]}>}>} selected
|
|
25
|
+
*/
|
|
26
|
+
export async function collect(selected) {
|
|
27
|
+
return Promise.all(
|
|
28
|
+
selected.map(async (platform) => {
|
|
29
|
+
try {
|
|
30
|
+
const { rows, notes } = await platform.query();
|
|
31
|
+
return { platform, rows, notes: notes ?? [], credentialMissing: false };
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (error instanceof MissingCredential) {
|
|
34
|
+
// A missing CLI is reported as its own state: the note carries the
|
|
35
|
+
// install command, which is a different fix from signing in.
|
|
36
|
+
const state = error.kind === 'cli-missing' ? 'CLI missing' : 'credential missing';
|
|
37
|
+
return {
|
|
38
|
+
platform,
|
|
39
|
+
rows: [stateRow(platform, state)],
|
|
40
|
+
notes: [`${platform.label}: ${error.credential} — ${error.howToFix}`],
|
|
41
|
+
credentialMissing: true,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
platform,
|
|
46
|
+
rows: [stateRow(platform, 'query failed')],
|
|
47
|
+
notes: [`${platform.label}: ${error.message}`],
|
|
48
|
+
credentialMissing: false,
|
|
49
|
+
failed: true,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function stateRow(platform, state) {
|
|
57
|
+
return { platform: platform.label, plan: state, total: NA, used: NA, remaining: NA, reset: NA };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* First-run guidance: only for a human watching a real terminal, and only when
|
|
62
|
+
* every selected platform is blocked on a missing credential. `--json` output
|
|
63
|
+
* and piped/redirected runs are never touched.
|
|
64
|
+
* @returns {string|null} the hint line, or null when it should not be shown
|
|
65
|
+
*/
|
|
66
|
+
export function firstRunHint(results, { isTTY = process.stdout.isTTY } = {}) {
|
|
67
|
+
if (!isTTY) return null;
|
|
68
|
+
if (!Array.isArray(results) || results.length === 0) return null;
|
|
69
|
+
return results.every((result) => result.credentialMissing) ? FIRST_RUN_HINT : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Prints the panel and returns the process exit code:
|
|
74
|
+
* 0 — at least one platform returned usable data
|
|
75
|
+
* 1 — every selected platform failed
|
|
76
|
+
* 2 — bad usage (no matching platform in `--only`)
|
|
77
|
+
*/
|
|
78
|
+
export async function runPanel(options = {}, io = {}) {
|
|
79
|
+
const {
|
|
80
|
+
stdout = process.stdout,
|
|
81
|
+
stderr = process.stderr,
|
|
82
|
+
isTTY = process.stdout.isTTY,
|
|
83
|
+
platforms = allPlatforms,
|
|
84
|
+
} = io;
|
|
85
|
+
|
|
86
|
+
let selected;
|
|
87
|
+
try {
|
|
88
|
+
selected = selectPlatforms(options.only, platforms);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
stderr.write(`token-quota: ${error.message}\n`);
|
|
91
|
+
return 2;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const results = await collect(selected);
|
|
95
|
+
const rows = results.flatMap((result) => result.rows);
|
|
96
|
+
const notes = results.flatMap((result) => result.notes);
|
|
97
|
+
|
|
98
|
+
if (options.json) {
|
|
99
|
+
stdout.write(`${JSON.stringify({ rows, notes }, null, 2)}\n`);
|
|
100
|
+
} else {
|
|
101
|
+
stdout.write(`${renderTable(rows)}\n`);
|
|
102
|
+
if (notes.length > 0) {
|
|
103
|
+
stdout.write(`\n${notes.map((note) => `- ${note}`).join('\n')}\n`);
|
|
104
|
+
}
|
|
105
|
+
const hint = firstRunHint(results, { isTTY });
|
|
106
|
+
if (hint) stdout.write(`\n${hint}\n`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Missing credentials are a reported state, not a crash; only total query
|
|
110
|
+
// failure (or an empty selection) exits non-zero.
|
|
111
|
+
const anyUsable = results.some((result) => !result.failed);
|
|
112
|
+
return anyUsable ? 0 : 1;
|
|
113
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alibaba Cloud Bailian (阿里云百炼) Token Plan quota fetcher — quota-axi-backed.
|
|
3
|
+
*
|
|
4
|
+
* All Bailian quota data comes from the `quota-axi` CLI (see src/quota-axi.js),
|
|
5
|
+
* which in turn reads the local official Bailian CLI (`bl`) — the owner of the
|
|
6
|
+
* console session that `bl auth login --console` establishes. This module only
|
|
7
|
+
* maps the quota-axi provider result into table rows and classifies failures:
|
|
8
|
+
*
|
|
9
|
+
* - `bl_cli_unavailable` → the Bailian CLI itself is not installed
|
|
10
|
+
* - `bl_usage_failed` → the `bl usage token-plan` read failed; the observed
|
|
11
|
+
* cause on this machine is an expired console session
|
|
12
|
+
* (`bl` exits 3 with "Console session is not logged in
|
|
13
|
+
* or has expired"). Model API keys such as
|
|
14
|
+
* BAILIAN_PLAN_API_KEY are inference keys and are
|
|
15
|
+
* rejected by the usage API, so a console session is
|
|
16
|
+
* the only working credential.
|
|
17
|
+
* - anything else → explicit error row, other platforms unaffected
|
|
18
|
+
*
|
|
19
|
+
* Bailian windows via quota-axi: `weekly` (per-week Token Plan percentage,
|
|
20
|
+
* computed by quota-axi from `per1WeekPercentage`), plus any per-model limits
|
|
21
|
+
* the payload carries.
|
|
22
|
+
*/
|
|
23
|
+
import { MissingCredential } from '../errors.js';
|
|
24
|
+
import { NA } from '../format.js';
|
|
25
|
+
import { fetchReport, findProvider, providerWindowsToRows } from '../quota-axi.js';
|
|
26
|
+
|
|
27
|
+
export const id = 'bailian';
|
|
28
|
+
export const label = 'bailian';
|
|
29
|
+
|
|
30
|
+
/** quota-axi's provider id for Alibaba Bailian differs from this panel's platform id. */
|
|
31
|
+
const QUOTA_AXI_PROVIDER_ID = 'alibaba';
|
|
32
|
+
|
|
33
|
+
const HOW_TO_FIX =
|
|
34
|
+
'needs a Bailian console session: run `bl auth login --console` (one browser sign-in) ' +
|
|
35
|
+
'to refresh it. BAILIAN_PLAN_API_KEY is a model inference key and cannot read Token Plan usage';
|
|
36
|
+
|
|
37
|
+
export async function query() {
|
|
38
|
+
const report = await fetchReport();
|
|
39
|
+
const provider = findProvider(report, QUOTA_AXI_PROVIDER_ID);
|
|
40
|
+
if (!provider) {
|
|
41
|
+
throw new Error(`quota-axi report contains no "${QUOTA_AXI_PROVIDER_ID}" provider`);
|
|
42
|
+
}
|
|
43
|
+
return buildRows(provider);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Maps one quota-axi alibaba provider entry into rows + notes. Exported for tests. */
|
|
47
|
+
export function buildRows(provider) {
|
|
48
|
+
const state = provider?.state ?? {};
|
|
49
|
+
const windows = Array.isArray(provider?.windows) ? provider.windows : [];
|
|
50
|
+
|
|
51
|
+
if (state.status === 'fresh' || state.status === 'stale' || windows.length > 0) {
|
|
52
|
+
const rows = providerWindowsToRows(label, provider);
|
|
53
|
+
const notes = [];
|
|
54
|
+
if (state.status === 'stale') {
|
|
55
|
+
notes.push(
|
|
56
|
+
`${label}: showing stale cached data` +
|
|
57
|
+
(state.error ? ` (fresh fetch failed: ${cleanError(state.error)})` : ''),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
notes.push(`${label}: Token Plan windows are percentages only; each window resets on its own schedule`);
|
|
61
|
+
if (rows.length === 0) {
|
|
62
|
+
rows.push(errorRow('quota-axi returned no usage windows'));
|
|
63
|
+
}
|
|
64
|
+
return { rows, notes };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const errorText = cleanError(state.error ?? 'unknown quota-axi state');
|
|
68
|
+
if (errorText.includes('bl_cli_unavailable')) {
|
|
69
|
+
throw new MissingCredential(
|
|
70
|
+
'Bailian CLI (bl)',
|
|
71
|
+
'install the official Bailian CLI (`bl`) and sign in with `bl auth login --console`',
|
|
72
|
+
'cli-missing',
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
if (errorText.includes('bl_usage_failed')) {
|
|
76
|
+
// quota-axi truncates the underlying bl error, but the dominant cause is the
|
|
77
|
+
// console session state; classify it as the credential problem it is.
|
|
78
|
+
throw new MissingCredential(
|
|
79
|
+
'Bailian console session (expired or absent — the `bl usage` read failed)',
|
|
80
|
+
HOW_TO_FIX,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
throw new Error(`Bailian quota query failed: ${errorText}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function errorRow(message) {
|
|
87
|
+
return { platform: label, plan: message, total: NA, used: NA, remaining: NA, reset: NA };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Trims the Windows command-invocation noise quota-axi wraps failures in, keeping any prefix marker. */
|
|
91
|
+
export function cleanError(text) {
|
|
92
|
+
const raw = String(text ?? '');
|
|
93
|
+
const at = raw.indexOf('Command failed:');
|
|
94
|
+
const kept = (at === -1 ? raw : raw.slice(0, at)).replace(/\s+/g, ' ').trim();
|
|
95
|
+
return (kept !== '' ? kept : 'command invocation failed').slice(0, 220);
|
|
96
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor quota fetcher — quota-axi-backed.
|
|
3
|
+
*
|
|
4
|
+
* All Cursor quota data comes from the `quota-axi` CLI (see src/quota-axi.js),
|
|
5
|
+
* which owns the credential reading (the signed-in Cursor editor's local
|
|
6
|
+
* `state.vscdb`, through the vendored `sqlite3.exe` on Windows) and the
|
|
7
|
+
* DashboardService API calls. This module only maps the quota-axi provider
|
|
8
|
+
* result into table rows and classifies its failure states:
|
|
9
|
+
*
|
|
10
|
+
* - `auth_required` → credential missing: open the Cursor app and sign in
|
|
11
|
+
* - `sqlite3_unavailable`→ local sqlite3 CLI missing (the vendored one should
|
|
12
|
+
* prevent this; if it appears, the vendored binary
|
|
13
|
+
* is unusable on this machine)
|
|
14
|
+
* - anything else → explicit error row, other platforms unaffected
|
|
15
|
+
*
|
|
16
|
+
* Cursor windows (quota-axi ids): included_usage, auto_usage, api_usage
|
|
17
|
+
* (percent of the monthly plan pools) and spend_limit (USD credits).
|
|
18
|
+
*/
|
|
19
|
+
import { MissingCredential } from '../errors.js';
|
|
20
|
+
import { NA } from '../format.js';
|
|
21
|
+
import { fetchReport, findProvider, providerWindowsToRows } from '../quota-axi.js';
|
|
22
|
+
|
|
23
|
+
export const id = 'cursor';
|
|
24
|
+
export const label = 'cursor';
|
|
25
|
+
|
|
26
|
+
const HOW_TO_FIX =
|
|
27
|
+
'no usable Cursor sign-in: open the Cursor editor and sign in ' +
|
|
28
|
+
'(quota-axi reads the local credential store Cursor itself keeps), then re-run';
|
|
29
|
+
|
|
30
|
+
export async function query() {
|
|
31
|
+
const report = await fetchReport();
|
|
32
|
+
const provider = findProvider(report, id);
|
|
33
|
+
if (!provider) {
|
|
34
|
+
throw new Error(`quota-axi report contains no "${id}" provider`);
|
|
35
|
+
}
|
|
36
|
+
return buildRows(provider);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Maps one quota-axi cursor provider entry into rows + notes. Exported for tests. */
|
|
40
|
+
export function buildRows(provider) {
|
|
41
|
+
const state = provider?.state ?? {};
|
|
42
|
+
const windows = Array.isArray(provider?.windows) ? provider.windows : [];
|
|
43
|
+
|
|
44
|
+
if (state.status === 'fresh' || state.status === 'stale' || windows.length > 0) {
|
|
45
|
+
const rows = providerWindowsToRows(label, provider);
|
|
46
|
+
const notes = [];
|
|
47
|
+
if (state.status === 'stale') {
|
|
48
|
+
notes.push(
|
|
49
|
+
`${label}: showing stale cached data` +
|
|
50
|
+
(state.error ? ` (fresh fetch failed: ${cleanError(state.error)})` : ''),
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
if (windows.some((window) => window?.id === 'spend_limit')) {
|
|
54
|
+
notes.push(`${label}: spend limit is the USD credit cap Cursor charges beyond the plan`);
|
|
55
|
+
}
|
|
56
|
+
if (rows.length === 0) {
|
|
57
|
+
rows.push(errorRow('quota-axi returned no usage windows'));
|
|
58
|
+
notes.push(`${label}: signed in, but Cursor reported no usage windows`);
|
|
59
|
+
}
|
|
60
|
+
return { rows, notes };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const errorText = cleanError(state.error ?? 'unknown quota-axi state');
|
|
64
|
+
if (state.status === 'auth_required' || /sign-in required|credentials/i.test(errorText)) {
|
|
65
|
+
throw new MissingCredential('Cursor sign-in (local credential store)', HOW_TO_FIX);
|
|
66
|
+
}
|
|
67
|
+
if (errorText.includes('sqlite3_unavailable')) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
'quota-axi could not read Cursor\'s local credential store: no working sqlite3 CLI ' +
|
|
70
|
+
'(the repo-vendored vendor/sqlite3-shim/sqlite3.exe should have been used; check it exists)',
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
throw new Error(`Cursor quota query failed: ${errorText}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function errorRow(message) {
|
|
77
|
+
return { platform: label, plan: message, total: NA, used: NA, remaining: NA, reset: NA };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Trims the Windows command-invocation noise quota-axi wraps failures in, keeping any prefix marker. */
|
|
81
|
+
export function cleanError(text) {
|
|
82
|
+
const raw = String(text ?? '');
|
|
83
|
+
const at = raw.indexOf('Command failed:');
|
|
84
|
+
const kept = (at === -1 ? raw : raw.slice(0, at)).replace(/\s+/g, ' ').trim();
|
|
85
|
+
return (kept !== '' ? kept : 'command invocation failed').slice(0, 220);
|
|
86
|
+
}
|