agy-cli-usage 0.4.3 → 0.4.4
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 +7 -0
- package/README.ko.md +2 -0
- package/README.md +2 -0
- package/dist/src/api.js +10 -0
- package/dist/src/pty-fallback.js +31 -16
- package/dist/src/render.js +11 -3
- package/dist/src/server.d.ts +14 -0
- package/dist/src/server.js +54 -27
- package/package.json +6 -2
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,13 @@
|
|
|
10
10
|
|
|
11
11
|
* use plain v* tags in release-please ([#12](https://github.com/abruption/agy-cli-usage/issues/12)) ([74b648d](https://github.com/abruption/agy-cli-usage/commit/74b648df24967f71a43095a80e7340a6b5ac2e39)), closes [#9](https://github.com/abruption/agy-cli-usage/issues/9)
|
|
12
12
|
|
|
13
|
+
## [0.4.4](https://github.com/abruption/agy-cli-usage/compare/v0.4.3...v0.4.4) (2026-07-03)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Bug Fixes
|
|
17
|
+
|
|
18
|
+
* PTY cleanup, duration parsing, email extraction, render clamping, test coverage ([#35](https://github.com/abruption/agy-cli-usage/issues/35)) ([de886a2](https://github.com/abruption/agy-cli-usage/commit/de886a2a5fdf9c7777f485fd94eab0f1a13d215f))
|
|
19
|
+
|
|
13
20
|
## [0.4.3](https://github.com/abruption/agy-cli-usage/compare/v0.4.2...v0.4.3) (2026-07-02)
|
|
14
21
|
|
|
15
22
|
|
package/README.ko.md
CHANGED
|
@@ -131,6 +131,7 @@ npm test # 빌드 후 node --test (자격증명·네트워크 불필요
|
|
|
131
131
|
## 주의
|
|
132
132
|
|
|
133
133
|
- `v1internal:retrieveUserQuotaSummary`는 **비공개·비문서 내부 엔드포인트**입니다. 스키마/호스트가 예고 없이 바뀔 수 있으며, 그때의 안전망이 PTY 폴백입니다. 본인 계정의 사용량 조회 용도로만 사용하세요.
|
|
134
|
+
- **최상위 티어 구독자는 API 경로에서 `account`가 `null`입니다.** API 경로는 `loadCodeAssist` 응답의 `currentTier.upgradeSubscriptionUri`에 붙은 `Email=` 파라미터에서 계정 이메일을 추출하는데, 이미 최상위 티어인 계정은 "업그레이드" URL 자체가 없어 추출할 대상이 없습니다. 이는 비공개 엔드포인트의 알려진 제약이며, `--source pty`(agy 자체가 렌더링한 `/usage` 패널에서 `Account: …`를 직접 읽어오므로 영향받지 않음) 외에는 다른 우회 플래그가 없습니다.
|
|
134
135
|
- 자격증명은 OS 저장소에서 **읽기만** 하며, refresh 토큰을 되쓰지 않아 `agy` 세션과 충돌하지 않습니다.
|
|
135
136
|
- 코드에 포함된 OAuth client_id/secret은 `agy` 바이너리의 **installed-app(public)** 값으로, [Google 문서](https://developers.google.com/identity/protocols/oauth2)상 기밀이 아닙니다. 사용자 식별은 각자의 키링 토큰으로 이뤄집니다.
|
|
136
137
|
|
|
@@ -198,6 +199,7 @@ npm test # 빌드 후 node --test (자격증명·네트워크 불필요
|
|
|
198
199
|
- `remainingFraction`(잔여 비율 0–1)을 우선 사용. `available`이 `true`면 풀 쿼타로 간주(패널은 "Quota available" 표시).
|
|
199
200
|
- `resetsInSeconds`는 `fetchedAt` 기준 상대값, `resetAt`은 절대값. 둘 다 `null` 가능.
|
|
200
201
|
- `kind`는 인식 시 `weekly`/`5h`로 정규화, 아니면 원본 window/label 문자열.
|
|
202
|
+
- `"source": "api"`로 성공한 응답에서도 `account`가 `null`일 수 있습니다 — 위 주의 섹션 참고(최상위 티어 구독자는 API 경로에 이메일 소스가 없음). 계정 이메일이 반드시 필요하면 `--source pty` 또는 PTY 기반 스냅샷을 사용하세요.
|
|
201
203
|
|
|
202
204
|
## HTTP API (`npm run serve` / `dist/src/server.js`)
|
|
203
205
|
|
package/README.md
CHANGED
|
@@ -131,6 +131,7 @@ npm test # build, then node --test (no credentials/network; pure logic)
|
|
|
131
131
|
## Caveats
|
|
132
132
|
|
|
133
133
|
- `v1internal:retrieveUserQuotaSummary` is a **private, undocumented endpoint**. Its schema/host may change without notice; the PTY fallback is the safety net. Use it only to check your own account's usage.
|
|
134
|
+
- **`account` is `null` via the API path for top-tier subscribers.** The API path derives the account email from the `Email=` param on `currentTier.upgradeSubscriptionUri` in the `loadCodeAssist` response — accounts already on the top tier have no "upgrade" URL, so there's nothing to extract from. This is a known limitation of the undocumented endpoint, not a bug you can work around via flags other than `--source pty`, which reads `Account: …` straight off agy's own rendered `/usage` panel and isn't affected.
|
|
134
135
|
- Credentials are **read-only** from the OS store; the refresh token is never written back, so it never conflicts with `agy`'s own session.
|
|
135
136
|
- The OAuth client_id/secret embedded in the code are `agy`'s **installed-app (public)** values — per [Google's docs](https://developers.google.com/identity/protocols/oauth2) these are not treated as secret. Per-user identity comes from your keyring token, not the client_id.
|
|
136
137
|
|
|
@@ -198,6 +199,7 @@ Notes for parsing:
|
|
|
198
199
|
- Prefer `remainingFraction` (fraction remaining, 0–1). When `available` is `true`, treat as full quota (the panel shows "Quota available").
|
|
199
200
|
- `resetsInSeconds` is relative to `fetchedAt`; `resetAt` is absolute. Either may be `null`.
|
|
200
201
|
- `kind` is normalized to `weekly` / `5h` where recognized, otherwise the raw window/label string.
|
|
202
|
+
- `account` may be `null` even on a successful `"source": "api"` response — see the Caveats section above (top-tier subscribers have no email source in the API path). Use `--source pty` / a PTY-sourced snapshot if you need the account email reliably.
|
|
201
203
|
|
|
202
204
|
## HTTP API (`npm run serve` / `dist/src/server.js`)
|
|
203
205
|
|
package/dist/src/api.js
CHANGED
|
@@ -19,6 +19,16 @@ class ApiError extends Error {
|
|
|
19
19
|
this.status = status;
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
|
+
// Known limitation: this is the only email source `loadCodeAssist` exposes —
|
|
23
|
+
// the `Email=` query param on `currentTier.upgradeSubscriptionUri`. Accounts
|
|
24
|
+
// already on the top tier have nothing left to upgrade to, so that URI (and
|
|
25
|
+
// therefore `account`) is `null` for them via the API path even though the
|
|
26
|
+
// PTY path can still show `Account: …` (it reads it straight off agy's own
|
|
27
|
+
// rendered panel, which has an in-app session it can draw on that this
|
|
28
|
+
// unauthenticated-beyond-the-token API response doesn't provide). There is no
|
|
29
|
+
// alternative email field in either `loadCodeAssist` or
|
|
30
|
+
// `retrieveUserQuotaSummary` today — see README Caveats. Use `--source pty`
|
|
31
|
+
// if you need the account email for a top-tier subscriber.
|
|
22
32
|
function extractEmail(uri) {
|
|
23
33
|
const m = uri?.match(/[?&]Email=([^&]+)/);
|
|
24
34
|
if (!m)
|
package/dist/src/pty-fallback.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// Windows (ConPTY). agy shows a welcome screen first, so `/usage` is sent after
|
|
13
13
|
// a delay and the session is held open long enough to render.
|
|
14
14
|
import { spawn } from 'node:child_process';
|
|
15
|
-
import { writeFileSync, readFileSync, mkdtempSync, existsSync } from 'node:fs';
|
|
15
|
+
import { writeFileSync, readFileSync, mkdtempSync, existsSync, rmSync } from 'node:fs';
|
|
16
16
|
import { tmpdir, homedir } from 'node:os';
|
|
17
17
|
import { join, delimiter } from 'node:path';
|
|
18
18
|
// Resolve the agy binary: explicit AGY_BIN, then PATH, then common install dir.
|
|
@@ -79,10 +79,14 @@ async function captureViaNodePty() {
|
|
|
79
79
|
async function captureViaPython() {
|
|
80
80
|
if (process.platform === 'win32')
|
|
81
81
|
return null;
|
|
82
|
+
// Cleaned up in `finally` below regardless of success/failure so repeated
|
|
83
|
+
// `--watch` runs or repeated fallback triggers don't leak a directory per
|
|
84
|
+
// capture (each holds a helper script + the captured PTY bytes).
|
|
82
85
|
const dir = mkdtempSync(join(tmpdir(), 'agy-usage-'));
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
+
try {
|
|
87
|
+
const helper = join(dir, 'drive.py');
|
|
88
|
+
const outFile = join(dir, 'out.bin');
|
|
89
|
+
writeFileSync(helper, `import os, pty, time, select, signal, struct, fcntl, termios
|
|
86
90
|
AGY = ${JSON.stringify(AGY_BIN)}
|
|
87
91
|
out = open(${JSON.stringify(outFile)}, "wb")
|
|
88
92
|
pid, fd = pty.fork()
|
|
@@ -107,18 +111,25 @@ try: os.kill(pid, signal.SIGTERM)
|
|
|
107
111
|
except Exception: pass
|
|
108
112
|
out.close()
|
|
109
113
|
`);
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
114
|
+
return await new Promise((resolve) => {
|
|
115
|
+
const proc = spawn('python3', [helper], { stdio: 'ignore' });
|
|
116
|
+
proc.on('error', () => resolve(null));
|
|
117
|
+
proc.on('exit', () => {
|
|
118
|
+
try {
|
|
119
|
+
resolve(readFileSync(outFile));
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
resolve(null);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
120
125
|
});
|
|
121
|
-
}
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
try {
|
|
129
|
+
rmSync(dir, { recursive: true, force: true });
|
|
130
|
+
}
|
|
131
|
+
catch { /* best-effort cleanup */ }
|
|
132
|
+
}
|
|
122
133
|
}
|
|
123
134
|
// --- VT reconstruction --------------------------------------------------------
|
|
124
135
|
async function reconstructScreen(raw) {
|
|
@@ -139,7 +150,11 @@ async function reconstructScreen(raw) {
|
|
|
139
150
|
// --- parse --------------------------------------------------------------------
|
|
140
151
|
function parseDuration(text) {
|
|
141
152
|
let seconds = 0;
|
|
142
|
-
|
|
153
|
+
// Matches both the spelled-out form ("3 days", "1 day") and the abbreviated
|
|
154
|
+
// form ("3d") that agy's panel may render instead — without the `\b`
|
|
155
|
+
// fallback, only "day"/"days" was recognized and a bare "3d 2h" silently
|
|
156
|
+
// dropped the day component.
|
|
157
|
+
const d = text.match(/(\d+)\s*d(?:ays?)?\b/i);
|
|
143
158
|
const h = text.match(/(\d+)\s*h(?:our)?/i);
|
|
144
159
|
const m = text.match(/(\d+)\s*m(?:in)?/i);
|
|
145
160
|
if (d)
|
package/dist/src/render.js
CHANGED
|
@@ -16,8 +16,15 @@ function barColor(remaining) {
|
|
|
16
16
|
return '33'; // yellow
|
|
17
17
|
return '31'; // red
|
|
18
18
|
}
|
|
19
|
+
// Clamp to [0,1] — remainingFraction comes from an undocumented private
|
|
20
|
+
// endpoint (or PTY-parsed text), so an out-of-range value must not be shown
|
|
21
|
+
// as-is. Shared by the bar and the percentage text so they never disagree
|
|
22
|
+
// (e.g. bar capped at 100% while the text next to it reads "150.00%").
|
|
23
|
+
function clampFraction(remainingFraction) {
|
|
24
|
+
return remainingFraction == null ? null : Math.max(0, Math.min(1, remainingFraction));
|
|
25
|
+
}
|
|
19
26
|
function bar(remainingFraction) {
|
|
20
|
-
const frac = remainingFraction
|
|
27
|
+
const frac = clampFraction(remainingFraction) ?? 0;
|
|
21
28
|
const filled = Math.round(frac * BAR_WIDTH);
|
|
22
29
|
const body = '█'.repeat(filled) + '░'.repeat(BAR_WIDTH - filled);
|
|
23
30
|
return useColor() ? `\x1b[${barColor(remainingFraction)}m${body}\x1b[0m` : body;
|
|
@@ -29,8 +36,9 @@ function bucketLine(b) {
|
|
|
29
36
|
lines.push(` [${bar(1)}] ${c('32', 'Quota available')}`);
|
|
30
37
|
}
|
|
31
38
|
else {
|
|
32
|
-
const
|
|
33
|
-
const
|
|
39
|
+
const clamped = clampFraction(b.remainingFraction);
|
|
40
|
+
const pct = clamped == null ? '—' : `${(clamped * 100).toFixed(2)}%`;
|
|
41
|
+
const remainPct = clamped == null ? '' : `${Math.round(clamped * 100)}% remaining`;
|
|
34
42
|
const dur = formatDuration(b.resetsInSeconds);
|
|
35
43
|
const reset = dur ? ` · ${dim(`Refreshes in ${dur}`)}` : '';
|
|
36
44
|
lines.push(` [${bar(b.remainingFraction)}] ${pct}`);
|
package/dist/src/server.d.ts
CHANGED
|
@@ -1,2 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { type Server } from 'node:http';
|
|
3
|
+
import type { Snapshot } from './types.js';
|
|
4
|
+
type SnapshotFetcher = (opts: {
|
|
5
|
+
source: 'auto';
|
|
6
|
+
channel: 'auto';
|
|
7
|
+
cache: boolean;
|
|
8
|
+
}) => Promise<Snapshot>;
|
|
9
|
+
/**
|
|
10
|
+
* Builds the HTTP server without binding a port.
|
|
11
|
+
* `fetchSnapshot` defaults to the real `getSnapshot` (network/keyring/PTY) —
|
|
12
|
+
* exposed as a parameter purely so tests can inject a stub and exercise the
|
|
13
|
+
* routing/response-shape logic without live credentials or a PTY.
|
|
14
|
+
*/
|
|
15
|
+
export declare function createApp(fetchSnapshot?: SnapshotFetcher): Server;
|
|
2
16
|
export {};
|
package/dist/src/server.js
CHANGED
|
@@ -7,37 +7,64 @@
|
|
|
7
7
|
// GET /quota -> normalized snapshot JSON
|
|
8
8
|
// GET /healthz -> { ok: true }
|
|
9
9
|
import { createServer } from 'node:http';
|
|
10
|
+
import { realpathSync } from 'node:fs';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
10
12
|
import { getSnapshot } from './main.js';
|
|
11
13
|
const PORT = Number(process.env.PORT) || 3007;
|
|
12
14
|
const HOST = process.env.HOST || '127.0.0.1';
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
res.
|
|
25
|
-
|
|
26
|
-
'Access-Control-Allow-Origin': '*',
|
|
27
|
-
'Cache-Control': 'public, max-age=300',
|
|
28
|
-
});
|
|
29
|
-
res.end(JSON.stringify(snap));
|
|
15
|
+
/**
|
|
16
|
+
* Builds the HTTP server without binding a port.
|
|
17
|
+
* `fetchSnapshot` defaults to the real `getSnapshot` (network/keyring/PTY) —
|
|
18
|
+
* exposed as a parameter purely so tests can inject a stub and exercise the
|
|
19
|
+
* routing/response-shape logic without live credentials or a PTY.
|
|
20
|
+
*/
|
|
21
|
+
export function createApp(fetchSnapshot = getSnapshot) {
|
|
22
|
+
return createServer(async (req, res) => {
|
|
23
|
+
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
|
24
|
+
if (url.pathname === '/healthz') {
|
|
25
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
26
|
+
res.end(JSON.stringify({ ok: true }));
|
|
27
|
+
return;
|
|
30
28
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
29
|
+
if (url.pathname === '/quota') {
|
|
30
|
+
try {
|
|
31
|
+
const noCache = url.searchParams.get('refresh') === '1';
|
|
32
|
+
const snap = await fetchSnapshot({ source: 'auto', channel: 'auto', cache: !noCache });
|
|
33
|
+
res.writeHead(200, {
|
|
34
|
+
'Content-Type': 'application/json',
|
|
35
|
+
'Access-Control-Allow-Origin': '*',
|
|
36
|
+
'Cache-Control': 'public, max-age=300',
|
|
37
|
+
});
|
|
38
|
+
res.end(JSON.stringify(snap));
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
42
|
+
res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
|
|
43
|
+
}
|
|
44
|
+
return;
|
|
34
45
|
}
|
|
35
|
-
|
|
46
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
47
|
+
res.end(JSON.stringify({ error: 'not found' }));
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
// Only bind a real port when this file is executed directly (the `serve`
|
|
51
|
+
// script's entry point) — guarded the same way as main.ts's isMainModule()
|
|
52
|
+
// so `createApp` can be imported for unit testing without the import itself
|
|
53
|
+
// starting a live server as a side effect.
|
|
54
|
+
function isMainModule() {
|
|
55
|
+
if (!process.argv[1])
|
|
56
|
+
return false;
|
|
57
|
+
try {
|
|
58
|
+
return fileURLToPath(import.meta.url) === realpathSync(process.argv[1]);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return false;
|
|
36
62
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
server.listen(PORT, HOST, () => {
|
|
41
|
-
|
|
42
|
-
});
|
|
63
|
+
}
|
|
64
|
+
if (isMainModule()) {
|
|
65
|
+
const server = createApp();
|
|
66
|
+
server.listen(PORT, HOST, () => {
|
|
67
|
+
process.stdout.write(`agy-usage server on http://${HOST}:${PORT} (GET /quota)\n`);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
43
70
|
//# sourceMappingURL=server.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agy-cli-usage",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
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
6
|
"types": "dist/src/main.d.ts",
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"scripts": {
|
|
12
12
|
"build": "tsc",
|
|
13
13
|
"check": "tsc --noEmit",
|
|
14
|
+
"lint": "eslint .",
|
|
14
15
|
"pretest": "tsc",
|
|
15
16
|
"test": "node --test dist/test/unit.test.js",
|
|
16
17
|
"start": "tsc && node dist/src/main.js",
|
|
@@ -55,7 +56,10 @@
|
|
|
55
56
|
},
|
|
56
57
|
"license": "MIT",
|
|
57
58
|
"devDependencies": {
|
|
59
|
+
"@eslint/js": "^9.39.4",
|
|
58
60
|
"@types/node": "^26.0.0",
|
|
59
|
-
"
|
|
61
|
+
"eslint": "^9.39.4",
|
|
62
|
+
"typescript": "^6.0.3",
|
|
63
|
+
"typescript-eslint": "^8.62.1"
|
|
60
64
|
}
|
|
61
65
|
}
|