agy-cli-usage 0.4.2 → 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 CHANGED
@@ -10,6 +10,20 @@
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
+
20
+ ## [0.4.3](https://github.com/abruption/agy-cli-usage/compare/v0.4.2...v0.4.3) (2026-07-02)
21
+
22
+
23
+ ### Bug Fixes
24
+
25
+ * key the quota cache by source/channel, validate --source/--channel ([#33](https://github.com/abruption/agy-cli-usage/issues/33)) ([19e6860](https://github.com/abruption/agy-cli-usage/commit/19e6860bcf2aa8a547b880a4430027539b4343fb)), closes [#30](https://github.com/abruption/agy-cli-usage/issues/30)
26
+
13
27
  ## [0.4.2](https://github.com/abruption/agy-cli-usage/compare/v0.4.1...v0.4.2) (2026-06-30)
14
28
 
15
29
 
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)
@@ -11,10 +11,18 @@ export interface CliOptions {
11
11
  version?: boolean;
12
12
  help?: boolean;
13
13
  }
14
+ /** Exported for direct unit testing (no process.argv/exit side effects). */
15
+ export declare function parseArgs(argv: string[]): CliOptions;
16
+ /** Exported for direct unit testing via an injected `cacheFile` — not part of the CLI's public surface. */
17
+ export declare function readCache(source: SnapshotOptions['source'], channel: SnapshotOptions['channel'], cacheFile?: string): Snapshot | null;
18
+ /** Exported for direct unit testing via an injected `cacheFile` — not part of the CLI's public surface. */
19
+ export declare function writeCache(snap: Snapshot, source: SnapshotOptions['source'], channel: SnapshotOptions['channel'], cacheFile?: string): void;
14
20
  /** Subset of options needed to produce a snapshot (also usable from server.ts). */
15
21
  export interface SnapshotOptions {
16
22
  source: 'auto' | 'api' | 'pty';
17
23
  channel: 'auto' | 'daily' | 'prod';
18
24
  cache: boolean;
25
+ /** Override the cache file path — for tests only; defaults to the real user cache. */
26
+ cacheFile?: string;
19
27
  }
20
28
  export declare function getSnapshot(opts: SnapshotOptions): Promise<Snapshot>;
package/dist/src/main.js CHANGED
@@ -17,14 +17,18 @@ import { captureUsageViaPty } from './pty-fallback.js';
17
17
  import { fromApi, fromPty } from './quota.js';
18
18
  import { renderPanel } from './render.js';
19
19
  import { currentVersion, runUpdate } from './update.js';
20
- import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
20
+ import { readFileSync, writeFileSync, mkdirSync, realpathSync } from 'node:fs';
21
21
  import { homedir } from 'node:os';
22
- import { join } from 'node:path';
22
+ import { join, dirname } from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
23
24
  const CACHE_DIR = join(process.env.XDG_CACHE_HOME || join(homedir(), '.cache'), 'agy-usage');
24
25
  const CACHE_FILE = join(CACHE_DIR, 'quota.json');
25
26
  const CACHE_TTL_MS = 5 * 60 * 1000;
27
+ const VALID_SOURCES = ['auto', 'api', 'pty'];
28
+ const VALID_CHANNELS = ['auto', 'daily', 'prod'];
26
29
  const errMessage = (e) => (e instanceof Error ? e.message : String(e));
27
- function parseArgs(argv) {
30
+ /** Exported for direct unit testing (no process.argv/exit side effects). */
31
+ export function parseArgs(argv) {
28
32
  const o = {
29
33
  json: false, watch: null, source: 'auto', channel: 'auto', cache: true, command: null, check: false,
30
34
  };
@@ -43,10 +47,20 @@ function parseArgs(argv) {
43
47
  else
44
48
  o.watch = 60;
45
49
  }
46
- else if (a === '--source')
47
- o.source = (argv[++i] ?? 'auto');
48
- else if (a === '--channel')
49
- o.channel = (argv[++i] ?? 'auto');
50
+ else if (a === '--source') {
51
+ const v = argv[++i];
52
+ if (!VALID_SOURCES.includes(v)) {
53
+ throw new Error(`invalid --source '${v}' — expected one of: ${VALID_SOURCES.join(', ')}`);
54
+ }
55
+ o.source = v;
56
+ }
57
+ else if (a === '--channel') {
58
+ const v = argv[++i];
59
+ if (!VALID_CHANNELS.includes(v)) {
60
+ throw new Error(`invalid --channel '${v}' — expected one of: ${VALID_CHANNELS.join(', ')}`);
61
+ }
62
+ o.channel = v;
63
+ }
50
64
  else if (a === '--no-cache' || a === '--refresh')
51
65
  o.cache = false;
52
66
  else if (a === '--check')
@@ -69,22 +83,25 @@ const HELP = `agy-cli-usage — Antigravity CLI (agy) usage/quota monitor
69
83
  agy-cli-usage update [--check] self-update via npm (--check: report only)
70
84
  agy-cli-usage --version | -v
71
85
  `;
72
- // --- cache -------------------------------------------------------------------
73
- function readCache() {
86
+ /** Exported for direct unit testing via an injected `cacheFile` — not part of the CLI's public surface. */
87
+ export function readCache(source, channel, cacheFile = CACHE_FILE) {
74
88
  try {
75
- const { ts, snap } = JSON.parse(readFileSync(CACHE_FILE, 'utf8'));
76
- if (Date.now() - ts < CACHE_TTL_MS)
77
- return snap;
89
+ const entry = JSON.parse(readFileSync(cacheFile, 'utf8'));
90
+ if (entry.source !== source || entry.channel !== channel)
91
+ return null;
92
+ if (Date.now() - entry.ts < CACHE_TTL_MS)
93
+ return entry.snap;
78
94
  }
79
95
  catch {
80
- /* no/expired cache */
96
+ /* no/expired/incompatible-format cache */
81
97
  }
82
98
  return null;
83
99
  }
84
- function writeCache(snap) {
100
+ /** Exported for direct unit testing via an injected `cacheFile` — not part of the CLI's public surface. */
101
+ export function writeCache(snap, source, channel, cacheFile = CACHE_FILE) {
85
102
  try {
86
- mkdirSync(CACHE_DIR, { recursive: true });
87
- writeFileSync(CACHE_FILE, JSON.stringify({ ts: Date.now(), snap }));
103
+ mkdirSync(dirname(cacheFile), { recursive: true });
104
+ writeFileSync(cacheFile, JSON.stringify({ ts: Date.now(), source, channel, snap }));
88
105
  }
89
106
  catch {
90
107
  /* cache is best-effort */
@@ -92,7 +109,7 @@ function writeCache(snap) {
92
109
  }
93
110
  export async function getSnapshot(opts) {
94
111
  if (opts.cache && opts.source !== 'pty') {
95
- const cached = readCache();
112
+ const cached = readCache(opts.source, opts.channel, opts.cacheFile);
96
113
  if (cached)
97
114
  return cached;
98
115
  }
@@ -114,7 +131,7 @@ export async function getSnapshot(opts) {
114
131
  snap = fromPty(await captureUsageViaPty());
115
132
  }
116
133
  }
117
- writeCache(snap);
134
+ writeCache(snap, opts.source, opts.channel, opts.cacheFile);
118
135
  return snap;
119
136
  }
120
137
  // --- main --------------------------------------------------------------------
@@ -157,13 +174,31 @@ async function main() {
157
174
  await once(opts);
158
175
  }
159
176
  }
160
- main().catch((err) => {
161
- if (err instanceof CredentialError) {
162
- process.stderr.write(`credential error: ${err.message}\n`);
177
+ // Only run the CLI when this file is executed directly (as the `bin` entry
178
+ // point) guarded so parseArgs/readCache/writeCache/etc. can be imported
179
+ // for unit testing without triggering a full live CLI run (network calls,
180
+ // process.exit()) as an import side effect. realpathSync resolves symlinks
181
+ // on process.argv[1] (npm global `bin` entries are frequently symlinks);
182
+ // import.meta.url is already symlink-resolved by Node's ESM loader.
183
+ function isMainModule() {
184
+ if (!process.argv[1])
185
+ return false;
186
+ try {
187
+ return fileURLToPath(import.meta.url) === realpathSync(process.argv[1]);
163
188
  }
164
- else {
165
- process.stderr.write(`error: ${errMessage(err)}\n`);
189
+ catch {
190
+ return false;
166
191
  }
167
- process.exit(1);
168
- });
192
+ }
193
+ if (isMainModule()) {
194
+ main().catch((err) => {
195
+ if (err instanceof CredentialError) {
196
+ process.stderr.write(`credential error: ${err.message}\n`);
197
+ }
198
+ else {
199
+ process.stderr.write(`error: ${errMessage(err)}\n`);
200
+ }
201
+ process.exit(1);
202
+ });
203
+ }
169
204
  //# sourceMappingURL=main.js.map
@@ -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
- const helper = join(dir, 'drive.py');
84
- const outFile = join(dir, 'out.bin');
85
- writeFileSync(helper, `import os, pty, time, select, signal, struct, fcntl, termios
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
- return new Promise((resolve) => {
111
- const proc = spawn('python3', [helper], { stdio: 'ignore' });
112
- proc.on('error', () => resolve(null));
113
- proc.on('exit', () => {
114
- try {
115
- resolve(readFileSync(outFile));
116
- }
117
- catch {
118
- resolve(null);
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
- const d = text.match(/(\d+)\s*day/i);
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)
@@ -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 == null ? 0 : Math.max(0, Math.min(1, 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 pct = b.remainingFraction == null ? '—' : `${(b.remainingFraction * 100).toFixed(2)}%`;
33
- const remainPct = b.remainingFraction == null ? '' : `${Math.round(b.remainingFraction * 100)}% remaining`;
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}`);
@@ -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 {};
@@ -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
- const server = createServer(async (req, res) => {
14
- const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
15
- if (url.pathname === '/healthz') {
16
- res.writeHead(200, { 'Content-Type': 'application/json' });
17
- res.end(JSON.stringify({ ok: true }));
18
- return;
19
- }
20
- if (url.pathname === '/quota') {
21
- try {
22
- const noCache = url.searchParams.get('refresh') === '1';
23
- const snap = await getSnapshot({ source: 'auto', channel: 'auto', cache: !noCache });
24
- res.writeHead(200, {
25
- 'Content-Type': 'application/json',
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
- catch (err) {
32
- res.writeHead(502, { 'Content-Type': 'application/json' });
33
- res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
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
- return;
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
- res.writeHead(404, { 'Content-Type': 'application/json' });
38
- res.end(JSON.stringify({ error: 'not found' }));
39
- });
40
- server.listen(PORT, HOST, () => {
41
- process.stdout.write(`agy-usage server on http://${HOST}:${PORT} (GET /quota)\n`);
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.2",
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
- "typescript": "^6.0.3"
61
+ "eslint": "^9.39.4",
62
+ "typescript": "^6.0.3",
63
+ "typescript-eslint": "^8.62.1"
60
64
  }
61
65
  }