agy-cli-usage 0.3.1 → 0.4.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/CHANGELOG.md +8 -0
- package/README.md +86 -51
- package/dist/src/api.d.ts +12 -0
- package/dist/src/api.js +84 -0
- package/dist/src/api.js.map +1 -0
- package/dist/src/credentials.d.ts +22 -0
- package/{src → dist/src}/credentials.js +125 -143
- package/dist/src/credentials.js.map +1 -0
- package/dist/src/main.d.ts +20 -0
- package/dist/src/main.js +169 -0
- package/dist/src/main.js.map +1 -0
- package/dist/src/pty-fallback.d.ts +5 -0
- package/dist/src/pty-fallback.js +221 -0
- package/dist/src/pty-fallback.js.map +1 -0
- package/dist/src/quota.d.ts +7 -0
- package/dist/src/quota.js +81 -0
- package/dist/src/quota.js.map +1 -0
- package/dist/src/render.d.ts +3 -0
- package/dist/src/render.js +82 -0
- package/dist/src/render.js.map +1 -0
- package/dist/src/server.d.ts +2 -0
- package/dist/src/server.js +43 -0
- package/dist/src/server.js.map +1 -0
- package/dist/src/types.d.ts +67 -0
- package/dist/src/types.js +3 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/update.d.ts +17 -0
- package/dist/src/update.js +87 -0
- package/dist/src/update.js.map +1 -0
- package/package.json +17 -10
- package/server.js +0 -48
- package/src/api.js +0 -92
- package/src/main.js +0 -142
- package/src/pty-fallback.js +0 -211
- package/src/quota.js +0 -100
- package/src/render.js +0 -82
- package/src/update.js +0 -87
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Self-update + version helpers for the CLI.
|
|
2
|
+
//
|
|
3
|
+
// `agy-cli-usage update` check the registry and `npm install -g` if newer
|
|
4
|
+
// `agy-cli-usage update --check` report only, don't install
|
|
5
|
+
// `agy-cli-usage --version` print the installed version
|
|
6
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
const PKG_NAME = 'agy-cli-usage';
|
|
9
|
+
/**
|
|
10
|
+
* Installed version, read from this package's package.json.
|
|
11
|
+
* NOTE: this module compiles to dist/src/update.js, so package.json (at the
|
|
12
|
+
* package root) is two levels up.
|
|
13
|
+
*/
|
|
14
|
+
export function currentVersion() {
|
|
15
|
+
const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
|
|
16
|
+
return pkg.version;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Compare two dotted versions numerically (prerelease tags ignored).
|
|
20
|
+
* Returns negative if a<b, 0 if equal, positive if a>b.
|
|
21
|
+
*/
|
|
22
|
+
export function semverCompare(a, b) {
|
|
23
|
+
const norm = (v) => String(v)
|
|
24
|
+
.replace(/^v/, '')
|
|
25
|
+
.split('-')[0]
|
|
26
|
+
.split('.')
|
|
27
|
+
.map((n) => parseInt(n, 10) || 0);
|
|
28
|
+
const pa = norm(a);
|
|
29
|
+
const pb = norm(b);
|
|
30
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
31
|
+
const d = (pa[i] || 0) - (pb[i] || 0);
|
|
32
|
+
if (d !== 0)
|
|
33
|
+
return d;
|
|
34
|
+
}
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
/** Latest published version: prefer the user's configured registry (npm view), fall back to public. */
|
|
38
|
+
export async function latestVersion() {
|
|
39
|
+
try {
|
|
40
|
+
const out = execFileSync('npm', ['view', PKG_NAME, 'version'], {
|
|
41
|
+
encoding: 'utf8',
|
|
42
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
43
|
+
}).trim();
|
|
44
|
+
if (out)
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// npm missing or offline — try the public registry directly
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const res = await fetch(`https://registry.npmjs.org/${PKG_NAME}/latest`);
|
|
52
|
+
if (res.ok)
|
|
53
|
+
return (await res.json()).version;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// offline
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
/** Run the update flow. Returns the intended process exit code. */
|
|
61
|
+
export async function runUpdate({ checkOnly = false } = {}) {
|
|
62
|
+
const current = currentVersion();
|
|
63
|
+
const latest = await latestVersion();
|
|
64
|
+
if (!latest) {
|
|
65
|
+
process.stderr.write('Could not determine the latest version (offline or npm unavailable).\n');
|
|
66
|
+
return 1;
|
|
67
|
+
}
|
|
68
|
+
if (semverCompare(latest, current) <= 0) {
|
|
69
|
+
process.stdout.write(`agy-cli-usage is up to date (${current}).\n`);
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
process.stdout.write(`Update available: ${current} -> ${latest}\n`);
|
|
73
|
+
if (checkOnly) {
|
|
74
|
+
process.stdout.write('Run `agy-cli-usage update` to install it.\n');
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
process.stdout.write(`Installing ${PKG_NAME}@${latest} globally…\n`);
|
|
78
|
+
const r = spawnSync('npm', ['install', '-g', `${PKG_NAME}@${latest}`], { stdio: 'inherit' });
|
|
79
|
+
if (r.error) {
|
|
80
|
+
process.stderr.write(`Failed to run npm: ${r.error.message}\nInstall manually: npm install -g ${PKG_NAME}@latest\n`);
|
|
81
|
+
return 1;
|
|
82
|
+
}
|
|
83
|
+
if (r.status === 0)
|
|
84
|
+
process.stdout.write(`Updated to ${latest}.\n`);
|
|
85
|
+
return r.status ?? 0;
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=update.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"update.js","sourceRoot":"","sources":["../../src/update.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,24 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agy-cli-usage",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Headless usage/quota monitor for the Antigravity CLI (agy) — reads Cloud Code quota directly, with a PTY fallback. No IDE required.",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"types": "dist/src/main.d.ts",
|
|
6
7
|
"bin": {
|
|
7
|
-
"agy-cli-usage": "src/main.js",
|
|
8
|
-
"agy-usage": "src/main.js"
|
|
8
|
+
"agy-cli-usage": "dist/src/main.js",
|
|
9
|
+
"agy-usage": "dist/src/main.js"
|
|
9
10
|
},
|
|
10
11
|
"scripts": {
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"
|
|
12
|
+
"build": "tsc",
|
|
13
|
+
"check": "tsc --noEmit",
|
|
14
|
+
"pretest": "tsc",
|
|
15
|
+
"test": "node --test dist/test/unit.test.js",
|
|
16
|
+
"start": "tsc && node dist/src/main.js",
|
|
17
|
+
"serve": "tsc && node dist/src/server.js",
|
|
18
|
+
"prepack": "tsc"
|
|
15
19
|
},
|
|
16
20
|
"engines": {
|
|
17
21
|
"node": ">=18"
|
|
18
22
|
},
|
|
19
23
|
"files": [
|
|
20
|
-
"src/",
|
|
21
|
-
"server.js",
|
|
24
|
+
"dist/src/",
|
|
22
25
|
"README.md",
|
|
23
26
|
"CHANGELOG.md",
|
|
24
27
|
"LICENSE"
|
|
@@ -49,5 +52,9 @@
|
|
|
49
52
|
"optionalDependencies": {
|
|
50
53
|
"node-pty": "^1.0.0"
|
|
51
54
|
},
|
|
52
|
-
"license": "MIT"
|
|
55
|
+
"license": "MIT",
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@types/node": "^26.0.0",
|
|
58
|
+
"typescript": "^6.0.3"
|
|
59
|
+
}
|
|
53
60
|
}
|
package/server.js
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Optional lightweight HTTP endpoint for dashboard integration (e.g. ontology).
|
|
3
|
-
// Serves the normalized quota snapshot as JSON, going through the same 5-minute
|
|
4
|
-
// cache as the CLI so polling clients never hammer the upstream API.
|
|
5
|
-
//
|
|
6
|
-
// PORT=3007 node server.js
|
|
7
|
-
// GET /quota -> normalized snapshot JSON
|
|
8
|
-
// GET /healthz -> { ok: true }
|
|
9
|
-
|
|
10
|
-
import { createServer } from 'node:http';
|
|
11
|
-
import { getSnapshot } from './src/main.js';
|
|
12
|
-
|
|
13
|
-
const PORT = Number(process.env.PORT) || 3007;
|
|
14
|
-
const HOST = process.env.HOST || '127.0.0.1';
|
|
15
|
-
|
|
16
|
-
const server = createServer(async (req, res) => {
|
|
17
|
-
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
18
|
-
|
|
19
|
-
if (url.pathname === '/healthz') {
|
|
20
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
21
|
-
res.end(JSON.stringify({ ok: true }));
|
|
22
|
-
return;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
if (url.pathname === '/quota') {
|
|
26
|
-
try {
|
|
27
|
-
const noCache = url.searchParams.get('refresh') === '1';
|
|
28
|
-
const snap = await getSnapshot({ source: 'auto', channel: 'auto', cache: !noCache });
|
|
29
|
-
res.writeHead(200, {
|
|
30
|
-
'Content-Type': 'application/json',
|
|
31
|
-
'Access-Control-Allow-Origin': '*',
|
|
32
|
-
'Cache-Control': 'public, max-age=300',
|
|
33
|
-
});
|
|
34
|
-
res.end(JSON.stringify(snap));
|
|
35
|
-
} catch (err) {
|
|
36
|
-
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
37
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
38
|
-
}
|
|
39
|
-
return;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
43
|
-
res.end(JSON.stringify({ error: 'not found' }));
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
server.listen(PORT, HOST, () => {
|
|
47
|
-
process.stdout.write(`agy-usage server on http://${HOST}:${PORT} (GET /quota)\n`);
|
|
48
|
-
});
|
package/src/api.js
DELETED
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
// Direct client for the Antigravity / Gemini Code Assist "Cloud Code" internal API.
|
|
2
|
-
//
|
|
3
|
-
// Reproduces exactly what `agy` does on startup to populate its /usage panel:
|
|
4
|
-
// 1. POST /v1internal:loadCodeAssist {metadata:{ideType:"ANTIGRAVITY"}}
|
|
5
|
-
// -> { cloudaicompanionProject, currentTier, ... }
|
|
6
|
-
// 2. POST /v1internal:retrieveUserQuotaSummary {project:<cloudaicompanionProject>}
|
|
7
|
-
// -> { groups:[ { displayName, description, buckets:[ {bucketId, displayName,
|
|
8
|
-
// window, resetTime, description?, remainingFraction} ] } ], description }
|
|
9
|
-
//
|
|
10
|
-
// Captured from live agy traffic (mitmproxy). The internal endpoint is undocumented;
|
|
11
|
-
// the PTY fallback exists for when it changes.
|
|
12
|
-
|
|
13
|
-
const UA = `antigravity-usage-monitor/0.1 ${process.platform}/${process.arch}`;
|
|
14
|
-
|
|
15
|
-
// Antigravity ships against the "daily" Cloud Code host; stable builds use the
|
|
16
|
-
// plain host. Try daily first (matches current CLI), fall back to prod.
|
|
17
|
-
const HOSTS = ['daily-cloudcode-pa.googleapis.com', 'cloudcode-pa.googleapis.com'];
|
|
18
|
-
|
|
19
|
-
class ApiError extends Error {
|
|
20
|
-
constructor(message, status) {
|
|
21
|
-
super(message);
|
|
22
|
-
this.status = status;
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function extractEmail(uri) {
|
|
27
|
-
const m = uri?.match(/[?&]Email=([^&]+)/);
|
|
28
|
-
if (!m) return null;
|
|
29
|
-
try {
|
|
30
|
-
return decodeURIComponent(m[1]);
|
|
31
|
-
} catch {
|
|
32
|
-
return m[1];
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
async function postInternal(host, accessToken, method, body) {
|
|
37
|
-
const res = await fetch(`https://${host}/v1internal:${method}`, {
|
|
38
|
-
method: 'POST',
|
|
39
|
-
headers: {
|
|
40
|
-
Authorization: `Bearer ${accessToken}`,
|
|
41
|
-
'Content-Type': 'application/json',
|
|
42
|
-
'User-Agent': UA,
|
|
43
|
-
},
|
|
44
|
-
body: JSON.stringify(body),
|
|
45
|
-
});
|
|
46
|
-
if (!res.ok) {
|
|
47
|
-
throw new ApiError(`${method} -> HTTP ${res.status}: ${(await res.text()).slice(0, 300)}`, res.status);
|
|
48
|
-
}
|
|
49
|
-
return res.json();
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Fetch the raw quota summary from the Cloud Code API.
|
|
54
|
-
* @param {string} accessToken
|
|
55
|
-
* @param {{ host?: string, channel?: 'daily'|'prod' }} [opts]
|
|
56
|
-
* @returns {Promise<{ raw: object, host: string, account: string|null, tier: string|null }>}
|
|
57
|
-
*/
|
|
58
|
-
export async function fetchQuotaSummary(accessToken, opts = {}) {
|
|
59
|
-
const candidates = opts.host
|
|
60
|
-
? [opts.host]
|
|
61
|
-
: opts.channel === 'prod'
|
|
62
|
-
? ['cloudcode-pa.googleapis.com']
|
|
63
|
-
: opts.channel === 'daily'
|
|
64
|
-
? ['daily-cloudcode-pa.googleapis.com']
|
|
65
|
-
: HOSTS;
|
|
66
|
-
|
|
67
|
-
let lastErr;
|
|
68
|
-
for (const host of candidates) {
|
|
69
|
-
try {
|
|
70
|
-
const lca = await postInternal(host, accessToken, 'loadCodeAssist', {
|
|
71
|
-
metadata: { ideType: 'ANTIGRAVITY' },
|
|
72
|
-
});
|
|
73
|
-
const project = lca.cloudaicompanionProject;
|
|
74
|
-
if (!project) throw new ApiError('loadCodeAssist returned no cloudaicompanionProject', 0);
|
|
75
|
-
|
|
76
|
-
const raw = await postInternal(host, accessToken, 'retrieveUserQuotaSummary', { project });
|
|
77
|
-
return {
|
|
78
|
-
raw,
|
|
79
|
-
host,
|
|
80
|
-
tier: lca.currentTier?.id ?? null,
|
|
81
|
-
account: extractEmail(lca.currentTier?.upgradeSubscriptionUri),
|
|
82
|
-
};
|
|
83
|
-
} catch (err) {
|
|
84
|
-
lastErr = err;
|
|
85
|
-
// 404 / wrong-host -> try next candidate; auth errors -> stop early.
|
|
86
|
-
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) throw err;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
throw lastErr ?? new ApiError('No Cloud Code host responded', 0);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export { ApiError };
|
package/src/main.js
DELETED
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// agy-usage — Antigravity CLI (agy) usage/quota monitor.
|
|
3
|
-
//
|
|
4
|
-
// Usage:
|
|
5
|
-
// agy-usage one-shot panel (like agy's /usage)
|
|
6
|
-
// agy-usage --json machine-readable JSON
|
|
7
|
-
// agy-usage --watch [secs] refresh every N seconds (default 60)
|
|
8
|
-
// agy-usage --source api|pty|auto data source (default auto: api, fall back to pty)
|
|
9
|
-
// agy-usage --channel daily|prod Cloud Code host (default: auto-detect)
|
|
10
|
-
// agy-usage --no-cache bypass the 5-minute cache
|
|
11
|
-
// agy-usage --refresh force a fresh fetch (alias for --no-cache)
|
|
12
|
-
// agy-usage update [--check] self-update via npm
|
|
13
|
-
// agy-usage --version | -v print the installed version
|
|
14
|
-
|
|
15
|
-
import { getAccessToken, CredentialError } from './credentials.js';
|
|
16
|
-
import { fetchQuotaSummary } from './api.js';
|
|
17
|
-
import { captureUsageViaPty } from './pty-fallback.js';
|
|
18
|
-
import { fromApi, fromPty } from './quota.js';
|
|
19
|
-
import { renderPanel } from './render.js';
|
|
20
|
-
import { currentVersion, runUpdate } from './update.js';
|
|
21
|
-
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
22
|
-
import { homedir } from 'node:os';
|
|
23
|
-
import { join } from 'node:path';
|
|
24
|
-
|
|
25
|
-
const CACHE_DIR = join(process.env.XDG_CACHE_HOME || join(homedir(), '.cache'), 'agy-usage');
|
|
26
|
-
const CACHE_FILE = join(CACHE_DIR, 'quota.json');
|
|
27
|
-
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
28
|
-
|
|
29
|
-
function parseArgs(argv) {
|
|
30
|
-
const o = { json: false, watch: null, source: 'auto', channel: 'auto', cache: true, command: null, check: false };
|
|
31
|
-
for (let i = 0; i < argv.length; i++) {
|
|
32
|
-
const a = argv[i];
|
|
33
|
-
if (a === 'update' && o.command == null) o.command = 'update';
|
|
34
|
-
else if (a === '--json') o.json = true;
|
|
35
|
-
else if (a === '--watch') {
|
|
36
|
-
const n = Number(argv[i + 1]);
|
|
37
|
-
if (Number.isFinite(n)) { o.watch = n; i++; } else o.watch = 60;
|
|
38
|
-
} else if (a === '--source') o.source = argv[++i];
|
|
39
|
-
else if (a === '--channel') o.channel = argv[++i];
|
|
40
|
-
else if (a === '--no-cache' || a === '--refresh') o.cache = false;
|
|
41
|
-
else if (a === '--check') o.check = true;
|
|
42
|
-
else if (a === '-v' || a === '--version') o.version = true;
|
|
43
|
-
else if (a === '-h' || a === '--help') o.help = true;
|
|
44
|
-
}
|
|
45
|
-
return o;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const HELP = `agy-usage — Antigravity CLI (agy) usage/quota monitor
|
|
49
|
-
|
|
50
|
-
agy-usage one-shot panel
|
|
51
|
-
agy-usage --json machine-readable JSON
|
|
52
|
-
agy-usage --watch [secs] auto-refresh (default 60s)
|
|
53
|
-
agy-usage --source <auto|api|pty>
|
|
54
|
-
agy-usage --channel <auto|daily|prod>
|
|
55
|
-
agy-usage --no-cache | --refresh
|
|
56
|
-
agy-usage update [--check] self-update via npm (--check: report only)
|
|
57
|
-
agy-usage --version | -v
|
|
58
|
-
`;
|
|
59
|
-
|
|
60
|
-
// --- cache -------------------------------------------------------------------
|
|
61
|
-
|
|
62
|
-
function readCache() {
|
|
63
|
-
try {
|
|
64
|
-
const { ts, snap } = JSON.parse(readFileSync(CACHE_FILE, 'utf8'));
|
|
65
|
-
if (Date.now() - ts < CACHE_TTL_MS) return snap;
|
|
66
|
-
} catch {}
|
|
67
|
-
return null;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function writeCache(snap) {
|
|
71
|
-
try {
|
|
72
|
-
mkdirSync(CACHE_DIR, { recursive: true });
|
|
73
|
-
writeFileSync(CACHE_FILE, JSON.stringify({ ts: Date.now(), snap }));
|
|
74
|
-
} catch {}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// --- fetch -------------------------------------------------------------------
|
|
78
|
-
|
|
79
|
-
export async function getSnapshot(opts) {
|
|
80
|
-
if (opts.cache && opts.source !== 'pty') {
|
|
81
|
-
const cached = readCache();
|
|
82
|
-
if (cached) return cached;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
let snap;
|
|
86
|
-
if (opts.source === 'pty') {
|
|
87
|
-
snap = fromPty(await captureUsageViaPty());
|
|
88
|
-
} else {
|
|
89
|
-
try {
|
|
90
|
-
const { accessToken } = await getAccessToken();
|
|
91
|
-
const raw = await fetchQuotaSummary(accessToken, { channel: opts.channel === 'auto' ? undefined : opts.channel });
|
|
92
|
-
snap = fromApi(raw);
|
|
93
|
-
} catch (err) {
|
|
94
|
-
if (opts.source === 'api') throw err;
|
|
95
|
-
// auto: fall back to PTY
|
|
96
|
-
process.stderr.write(`[api failed: ${err.message}] falling back to PTY (agy)…\n`);
|
|
97
|
-
snap = fromPty(await captureUsageViaPty());
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
writeCache(snap);
|
|
101
|
-
return snap;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// --- main --------------------------------------------------------------------
|
|
105
|
-
|
|
106
|
-
async function once(opts) {
|
|
107
|
-
const snap = await getSnapshot(opts);
|
|
108
|
-
if (opts.json) process.stdout.write(JSON.stringify(snap, null, 2) + '\n');
|
|
109
|
-
else process.stdout.write(renderPanel(snap) + '\n');
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
async function main() {
|
|
113
|
-
const opts = parseArgs(process.argv.slice(2));
|
|
114
|
-
if (opts.help) { process.stdout.write(HELP); return; }
|
|
115
|
-
if (opts.version) { process.stdout.write(currentVersion() + '\n'); return; }
|
|
116
|
-
if (opts.command === 'update') { process.exit(await runUpdate({ checkOnly: opts.check })); }
|
|
117
|
-
|
|
118
|
-
if (opts.watch != null) {
|
|
119
|
-
const intervalMs = Math.max(5, opts.watch) * 1000;
|
|
120
|
-
const tick = async () => {
|
|
121
|
-
try {
|
|
122
|
-
if (!opts.json) process.stdout.write('\x1b[2J\x1b[H'); // clear screen
|
|
123
|
-
await once(opts);
|
|
124
|
-
} catch (err) {
|
|
125
|
-
process.stderr.write(`error: ${err.message}\n`);
|
|
126
|
-
}
|
|
127
|
-
};
|
|
128
|
-
await tick();
|
|
129
|
-
setInterval(tick, intervalMs);
|
|
130
|
-
} else {
|
|
131
|
-
await once(opts);
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
main().catch((err) => {
|
|
136
|
-
if (err instanceof CredentialError) {
|
|
137
|
-
process.stderr.write(`credential error: ${err.message}\n`);
|
|
138
|
-
} else {
|
|
139
|
-
process.stderr.write(`error: ${err.message}\n`);
|
|
140
|
-
}
|
|
141
|
-
process.exit(1);
|
|
142
|
-
});
|
package/src/pty-fallback.js
DELETED
|
@@ -1,211 +0,0 @@
|
|
|
1
|
-
// Fallback path: drive the real `agy` TUI in a pseudo-terminal, send `/usage`,
|
|
2
|
-
// reconstruct the rendered screen with a headless VT emulator, and parse the
|
|
3
|
-
// panel. Used only when the direct API path is unavailable (no readable
|
|
4
|
-
// keyring, or the internal API changed). Slower and more brittle than the API
|
|
5
|
-
// path, but uses agy's own auth so it works wherever agy itself works.
|
|
6
|
-
//
|
|
7
|
-
// Why a VT emulator: agy renders /usage in the alternate screen buffer using
|
|
8
|
-
// cursor addressing, so naive ANSI-stripping yields nothing. We feed the raw
|
|
9
|
-
// PTY bytes through @xterm/headless to get the final visible screen, then parse.
|
|
10
|
-
//
|
|
11
|
-
// Capture backend: python3 `pty` on POSIX (no native build), node-pty on
|
|
12
|
-
// Windows (ConPTY). agy shows a welcome screen first, so `/usage` is sent after
|
|
13
|
-
// a delay and the session is held open long enough to render.
|
|
14
|
-
|
|
15
|
-
import { spawn } from 'node:child_process';
|
|
16
|
-
import { writeFileSync, readFileSync, mkdtempSync, existsSync } from 'node:fs';
|
|
17
|
-
import { tmpdir, homedir } from 'node:os';
|
|
18
|
-
import { join, delimiter } from 'node:path';
|
|
19
|
-
|
|
20
|
-
// Resolve the agy binary: explicit AGY_BIN, then PATH, then common install dir.
|
|
21
|
-
function resolveAgy() {
|
|
22
|
-
const explicit = process.env.AGY_BIN;
|
|
23
|
-
if (explicit) return explicit;
|
|
24
|
-
const exe = process.platform === 'win32' ? 'agy.exe' : 'agy';
|
|
25
|
-
for (const dir of (process.env.PATH || '').split(delimiter)) {
|
|
26
|
-
if (dir && existsSync(join(dir, exe))) return join(dir, exe);
|
|
27
|
-
}
|
|
28
|
-
const local = join(homedir(), '.local', 'bin', exe);
|
|
29
|
-
if (existsSync(local)) return local;
|
|
30
|
-
return 'agy'; // last resort: let the OS resolve it
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const AGY_BIN = resolveAgy();
|
|
34
|
-
const COLS = 120;
|
|
35
|
-
const ROWS = 60;
|
|
36
|
-
const USAGE_AT_MS = 10_000; // send /usage after the welcome screen settles
|
|
37
|
-
const TEARDOWN_MS = 23_000; // keep session open long enough to render
|
|
38
|
-
|
|
39
|
-
// --- capture: returns raw PTY bytes (Buffer) or null --------------------------
|
|
40
|
-
|
|
41
|
-
async function captureViaNodePty() {
|
|
42
|
-
let pty;
|
|
43
|
-
try {
|
|
44
|
-
pty = await import('node-pty');
|
|
45
|
-
} catch {
|
|
46
|
-
return null;
|
|
47
|
-
}
|
|
48
|
-
return new Promise((resolve) => {
|
|
49
|
-
let term;
|
|
50
|
-
try {
|
|
51
|
-
term = pty.spawn(AGY_BIN, [], { name: 'xterm-256color', cols: COLS, rows: ROWS, cwd: process.cwd(), env: process.env });
|
|
52
|
-
} catch {
|
|
53
|
-
resolve(null);
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
56
|
-
const chunks = [];
|
|
57
|
-
term.onData((d) => chunks.push(Buffer.from(d, 'utf8')));
|
|
58
|
-
const t1 = setTimeout(() => { try { term.write('/usage\r'); } catch {} }, USAGE_AT_MS);
|
|
59
|
-
const t2 = setTimeout(() => {
|
|
60
|
-
try { term.write('\x03'); } catch {}
|
|
61
|
-
try { term.kill(); } catch {}
|
|
62
|
-
resolve(Buffer.concat(chunks));
|
|
63
|
-
}, TEARDOWN_MS);
|
|
64
|
-
term.onExit(() => { clearTimeout(t1); clearTimeout(t2); resolve(Buffer.concat(chunks)); });
|
|
65
|
-
});
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
async function captureViaPython() {
|
|
69
|
-
if (process.platform === 'win32') return null;
|
|
70
|
-
const dir = mkdtempSync(join(tmpdir(), 'agy-usage-'));
|
|
71
|
-
const helper = join(dir, 'drive.py');
|
|
72
|
-
const outFile = join(dir, 'out.bin');
|
|
73
|
-
writeFileSync(
|
|
74
|
-
helper,
|
|
75
|
-
`import os, pty, time, select, signal, struct, fcntl, termios
|
|
76
|
-
AGY = ${JSON.stringify(AGY_BIN)}
|
|
77
|
-
out = open(${JSON.stringify(outFile)}, "wb")
|
|
78
|
-
pid, fd = pty.fork()
|
|
79
|
-
if pid == 0:
|
|
80
|
-
os.execvpe(AGY, [AGY], os.environ)
|
|
81
|
-
os._exit(127)
|
|
82
|
-
fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", ${ROWS}, ${COLS}, 0, 0))
|
|
83
|
-
start = time.time(); sent = False
|
|
84
|
-
while time.time() - start < ${TEARDOWN_MS / 1000}:
|
|
85
|
-
e = time.time() - start
|
|
86
|
-
r, _, _ = select.select([fd], [], [], 0.5)
|
|
87
|
-
if r:
|
|
88
|
-
try: d = os.read(fd, 8192)
|
|
89
|
-
except OSError: break
|
|
90
|
-
if not d: break
|
|
91
|
-
out.write(d); out.flush()
|
|
92
|
-
if not sent and e > ${USAGE_AT_MS / 1000}:
|
|
93
|
-
os.write(fd, b"/usage\\r"); sent = True
|
|
94
|
-
try: os.write(fd, b"\\x03")
|
|
95
|
-
except OSError: pass
|
|
96
|
-
try: os.kill(pid, signal.SIGTERM)
|
|
97
|
-
except Exception: pass
|
|
98
|
-
out.close()
|
|
99
|
-
`,
|
|
100
|
-
);
|
|
101
|
-
return new Promise((resolve) => {
|
|
102
|
-
const proc = spawn('python3', [helper], { stdio: 'ignore' });
|
|
103
|
-
proc.on('error', () => resolve(null));
|
|
104
|
-
proc.on('exit', () => {
|
|
105
|
-
try { resolve(readFileSync(outFile)); } catch { resolve(null); }
|
|
106
|
-
});
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// --- VT reconstruction --------------------------------------------------------
|
|
111
|
-
|
|
112
|
-
async function reconstructScreen(raw) {
|
|
113
|
-
const mod = await import('@xterm/headless');
|
|
114
|
-
const Terminal = mod.Terminal ?? mod.default?.Terminal ?? mod.default;
|
|
115
|
-
const term = new Terminal({ cols: COLS, rows: ROWS, allowProposedApi: true, scrollback: 200 });
|
|
116
|
-
await new Promise((res) => term.write(raw, res));
|
|
117
|
-
const buf = term.buffer.active;
|
|
118
|
-
const lines = [];
|
|
119
|
-
// include scrollback so a panel taller than the viewport is still captured
|
|
120
|
-
for (let i = 0; i < buf.length; i++) {
|
|
121
|
-
const line = buf.getLine(i);
|
|
122
|
-
if (line) lines.push(line.translateToString(true).replace(/\s+$/, ''));
|
|
123
|
-
}
|
|
124
|
-
term.dispose?.();
|
|
125
|
-
return lines.join('\n');
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// --- parse --------------------------------------------------------------------
|
|
129
|
-
|
|
130
|
-
function parseDuration(text) {
|
|
131
|
-
let seconds = 0;
|
|
132
|
-
const d = text.match(/(\d+)\s*day/i);
|
|
133
|
-
const h = text.match(/(\d+)\s*h(?:our)?/i);
|
|
134
|
-
const m = text.match(/(\d+)\s*m(?:in)?/i);
|
|
135
|
-
if (d) seconds += +d[1] * 86400;
|
|
136
|
-
if (h) seconds += +h[1] * 3600;
|
|
137
|
-
if (m) seconds += +m[1] * 60;
|
|
138
|
-
return seconds || null;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/** Parse the reconstructed /usage screen text into { account, groups:[...] }. */
|
|
142
|
-
export function parsePanel(text) {
|
|
143
|
-
const lines = text.split(/\r?\n/);
|
|
144
|
-
const account = text.match(/Account:\s*(\S+)/)?.[1] ?? null;
|
|
145
|
-
|
|
146
|
-
const groups = [];
|
|
147
|
-
let group = null;
|
|
148
|
-
let bucket = null;
|
|
149
|
-
const pushBucket = () => { if (group && bucket) group.buckets.push(bucket); bucket = null; };
|
|
150
|
-
const pushGroup = () => { pushBucket(); if (group) groups.push(group); group = null; };
|
|
151
|
-
|
|
152
|
-
for (const line of lines) {
|
|
153
|
-
const t = line.trim();
|
|
154
|
-
if (!t) continue;
|
|
155
|
-
|
|
156
|
-
if (/^[A-Z][A-Z0-9 &/]*MODELS$/.test(t)) {
|
|
157
|
-
pushGroup();
|
|
158
|
-
group = { name: t.replace(/\s+/g, ' '), models: '', buckets: [] };
|
|
159
|
-
continue;
|
|
160
|
-
}
|
|
161
|
-
const models = t.match(/^Models within this group:\s*(.+)$/i);
|
|
162
|
-
if (models && group) { group.models = models[1].trim(); continue; }
|
|
163
|
-
|
|
164
|
-
if (/^(Weekly Limit|Five Hour Limit|5[- ]?Hour Limit)$/i.test(t)) {
|
|
165
|
-
pushBucket();
|
|
166
|
-
bucket = {
|
|
167
|
-
kind: /week/i.test(t) ? 'weekly' : '5h',
|
|
168
|
-
label: t,
|
|
169
|
-
remainingFraction: null,
|
|
170
|
-
resetsInSeconds: null,
|
|
171
|
-
available: false,
|
|
172
|
-
description: null,
|
|
173
|
-
};
|
|
174
|
-
continue;
|
|
175
|
-
}
|
|
176
|
-
if (bucket) {
|
|
177
|
-
const pct = t.match(/(\d+(?:\.\d+)?)\s*%/);
|
|
178
|
-
if (pct && bucket.remainingFraction == null) bucket.remainingFraction = +pct[1] / 100;
|
|
179
|
-
if (/Quota available/i.test(t)) { bucket.available = true; bucket.remainingFraction = 1; }
|
|
180
|
-
const refresh = t.match(/Refreshes in (.+)$/i);
|
|
181
|
-
if (refresh) bucket.resetsInSeconds = parseDuration(refresh[1]);
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
pushGroup();
|
|
185
|
-
return { account, groups };
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* Run agy, capture /usage, reconstruct + parse the panel.
|
|
190
|
-
* @returns {Promise<{ account, groups }>}
|
|
191
|
-
*/
|
|
192
|
-
export async function captureUsageViaPty() {
|
|
193
|
-
const order = process.platform === 'win32'
|
|
194
|
-
? [captureViaNodePty, captureViaPython]
|
|
195
|
-
: [captureViaPython, captureViaNodePty];
|
|
196
|
-
|
|
197
|
-
let raw = null;
|
|
198
|
-
for (const fn of order) {
|
|
199
|
-
raw = await fn();
|
|
200
|
-
if (raw && raw.length) break;
|
|
201
|
-
}
|
|
202
|
-
if (!raw || !raw.length) {
|
|
203
|
-
throw new Error('No PTY backend captured agy output (need python3 on POSIX, or node-pty on Windows)');
|
|
204
|
-
}
|
|
205
|
-
const screen = await reconstructScreen(raw);
|
|
206
|
-
const parsed = parsePanel(screen);
|
|
207
|
-
if (!parsed.groups.length) {
|
|
208
|
-
throw new Error('Could not parse /usage panel from agy output');
|
|
209
|
-
}
|
|
210
|
-
return parsed;
|
|
211
|
-
}
|