agy-cli-usage 0.1.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 +84 -0
- package/package.json +52 -0
- package/server.js +48 -0
- package/src/api.js +92 -0
- package/src/credentials.js +158 -0
- package/src/main.js +132 -0
- package/src/pty-fallback.js +211 -0
- package/src/quota.js +100 -0
- package/src/render.js +82 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 abruption
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# agy-cli-usage
|
|
2
|
+
|
|
3
|
+
[](https://github.com/abruption/agy-cli-usage/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/agy-cli-usage)
|
|
5
|
+
|
|
6
|
+
Headless usage/quota monitor for the Antigravity CLI (`agy`). `agy`의 인터랙티브 `/usage` 패널과 동일한 정보(모델 그룹별 주간·5시간 한도, 잔여율, 리프레시 시각)를 **headless로** 조회한다. IDE 불필요.
|
|
7
|
+
|
|
8
|
+
`agy -p "<prompt>"`(headless)는 `/usage` 같은 TUI 슬래시 커맨드를 렌더링하지 않으므로 자동화가 불가능하다. 이 도구는 `agy`가 내부적으로 호출하는 Cloud Code API를 직접 호출하거나, 불가능할 때 `agy`를 PTY로 구동해 패널을 파싱한다.
|
|
9
|
+
|
|
10
|
+
## 설치
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
# npm 레지스트리에서
|
|
14
|
+
npm install -g agy-cli-usage
|
|
15
|
+
|
|
16
|
+
# 또는 GitHub에서 직접
|
|
17
|
+
npm install -g github:abruption/agy-cli-usage
|
|
18
|
+
npx github:abruption/agy-cli-usage
|
|
19
|
+
|
|
20
|
+
# 또는 소스에서
|
|
21
|
+
git clone https://github.com/abruption/agy-cli-usage && cd agy-cli-usage
|
|
22
|
+
npm install && npm link
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
전역 명령은 `agy-cli-usage` (별칭 `agy-usage`)로 등록된다. Node.js >= 18 (전역 `fetch` 필요). 사전 조건: 같은 머신에서 `agy`에 로그인되어 있을 것.
|
|
26
|
+
|
|
27
|
+
## 사용법
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
agy-usage # /usage 와 동일한 패널 출력 (1회성)
|
|
31
|
+
agy-usage --json # 머신 리더블 JSON
|
|
32
|
+
agy-usage --watch 60 # 60초 간격 자동 갱신 (5분 캐시 경유)
|
|
33
|
+
agy-usage --source api|pty|auto # 데이터 소스 (기본 auto: API → 실패 시 PTY)
|
|
34
|
+
agy-usage --channel daily|prod # Cloud Code 호스트 (기본 auto: daily→prod 탐지)
|
|
35
|
+
agy-usage --no-cache # 5분 캐시 무시하고 강제 조회
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## 동작 방식
|
|
39
|
+
|
|
40
|
+
`agy`는 OAuth 토큰을 OS 키링(zalando/go-keyring 규약, service=`gemini`/account=`antigravity`)에 저장한다.
|
|
41
|
+
|
|
42
|
+
1. **직접 API (기본)** — 키링에서 토큰을 읽고(만료 시 자동 refresh), `agy`와 동일하게:
|
|
43
|
+
- `POST https://<host>/v1internal:loadCodeAssist` → `cloudaicompanionProject` 획득
|
|
44
|
+
- `POST https://<host>/v1internal:retrieveUserQuotaSummary {project}` → 쿼타
|
|
45
|
+
- 호스트: `daily-cloudcode-pa.googleapis.com`(현재 CLI) 또는 `cloudcode-pa.googleapis.com`
|
|
46
|
+
2. **PTY 폴백** — 키링을 읽을 수 없거나(헤드리스 등) 내부 API가 바뀌면, `agy`를 가상 터미널로 띄워 `/usage`를 보내고, `@xterm/headless`로 alt-screen을 재구성해 패널을 파싱한다.
|
|
47
|
+
|
|
48
|
+
### 크로스플랫폼 자격증명
|
|
49
|
+
|
|
50
|
+
| OS | 키링 백엔드 | 비고 |
|
|
51
|
+
|----|-----------|------|
|
|
52
|
+
| macOS | Keychain | `@napi-rs/keyring`, CLI 폴백 `security` |
|
|
53
|
+
| Windows | Credential Manager | `@napi-rs/keyring` |
|
|
54
|
+
| Linux (데스크톱) | Secret Service | `@napi-rs/keyring`, CLI 폴백 `secret-tool` |
|
|
55
|
+
| Linux (헤드리스) | — | Secret Service 부재 시 `--source pty`로 우회 (python3 필요) |
|
|
56
|
+
|
|
57
|
+
## HTTP 엔드포인트 (선택)
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
PORT=3007 node server.js # GET /quota → 정규화 JSON (5분 캐시), GET /healthz
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
외부 대시보드/스크립트에서 `GET /quota`로 소비하거나, `agy-usage --json`을 subprocess로 호출한다.
|
|
64
|
+
|
|
65
|
+
## 개발 / 릴리스
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
npm test # node --test (자격증명·네트워크 불필요, 순수 로직)
|
|
69
|
+
npm run check # 구문 검사
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
- **CI** (`.github/workflows/ci.yml`): push/PR마다 ubuntu(Node 18/20/22) + macOS/Windows(Node 22)에서 테스트.
|
|
73
|
+
- **Release** (`.github/workflows/release.yml`): `v*` 태그 push 시 빌드·테스트 후 npm publish(provenance) + GitHub Release 생성. `NPM_TOKEN` 시크릿 필요.
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
npm version patch # package.json 버전 +0.0.1 & v* 태그 생성
|
|
77
|
+
git push --follow-tags # → Release 워크플로 실행 → npm 자동 배포
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## 주의
|
|
81
|
+
|
|
82
|
+
- `v1internal:retrieveUserQuotaSummary`는 **비공개 내부 엔드포인트**다. 스키마/호스트가 예고 없이 바뀔 수 있으며, 그때를 위한 안전망이 PTY 폴백이다.
|
|
83
|
+
- 본인 계정의 키링 토큰만 읽으며(읽기 전용), refresh 토큰을 키링에 되쓰지 않는다.
|
|
84
|
+
- OAuth client_secret은 데스크톱(공개) 클라이언트 값이라 비밀이 아니다.
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "agy-cli-usage",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Headless usage/quota monitor for the Antigravity CLI (agy) — reads Cloud Code quota directly, with a PTY fallback. No IDE required.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"agy-cli-usage": "src/main.js",
|
|
8
|
+
"agy-usage": "src/main.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "node src/main.js",
|
|
12
|
+
"serve": "node server.js",
|
|
13
|
+
"test": "node --test",
|
|
14
|
+
"check": "node --check src/main.js && node --check server.js"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"src/",
|
|
21
|
+
"server.js",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"keywords": [
|
|
26
|
+
"antigravity",
|
|
27
|
+
"agy",
|
|
28
|
+
"gemini",
|
|
29
|
+
"code-assist",
|
|
30
|
+
"quota",
|
|
31
|
+
"usage",
|
|
32
|
+
"cli",
|
|
33
|
+
"rate-limit"
|
|
34
|
+
],
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/abruption/agy-cli-usage.git"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://github.com/abruption/agy-cli-usage#readme",
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/abruption/agy-cli-usage/issues"
|
|
42
|
+
},
|
|
43
|
+
"author": "abruption",
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@napi-rs/keyring": "^1.3.0",
|
|
46
|
+
"@xterm/headless": "^6.0.0"
|
|
47
|
+
},
|
|
48
|
+
"optionalDependencies": {
|
|
49
|
+
"node-pty": "^1.0.0"
|
|
50
|
+
},
|
|
51
|
+
"license": "MIT"
|
|
52
|
+
}
|
package/server.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
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 };
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Cross-platform reader for the Antigravity CLI (`agy`) OAuth credential.
|
|
2
|
+
//
|
|
3
|
+
// `agy` stores its token in the OS keyring using the zalando/go-keyring
|
|
4
|
+
// convention: service="gemini", account="antigravity". Long/binary values are
|
|
5
|
+
// stored with a `go-keyring-base64:` prefix followed by base64(JSON). The
|
|
6
|
+
// decoded JSON looks like:
|
|
7
|
+
// { "token": { "access_token", "token_type", "refresh_token", "expiry" },
|
|
8
|
+
// "auth_method": "consumer" }
|
|
9
|
+
//
|
|
10
|
+
// Read backends, tried in order:
|
|
11
|
+
// 1. @napi-rs/keyring native module (macOS / Windows / Linux Secret Service)
|
|
12
|
+
// 2. OS CLI fallback (`security` on macOS, `secret-tool` on Linux)
|
|
13
|
+
// If every backend fails (e.g. a headless Linux box with no Secret Service),
|
|
14
|
+
// the caller falls back to the PTY path which drives `agy` itself.
|
|
15
|
+
|
|
16
|
+
import { execFileSync } from 'node:child_process';
|
|
17
|
+
|
|
18
|
+
// OAuth client for the Antigravity CLI. This is an installed/desktop ("public")
|
|
19
|
+
// OAuth client: per Google's own docs the client secret of an installed app is
|
|
20
|
+
// "obviously not treated as a secret" — it ships inside the agy binary and is
|
|
21
|
+
// identical for every user (the per-user identity is the keyring token, not
|
|
22
|
+
// this). Verified: the same client_id appears in agy's browser consent URL
|
|
23
|
+
// regardless of account, and the flow uses PKCE (code_challenge/S256), the
|
|
24
|
+
// mechanism that secures public clients precisely because the secret is public.
|
|
25
|
+
// Same pattern as Google's open-source gemini-cli.
|
|
26
|
+
const OAUTH_CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
|
|
27
|
+
const OAUTH_CLIENT_SECRET = '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';
|
|
28
|
+
const TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
|
29
|
+
|
|
30
|
+
const KEYRING_SERVICE = 'gemini';
|
|
31
|
+
const KEYRING_ACCOUNT = 'antigravity';
|
|
32
|
+
const B64_PREFIX = 'go-keyring-base64:';
|
|
33
|
+
|
|
34
|
+
class CredentialError extends Error {}
|
|
35
|
+
|
|
36
|
+
// --- raw keyring read --------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
async function readViaNapiEsm() {
|
|
39
|
+
try {
|
|
40
|
+
const mod = await import('@napi-rs/keyring');
|
|
41
|
+
const Entry = mod.Entry ?? mod.default?.Entry;
|
|
42
|
+
if (!Entry) return null;
|
|
43
|
+
return new Entry(KEYRING_SERVICE, KEYRING_ACCOUNT).getPassword();
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function readViaCli() {
|
|
50
|
+
try {
|
|
51
|
+
if (process.platform === 'darwin') {
|
|
52
|
+
return execFileSync(
|
|
53
|
+
'security',
|
|
54
|
+
['find-generic-password', '-s', KEYRING_SERVICE, '-a', KEYRING_ACCOUNT, '-w'],
|
|
55
|
+
{ encoding: 'utf8' },
|
|
56
|
+
).trim();
|
|
57
|
+
}
|
|
58
|
+
if (process.platform === 'linux') {
|
|
59
|
+
return execFileSync(
|
|
60
|
+
'secret-tool',
|
|
61
|
+
['lookup', 'service', KEYRING_SERVICE, 'account', KEYRING_ACCOUNT],
|
|
62
|
+
{ encoding: 'utf8' },
|
|
63
|
+
).trim();
|
|
64
|
+
}
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function readRawSecret() {
|
|
72
|
+
const fromNapi = await readViaNapiEsm();
|
|
73
|
+
if (fromNapi) return fromNapi;
|
|
74
|
+
const fromCli = readViaCli();
|
|
75
|
+
if (fromCli) return fromCli;
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// --- decode ------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
function decodeSecret(raw) {
|
|
82
|
+
const payload = raw.startsWith(B64_PREFIX)
|
|
83
|
+
? Buffer.from(raw.slice(B64_PREFIX.length), 'base64').toString('utf8')
|
|
84
|
+
: raw;
|
|
85
|
+
let parsed;
|
|
86
|
+
try {
|
|
87
|
+
parsed = JSON.parse(payload);
|
|
88
|
+
} catch {
|
|
89
|
+
throw new CredentialError('Stored agy credential is not valid JSON');
|
|
90
|
+
}
|
|
91
|
+
const token = parsed.token ?? parsed;
|
|
92
|
+
if (!token?.access_token) {
|
|
93
|
+
throw new CredentialError('Stored agy credential has no access_token');
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
accessToken: token.access_token,
|
|
97
|
+
refreshToken: token.refresh_token,
|
|
98
|
+
expiry: token.expiry ? new Date(token.expiry) : null,
|
|
99
|
+
authMethod: parsed.auth_method ?? null,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// --- refresh -----------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
function isExpired(cred, skewMs = 60_000) {
|
|
106
|
+
if (!cred.expiry) return false;
|
|
107
|
+
return cred.expiry.getTime() - Date.now() < skewMs;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function refreshAccessToken(refreshToken) {
|
|
111
|
+
const body = new URLSearchParams({
|
|
112
|
+
grant_type: 'refresh_token',
|
|
113
|
+
refresh_token: refreshToken,
|
|
114
|
+
client_id: OAUTH_CLIENT_ID,
|
|
115
|
+
client_secret: OAUTH_CLIENT_SECRET,
|
|
116
|
+
});
|
|
117
|
+
const res = await fetch(TOKEN_URL, {
|
|
118
|
+
method: 'POST',
|
|
119
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
120
|
+
body: body.toString(),
|
|
121
|
+
});
|
|
122
|
+
if (!res.ok) {
|
|
123
|
+
throw new CredentialError(`Token refresh failed: HTTP ${res.status} ${await res.text()}`);
|
|
124
|
+
}
|
|
125
|
+
const json = await res.json();
|
|
126
|
+
return json.access_token;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// --- public API --------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Returns a valid access token for the Cloud Code API, refreshing if needed.
|
|
133
|
+
* Throws CredentialError if no credential can be read from any keyring backend
|
|
134
|
+
* (the caller should then consider the PTY fallback).
|
|
135
|
+
* @returns {Promise<{ accessToken: string, authMethod: string|null }>}
|
|
136
|
+
*/
|
|
137
|
+
export async function getAccessToken() {
|
|
138
|
+
const raw = await readRawSecret();
|
|
139
|
+
if (!raw) {
|
|
140
|
+
throw new CredentialError(
|
|
141
|
+
'Could not read agy credential from the OS keyring. ' +
|
|
142
|
+
'Is agy logged in on this machine? (headless servers may lack a keyring — use --source pty)',
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
const cred = decodeSecret(raw);
|
|
146
|
+
if (isExpired(cred) && cred.refreshToken) {
|
|
147
|
+
const fresh = await refreshAccessToken(cred.refreshToken);
|
|
148
|
+
return { accessToken: fresh, authMethod: cred.authMethod };
|
|
149
|
+
}
|
|
150
|
+
return { accessToken: cred.accessToken, authMethod: cred.authMethod };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Whether a keyring-based credential is readable at all (no refresh attempted). */
|
|
154
|
+
export async function hasCredential() {
|
|
155
|
+
return (await readRawSecret()) != null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export { CredentialError };
|
package/src/main.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
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
|
+
|
|
13
|
+
import { getAccessToken, CredentialError } from './credentials.js';
|
|
14
|
+
import { fetchQuotaSummary } from './api.js';
|
|
15
|
+
import { captureUsageViaPty } from './pty-fallback.js';
|
|
16
|
+
import { fromApi, fromPty } from './quota.js';
|
|
17
|
+
import { renderPanel } from './render.js';
|
|
18
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
19
|
+
import { homedir } from 'node:os';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
|
|
22
|
+
const CACHE_DIR = join(process.env.XDG_CACHE_HOME || join(homedir(), '.cache'), 'agy-usage');
|
|
23
|
+
const CACHE_FILE = join(CACHE_DIR, 'quota.json');
|
|
24
|
+
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
25
|
+
|
|
26
|
+
function parseArgs(argv) {
|
|
27
|
+
const o = { json: false, watch: null, source: 'auto', channel: 'auto', cache: true };
|
|
28
|
+
for (let i = 0; i < argv.length; i++) {
|
|
29
|
+
const a = argv[i];
|
|
30
|
+
if (a === '--json') o.json = true;
|
|
31
|
+
else if (a === '--watch') {
|
|
32
|
+
const n = Number(argv[i + 1]);
|
|
33
|
+
if (Number.isFinite(n)) { o.watch = n; i++; } else o.watch = 60;
|
|
34
|
+
} else if (a === '--source') o.source = argv[++i];
|
|
35
|
+
else if (a === '--channel') o.channel = argv[++i];
|
|
36
|
+
else if (a === '--no-cache' || a === '--refresh') o.cache = false;
|
|
37
|
+
else if (a === '-h' || a === '--help') o.help = true;
|
|
38
|
+
}
|
|
39
|
+
return o;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const HELP = `agy-usage — Antigravity CLI (agy) usage/quota monitor
|
|
43
|
+
|
|
44
|
+
agy-usage one-shot panel
|
|
45
|
+
agy-usage --json machine-readable JSON
|
|
46
|
+
agy-usage --watch [secs] auto-refresh (default 60s)
|
|
47
|
+
agy-usage --source <auto|api|pty>
|
|
48
|
+
agy-usage --channel <auto|daily|prod>
|
|
49
|
+
agy-usage --no-cache | --refresh
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
// --- cache -------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
function readCache() {
|
|
55
|
+
try {
|
|
56
|
+
const { ts, snap } = JSON.parse(readFileSync(CACHE_FILE, 'utf8'));
|
|
57
|
+
if (Date.now() - ts < CACHE_TTL_MS) return snap;
|
|
58
|
+
} catch {}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function writeCache(snap) {
|
|
63
|
+
try {
|
|
64
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
65
|
+
writeFileSync(CACHE_FILE, JSON.stringify({ ts: Date.now(), snap }));
|
|
66
|
+
} catch {}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// --- fetch -------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
export async function getSnapshot(opts) {
|
|
72
|
+
if (opts.cache && opts.source !== 'pty') {
|
|
73
|
+
const cached = readCache();
|
|
74
|
+
if (cached) return cached;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let snap;
|
|
78
|
+
if (opts.source === 'pty') {
|
|
79
|
+
snap = fromPty(await captureUsageViaPty());
|
|
80
|
+
} else {
|
|
81
|
+
try {
|
|
82
|
+
const { accessToken } = await getAccessToken();
|
|
83
|
+
const raw = await fetchQuotaSummary(accessToken, { channel: opts.channel === 'auto' ? undefined : opts.channel });
|
|
84
|
+
snap = fromApi(raw);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
if (opts.source === 'api') throw err;
|
|
87
|
+
// auto: fall back to PTY
|
|
88
|
+
process.stderr.write(`[api failed: ${err.message}] falling back to PTY (agy)…\n`);
|
|
89
|
+
snap = fromPty(await captureUsageViaPty());
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
writeCache(snap);
|
|
93
|
+
return snap;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// --- main --------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
async function once(opts) {
|
|
99
|
+
const snap = await getSnapshot(opts);
|
|
100
|
+
if (opts.json) process.stdout.write(JSON.stringify(snap, null, 2) + '\n');
|
|
101
|
+
else process.stdout.write(renderPanel(snap) + '\n');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function main() {
|
|
105
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
106
|
+
if (opts.help) { process.stdout.write(HELP); return; }
|
|
107
|
+
|
|
108
|
+
if (opts.watch != null) {
|
|
109
|
+
const intervalMs = Math.max(5, opts.watch) * 1000;
|
|
110
|
+
const tick = async () => {
|
|
111
|
+
try {
|
|
112
|
+
if (!opts.json) process.stdout.write('\x1b[2J\x1b[H'); // clear screen
|
|
113
|
+
await once(opts);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
process.stderr.write(`error: ${err.message}\n`);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
await tick();
|
|
119
|
+
setInterval(tick, intervalMs);
|
|
120
|
+
} else {
|
|
121
|
+
await once(opts);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
main().catch((err) => {
|
|
126
|
+
if (err instanceof CredentialError) {
|
|
127
|
+
process.stderr.write(`credential error: ${err.message}\n`);
|
|
128
|
+
} else {
|
|
129
|
+
process.stderr.write(`error: ${err.message}\n`);
|
|
130
|
+
}
|
|
131
|
+
process.exit(1);
|
|
132
|
+
});
|
|
@@ -0,0 +1,211 @@
|
|
|
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
|
+
}
|
package/src/quota.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Normalizes quota data from either source (direct API JSON or PTY-parsed text)
|
|
2
|
+
// into one shape consumed by the renderer / JSON output / HTTP endpoint.
|
|
3
|
+
//
|
|
4
|
+
// Normalized model:
|
|
5
|
+
// {
|
|
6
|
+
// account, fetchedAt, source: 'api'|'pty', host?,
|
|
7
|
+
// note, // the API's overall description blurb
|
|
8
|
+
// groups: [
|
|
9
|
+
// { name, models, // e.g. "Gemini Models", "Gemini Flash, Gemini Pro"
|
|
10
|
+
// buckets: [
|
|
11
|
+
// { kind: 'weekly'|'5h', label,
|
|
12
|
+
// remainingFraction, // 0..1 (null if unknown)
|
|
13
|
+
// usedFraction, // 1 - remaining
|
|
14
|
+
// resetAt, // ISO string or null
|
|
15
|
+
// resetsInSeconds, // derived from resetAt - now (null if unknown)
|
|
16
|
+
// available, // true when nothing consumed (remainingFraction === 1)
|
|
17
|
+
// description } ] } ]
|
|
18
|
+
// }
|
|
19
|
+
|
|
20
|
+
function bucketKind(window, label) {
|
|
21
|
+
if (window === 'weekly' || /week/i.test(label)) return 'weekly';
|
|
22
|
+
if (window === '5h' || /5.?hour|five.?hour/i.test(label)) return '5h';
|
|
23
|
+
return window || label;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function secondsUntil(resetAt, now) {
|
|
27
|
+
if (!resetAt) return null;
|
|
28
|
+
const ms = new Date(resetAt).getTime() - now;
|
|
29
|
+
return Number.isFinite(ms) ? Math.max(0, Math.round(ms / 1000)) : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Build a normalized snapshot from the raw retrieveUserQuotaSummary response. */
|
|
33
|
+
export function fromApi({ raw, host, account, tier }, nowMs = Date.now()) {
|
|
34
|
+
const groups = (raw.groups ?? []).map((g) => ({
|
|
35
|
+
name: g.displayName ?? 'Models',
|
|
36
|
+
models: (g.description ?? '').replace(/^Models within this group:\s*/i, '').trim(),
|
|
37
|
+
buckets: (g.buckets ?? []).map((b) => {
|
|
38
|
+
const remaining = typeof b.remainingFraction === 'number' ? b.remainingFraction : null;
|
|
39
|
+
return {
|
|
40
|
+
kind: bucketKind(b.window, b.displayName ?? ''),
|
|
41
|
+
label: b.displayName ?? b.window ?? '',
|
|
42
|
+
remainingFraction: remaining,
|
|
43
|
+
usedFraction: remaining == null ? null : 1 - remaining,
|
|
44
|
+
resetAt: b.resetTime ?? null,
|
|
45
|
+
resetsInSeconds: secondsUntil(b.resetTime, nowMs),
|
|
46
|
+
available: remaining === 1,
|
|
47
|
+
description: b.description ?? null,
|
|
48
|
+
};
|
|
49
|
+
}),
|
|
50
|
+
}));
|
|
51
|
+
return {
|
|
52
|
+
account: account ?? null,
|
|
53
|
+
tier: tier ?? null,
|
|
54
|
+
fetchedAt: new Date(nowMs).toISOString(),
|
|
55
|
+
source: 'api',
|
|
56
|
+
host: host ?? null,
|
|
57
|
+
note: raw.description ?? null,
|
|
58
|
+
groups,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build a normalized snapshot from PTY-parsed groups (see pty-fallback.js).
|
|
64
|
+
* Input groups: [{ name, models, buckets:[{ kind, label, remainingFraction,
|
|
65
|
+
* resetsInSeconds, available, description }] }]
|
|
66
|
+
*/
|
|
67
|
+
export function fromPty(parsed, nowMs = Date.now()) {
|
|
68
|
+
const groups = (parsed.groups ?? []).map((g) => ({
|
|
69
|
+
name: g.name,
|
|
70
|
+
models: g.models ?? '',
|
|
71
|
+
buckets: (g.buckets ?? []).map((b) => ({
|
|
72
|
+
kind: b.kind,
|
|
73
|
+
label: b.label,
|
|
74
|
+
remainingFraction: b.remainingFraction ?? null,
|
|
75
|
+
usedFraction: b.remainingFraction == null ? null : 1 - b.remainingFraction,
|
|
76
|
+
resetAt: b.resetsInSeconds != null ? new Date(nowMs + b.resetsInSeconds * 1000).toISOString() : null,
|
|
77
|
+
resetsInSeconds: b.resetsInSeconds ?? null,
|
|
78
|
+
available: b.available ?? b.remainingFraction === 1,
|
|
79
|
+
description: b.description ?? null,
|
|
80
|
+
})),
|
|
81
|
+
}));
|
|
82
|
+
return {
|
|
83
|
+
account: parsed.account ?? null,
|
|
84
|
+
tier: null,
|
|
85
|
+
fetchedAt: new Date(nowMs).toISOString(),
|
|
86
|
+
source: 'pty',
|
|
87
|
+
host: null,
|
|
88
|
+
note: parsed.note ?? null,
|
|
89
|
+
groups,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Format a seconds duration like agy: "73h 53m" / "2h 7m" / "12m". */
|
|
94
|
+
export function formatDuration(seconds) {
|
|
95
|
+
if (seconds == null) return null;
|
|
96
|
+
const h = Math.floor(seconds / 3600);
|
|
97
|
+
const m = Math.floor((seconds % 3600) / 60);
|
|
98
|
+
if (h > 0) return `${h}h ${m}m`;
|
|
99
|
+
return `${m}m`;
|
|
100
|
+
}
|
package/src/render.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Renders a normalized quota snapshot as a terminal panel, mirroring agy's
|
|
2
|
+
// `/usage` layout (progress bar + percent + reset time per bucket).
|
|
3
|
+
|
|
4
|
+
import { formatDuration } from './quota.js';
|
|
5
|
+
|
|
6
|
+
const BAR_WIDTH = 50;
|
|
7
|
+
|
|
8
|
+
const useColor = () => process.stdout.isTTY && !process.env.NO_COLOR;
|
|
9
|
+
const c = (code, s) => (useColor() ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
10
|
+
const dim = (s) => c('2', s);
|
|
11
|
+
const bold = (s) => c('1', s);
|
|
12
|
+
|
|
13
|
+
// remaining-based color: lots left = green, getting low = yellow/red.
|
|
14
|
+
function barColor(remaining) {
|
|
15
|
+
if (remaining == null) return '37';
|
|
16
|
+
if (remaining > 0.5) return '32'; // green
|
|
17
|
+
if (remaining > 0.2) return '33'; // yellow
|
|
18
|
+
return '31'; // red
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function bar(remainingFraction) {
|
|
22
|
+
const frac = remainingFraction == null ? 0 : Math.max(0, Math.min(1, remainingFraction));
|
|
23
|
+
const filled = Math.round(frac * BAR_WIDTH);
|
|
24
|
+
const body = '█'.repeat(filled) + '░'.repeat(BAR_WIDTH - filled);
|
|
25
|
+
return useColor() ? `\x1b[${barColor(remainingFraction)}m${body}\x1b[0m` : body;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function bucketLine(b) {
|
|
29
|
+
const lines = [];
|
|
30
|
+
lines.push(` ${bold(b.label)}`);
|
|
31
|
+
if (b.available) {
|
|
32
|
+
lines.push(` [${bar(1)}] ${c('32', 'Quota available')}`);
|
|
33
|
+
} else {
|
|
34
|
+
const pct = b.remainingFraction == null ? '—' : `${(b.remainingFraction * 100).toFixed(2)}%`;
|
|
35
|
+
const remainPct = b.remainingFraction == null ? '' : `${Math.round(b.remainingFraction * 100)}% remaining`;
|
|
36
|
+
const dur = formatDuration(b.resetsInSeconds);
|
|
37
|
+
const reset = dur ? ` · ${dim(`Refreshes in ${dur}`)}` : '';
|
|
38
|
+
lines.push(` [${bar(b.remainingFraction)}] ${pct}`);
|
|
39
|
+
lines.push(` ${dim(remainPct)}${reset}`);
|
|
40
|
+
}
|
|
41
|
+
return lines.join('\n');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Returns the full panel as a string. */
|
|
45
|
+
export function renderPanel(snap) {
|
|
46
|
+
const out = [];
|
|
47
|
+
out.push('');
|
|
48
|
+
out.push(bold(' Models & Quota'));
|
|
49
|
+
if (snap.account) out.push(` ${dim('Account:')} ${snap.account}`);
|
|
50
|
+
out.push(` ${dim(`source: ${snap.source}${snap.host ? ` · ${snap.host}` : ''} · ${snap.fetchedAt}`)}`);
|
|
51
|
+
out.push('');
|
|
52
|
+
|
|
53
|
+
for (const g of snap.groups) {
|
|
54
|
+
out.push(bold(` ${g.name.toUpperCase()}`));
|
|
55
|
+
if (g.models) out.push(` ${dim(`Models within this group: ${g.models}`)}`);
|
|
56
|
+
out.push('');
|
|
57
|
+
for (const b of g.buckets) {
|
|
58
|
+
out.push(bucketLine(b));
|
|
59
|
+
out.push('');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (snap.note) {
|
|
63
|
+
out.push(dim(wrap(snap.note, 76, ' │')));
|
|
64
|
+
}
|
|
65
|
+
return out.join('\n');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function wrap(text, width, prefix) {
|
|
69
|
+
const words = text.split(/\s+/);
|
|
70
|
+
const lines = [];
|
|
71
|
+
let cur = '';
|
|
72
|
+
for (const w of words) {
|
|
73
|
+
if ((cur + ' ' + w).trim().length > width) {
|
|
74
|
+
lines.push(prefix + cur);
|
|
75
|
+
cur = w;
|
|
76
|
+
} else {
|
|
77
|
+
cur = (cur + ' ' + w).trim();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (cur) lines.push(prefix + cur);
|
|
81
|
+
return lines.join('\n');
|
|
82
|
+
}
|