agy-cli-usage 0.4.4 → 0.4.6

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,21 @@
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.6](https://github.com/abruption/agy-cli-usage/compare/v0.4.5...v0.4.6) (2026-09-07)
14
+
15
+
16
+ ### Bug Fixes
17
+
18
+ * restore the "antigravity" User-Agent the quota API gates on ([#48](https://github.com/abruption/agy-cli-usage/issues/48)) ([c375d5a](https://github.com/abruption/agy-cli-usage/commit/c375d5a3e9c1698cbb1dfe998be6e7c388363986)), closes [#47](https://github.com/abruption/agy-cli-usage/issues/47)
19
+
20
+ ## [0.4.5](https://github.com/abruption/agy-cli-usage/compare/v0.4.4...v0.4.5) (2026-07-03)
21
+
22
+
23
+ ### Bug Fixes
24
+
25
+ * derive UA from package version, name error classes, bound update-check timeouts, document env vars in --help, show cache freshness in watch mode ([62becdc](https://github.com/abruption/agy-cli-usage/commit/62becdcf4175d2cda3cec8dc3f5bba13005c0d59)), closes [#32](https://github.com/abruption/agy-cli-usage/issues/32)
26
+ * UA version, error class names, update-check timeouts, --help env vars, watch/cache indicator ([#37](https://github.com/abruption/agy-cli-usage/issues/37)) ([62becdc](https://github.com/abruption/agy-cli-usage/commit/62becdcf4175d2cda3cec8dc3f5bba13005c0d59))
27
+
13
28
  ## [0.4.4](https://github.com/abruption/agy-cli-usage/compare/v0.4.3...v0.4.4) (2026-07-03)
14
29
 
15
30
 
package/README.md CHANGED
@@ -107,6 +107,8 @@ The token is **read only** from wherever `agy` stored it. Handled per platform a
107
107
 
108
108
  Read order: `keyring → OS CLI → Windows credman → token file → PTY`. Override the file path with `AGY_OAUTH_TOKEN_FILE`.
109
109
 
110
+ Token files are tried in order: `AGY_OAUTH_TOKEN_FILE`, then `~/.gemini/antigravity-cli/antigravity-oauth-token`, then `~/.gemini/jetski-standalone-oauth-token`. The last one is checked only after every keyring backend has failed — on macOS it sits beside the Keychain entry and can hold an older grant from a different session, so it must not win over a working one.
111
+
110
112
  ## HTTP endpoint (optional)
111
113
 
112
114
  ```bash
package/dist/src/api.d.ts CHANGED
@@ -1,7 +1,21 @@
1
1
  import type { FetchResult } from './types.js';
2
+ /** Exported for direct unit testing — keeps the UA tied to the real package name/version. */
3
+ export declare function buildUserAgent(): string;
4
+ /**
5
+ * Why these are distinct: the endpoint uses 401 and 403 for unrelated problems
6
+ * and only one of them is fixed by signing in again.
7
+ * unauthorized — the token was rejected (expired/revoked): re-authenticate.
8
+ * not-entitled — the token is fine but the account has no Antigravity
9
+ * license ("You do not have a valid license of this
10
+ * product…"): signing in again changes nothing.
11
+ * no-project — HTTP 200 without `cloudaicompanionProject`.
12
+ * http — anything else (wrong host, 5xx, …).
13
+ */
14
+ export type ApiErrorKind = 'unauthorized' | 'not-entitled' | 'no-project' | 'http';
2
15
  declare class ApiError extends Error {
3
16
  status: number;
4
- constructor(message: string, status: number);
17
+ kind: ApiErrorKind;
18
+ constructor(message: string, status: number, kind?: ApiErrorKind);
5
19
  }
6
20
  export interface FetchOptions {
7
21
  host?: string;
package/dist/src/api.js CHANGED
@@ -8,15 +8,30 @@
8
8
  //
9
9
  // Captured from live agy traffic (mitmproxy). The internal endpoint is undocumented;
10
10
  // the PTY fallback exists for when it changes.
11
- const UA = `antigravity-usage-monitor/0.1 ${process.platform}/${process.arch}`;
11
+ import { currentVersion } from './update.js';
12
+ // The UA is load-bearing, not cosmetic: Cloud Code picks the product from the
13
+ // User-Agent, not from the request body. Without an `antigravity` substring
14
+ // (case-insensitive, position and version format irrelevant) `loadCodeAssist`
15
+ // answers HTTP 200 but omits `cloudaicompanionProject`, so the quota call can
16
+ // never run — sending `ideType: "ANTIGRAVITY"` does not compensate. Dropping
17
+ // that substring is what broke v0.4.5 (#47). Verified against the live daily
18
+ // host on 2026-09-07.
19
+ /** Exported for direct unit testing — keeps the UA tied to the real package name/version. */
20
+ export function buildUserAgent() {
21
+ return `antigravity-agy-cli-usage/${currentVersion()} ${process.platform}/${process.arch}`;
22
+ }
23
+ const UA = buildUserAgent();
12
24
  // Antigravity ships against the "daily" Cloud Code host; stable builds use the
13
25
  // plain host. Try daily first (matches current CLI), fall back to prod.
14
26
  const HOSTS = ['daily-cloudcode-pa.googleapis.com', 'cloudcode-pa.googleapis.com'];
15
27
  class ApiError extends Error {
16
28
  status;
17
- constructor(message, status) {
29
+ kind;
30
+ constructor(message, status, kind) {
18
31
  super(message);
32
+ this.name = 'ApiError';
19
33
  this.status = status;
34
+ this.kind = kind ?? (status === 401 ? 'unauthorized' : status === 403 ? 'not-entitled' : 'http');
20
35
  }
21
36
  }
22
37
  // Known limitation: this is the only email source `loadCodeAssist` exposes —
@@ -71,8 +86,11 @@ export async function fetchQuotaSummary(accessToken, opts = {}) {
71
86
  metadata: { ideType: 'ANTIGRAVITY' },
72
87
  });
73
88
  const project = lca.cloudaicompanionProject;
74
- if (!project)
75
- throw new ApiError('loadCodeAssist returned no cloudaicompanionProject', 0);
89
+ if (!project) {
90
+ throw new ApiError(`loadCodeAssist on ${host} returned HTTP 200 without cloudaicompanionProject — either the ` +
91
+ `request was not recognized as Antigravity (the API reads the product off the User-Agent, ` +
92
+ `which must contain "antigravity") or the signed-in account has no Antigravity entitlement`, 0, 'no-project');
93
+ }
76
94
  const raw = await postInternal(host, accessToken, 'retrieveUserQuotaSummary', { project });
77
95
  return {
78
96
  raw,
@@ -83,8 +101,11 @@ export async function fetchQuotaSummary(accessToken, opts = {}) {
83
101
  }
84
102
  catch (err) {
85
103
  lastErr = err;
86
- // 404 / wrong-host -> try next candidate; auth errors -> stop early.
87
- if (err instanceof ApiError && (err.status === 401 || err.status === 403))
104
+ // 404 / wrong-host -> try next candidate. A rejected token or a missing
105
+ // license is account-level, identical on every host: stop early.
106
+ // `no-project` deliberately keeps going — an account can be entitled on
107
+ // one channel and not the other, and one extra round trip is cheap.
108
+ if (err instanceof ApiError && (err.kind === 'unauthorized' || err.kind === 'not-entitled'))
88
109
  throw err;
89
110
  }
90
111
  }
@@ -1,4 +1,5 @@
1
1
  declare class CredentialError extends Error {
2
+ constructor(message: string);
2
3
  }
3
4
  interface Cred {
4
5
  accessToken: string;
@@ -35,6 +35,10 @@ const KEYRING_SERVICE = 'gemini';
35
35
  const KEYRING_ACCOUNT = 'antigravity';
36
36
  const B64_PREFIX = 'go-keyring-base64:';
37
37
  class CredentialError extends Error {
38
+ constructor(message) {
39
+ super(message);
40
+ this.name = 'CredentialError';
41
+ }
38
42
  }
39
43
  // --- raw keyring read --------------------------------------------------------
40
44
  async function readViaNapiEsm() {
@@ -123,10 +127,18 @@ function readViaWindowsCredman() {
123
127
  }
124
128
  // On headless Linux (no Secret Service) agy persists the token to a plain-JSON
125
129
  // file instead of the keyring. Same payload shape, no `go-keyring-base64:` prefix.
130
+ //
131
+ // The jetski path is last on purpose. macOS keeps that file around next to the
132
+ // Keychain entry, and the two can hold *different* grants — an observed one was
133
+ // three days stale and belonged to a session with no Antigravity license, which
134
+ // still refreshes fine and only fails later at the quota call. Every keyring
135
+ // backend is tried first, so this is reached only where the alternative is no
136
+ // credential at all.
126
137
  function readViaFile() {
127
138
  const candidates = [
128
139
  process.env.AGY_OAUTH_TOKEN_FILE,
129
140
  join(homedir(), '.gemini', 'antigravity-cli', 'antigravity-oauth-token'),
141
+ join(homedir(), '.gemini', 'jetski-standalone-oauth-token'),
130
142
  ].filter((p) => Boolean(p));
131
143
  for (const path of candidates) {
132
144
  try {
package/dist/src/main.js CHANGED
@@ -12,7 +12,7 @@
12
12
  // agy-cli-usage update [--check] self-update via npm
13
13
  // agy-cli-usage --version | -v print the installed version
14
14
  import { getAccessToken, CredentialError } from './credentials.js';
15
- import { fetchQuotaSummary } from './api.js';
15
+ import { fetchQuotaSummary, ApiError } from './api.js';
16
16
  import { captureUsageViaPty } from './pty-fallback.js';
17
17
  import { fromApi, fromPty } from './quota.js';
18
18
  import { renderPanel } from './render.js';
@@ -24,6 +24,15 @@ import { fileURLToPath } from 'node:url';
24
24
  const CACHE_DIR = join(process.env.XDG_CACHE_HOME || join(homedir(), '.cache'), 'agy-usage');
25
25
  const CACHE_FILE = join(CACHE_DIR, 'quota.json');
26
26
  const CACHE_TTL_MS = 5 * 60 * 1000;
27
+ // The API path fails in ways that look alike on the wire but need different
28
+ // things from the user — see ApiErrorKind in api.ts. Printing "sign in again"
29
+ // for a missing license just sends people round a loop that cannot help.
30
+ const API_ERROR_HINTS = {
31
+ unauthorized: 'hint: the stored token was rejected — run `agy` and sign in again.',
32
+ 'not-entitled': 'hint: the token is valid but this Google account has no Antigravity license. Signing in again ' +
33
+ 'will not change that — check which account `agy` is using, or its subscription.',
34
+ 'no-project': 'hint: `--source pty` reads the panel from `agy` itself and does not need this endpoint.',
35
+ };
27
36
  const VALID_SOURCES = ['auto', 'api', 'pty'];
28
37
  const VALID_CHANNELS = ['auto', 'daily', 'prod'];
29
38
  const errMessage = (e) => (e instanceof Error ? e.message : String(e));
@@ -82,6 +91,15 @@ const HELP = `agy-cli-usage — Antigravity CLI (agy) usage/quota monitor
82
91
  agy-cli-usage --no-cache | --refresh
83
92
  agy-cli-usage update [--check] self-update via npm (--check: report only)
84
93
  agy-cli-usage --version | -v
94
+
95
+ Environment variables:
96
+ AGY_OAUTH_TOKEN_FILE Override the token file path (headless fallback).
97
+ AGY_BIN Path to the agy binary (PTY source). Else resolved from
98
+ PATH, then ~/.local/bin.
99
+ XDG_CACHE_HOME Cache base dir (cache lives at
100
+ <base>/agy-usage/quota.json; default ~/.cache).
101
+ NO_COLOR Disable ANSI color in the rendered panel.
102
+ PORT / HOST HTTP server bind (server mode only).
85
103
  `;
86
104
  /** Exported for direct unit testing via an injected `cacheFile` — not part of the CLI's public surface. */
87
105
  export function readCache(source, channel, cacheFile = CACHE_FILE) {
@@ -197,6 +215,9 @@ if (isMainModule()) {
197
215
  }
198
216
  else {
199
217
  process.stderr.write(`error: ${errMessage(err)}\n`);
218
+ const hint = err instanceof ApiError ? API_ERROR_HINTS[err.kind] : undefined;
219
+ if (hint)
220
+ process.stderr.write(`${hint}\n`);
200
221
  }
201
222
  process.exit(1);
202
223
  });
@@ -1,3 +1,3 @@
1
1
  import type { Snapshot } from './types.js';
2
- /** Returns the full panel as a string. */
3
- export declare function renderPanel(snap: Snapshot): string;
2
+ /** Returns the full panel as a string. `nowMs` is injectable for testing. */
3
+ export declare function renderPanel(snap: Snapshot, nowMs?: number): string;
@@ -46,14 +46,40 @@ function bucketLine(b) {
46
46
  }
47
47
  return lines.join('\n');
48
48
  }
49
- /** Returns the full panel as a string. */
50
- export function renderPanel(snap) {
49
+ // `--watch`'s default interval (60s) is faster than the 5-minute quota cache
50
+ // TTL, so most refreshes just re-render a cached snapshot with no visual
51
+ // sign the fetch didn't actually happen. `fetchedAt` is set once, when the
52
+ // data was truly fetched (see quota.ts), and carried through untouched on a
53
+ // cache hit — so comparing it to "now" at render time is enough to tell the
54
+ // two cases apart without threading extra state through Snapshot/the cache.
55
+ // A small skew is tolerated so a genuinely fresh fetch isn't mislabeled
56
+ // "cached" just because rendering happened slightly after fetching.
57
+ const FRESHNESS_SKEW_MS = 3_000;
58
+ function formatAge(totalSeconds) {
59
+ const s = Math.max(0, Math.round(totalSeconds));
60
+ if (s < 60)
61
+ return `${s}s`;
62
+ const h = Math.floor(s / 3600);
63
+ const m = Math.floor((s % 3600) / 60);
64
+ if (h > 0)
65
+ return `${h}h ${m}m`;
66
+ return `${m}m ${s % 60}s`;
67
+ }
68
+ function freshnessSuffix(fetchedAt, nowMs) {
69
+ const ageMs = nowMs - new Date(fetchedAt).getTime();
70
+ if (!Number.isFinite(ageMs) || ageMs < FRESHNESS_SKEW_MS)
71
+ return '';
72
+ return dim(` (cached, refreshed ${formatAge(ageMs / 1000)} ago)`);
73
+ }
74
+ /** Returns the full panel as a string. `nowMs` is injectable for testing. */
75
+ export function renderPanel(snap, nowMs = Date.now()) {
51
76
  const out = [];
52
77
  out.push('');
53
78
  out.push(bold(' Models & Quota'));
54
79
  if (snap.account)
55
80
  out.push(` ${dim('Account:')} ${snap.account}`);
56
- out.push(` ${dim(`source: ${snap.source}${snap.host ? ` · ${snap.host}` : ''} · ${snap.fetchedAt}`)}`);
81
+ out.push(` ${dim(`source: ${snap.source}${snap.host ? ` · ${snap.host}` : ''} · ${snap.fetchedAt}`)}` +
82
+ freshnessSuffix(snap.fetchedAt, nowMs));
57
83
  out.push('');
58
84
  for (const g of snap.groups) {
59
85
  out.push(bold(` ${g.name.toUpperCase()}`));
@@ -9,8 +9,15 @@ export declare function currentVersion(): string;
9
9
  * Returns negative if a<b, 0 if equal, positive if a>b.
10
10
  */
11
11
  export declare function semverCompare(a: string, b: string): number;
12
+ export declare const NPM_VIEW_TIMEOUT_MS = 8000;
13
+ export declare const REGISTRY_FETCH_TIMEOUT_MS = 8000;
14
+ /** Exported for direct unit testing via injection — not part of the public surface. */
15
+ export interface LatestVersionDeps {
16
+ execNpmView?: (pkgName: string, timeoutMs: number) => string;
17
+ fetchRegistry?: (pkgName: string, timeoutMs: number) => Promise<Response>;
18
+ }
12
19
  /** Latest published version: prefer the user's configured registry (npm view), fall back to public. */
13
- export declare function latestVersion(): Promise<string | null>;
20
+ export declare function latestVersion(deps?: LatestVersionDeps): Promise<string | null>;
14
21
  /** Run the update flow. Returns the intended process exit code. */
15
22
  export declare function runUpdate({ checkOnly }?: {
16
23
  checkOnly?: boolean;
@@ -34,26 +34,42 @@ export function semverCompare(a, b) {
34
34
  }
35
35
  return 0;
36
36
  }
37
+ // Both the `npm view` child process and the registry fetch fallback are
38
+ // bounded so a slow/unreachable registry can't hang the update check
39
+ // indefinitely — a timed-out attempt is treated the same as "unavailable"
40
+ // and falls through to the next strategy (or to `latestVersion` returning
41
+ // null, which `runUpdate` reports as "could not determine the latest version").
42
+ export const NPM_VIEW_TIMEOUT_MS = 8_000;
43
+ export const REGISTRY_FETCH_TIMEOUT_MS = 8_000;
44
+ function defaultExecNpmView(pkgName, timeoutMs) {
45
+ return execFileSync('npm', ['view', pkgName, 'version'], {
46
+ encoding: 'utf8',
47
+ stdio: ['ignore', 'pipe', 'ignore'],
48
+ timeout: timeoutMs,
49
+ });
50
+ }
51
+ function defaultFetchRegistry(pkgName, timeoutMs) {
52
+ return fetch(`https://registry.npmjs.org/${pkgName}/latest`, { signal: AbortSignal.timeout(timeoutMs) });
53
+ }
37
54
  /** Latest published version: prefer the user's configured registry (npm view), fall back to public. */
38
- export async function latestVersion() {
55
+ export async function latestVersion(deps = {}) {
56
+ const execNpmView = deps.execNpmView ?? defaultExecNpmView;
57
+ const fetchRegistry = deps.fetchRegistry ?? defaultFetchRegistry;
39
58
  try {
40
- const out = execFileSync('npm', ['view', PKG_NAME, 'version'], {
41
- encoding: 'utf8',
42
- stdio: ['ignore', 'pipe', 'ignore'],
43
- }).trim();
59
+ const out = execNpmView(PKG_NAME, NPM_VIEW_TIMEOUT_MS).trim();
44
60
  if (out)
45
61
  return out;
46
62
  }
47
63
  catch {
48
- // npm missing or offline — try the public registry directly
64
+ // npm missing, offline, or timed out — try the public registry directly
49
65
  }
50
66
  try {
51
- const res = await fetch(`https://registry.npmjs.org/${PKG_NAME}/latest`);
67
+ const res = await fetchRegistry(PKG_NAME, REGISTRY_FETCH_TIMEOUT_MS);
52
68
  if (res.ok)
53
69
  return (await res.json()).version;
54
70
  }
55
71
  catch {
56
- // offline
72
+ // offline or timed out
57
73
  }
58
74
  return null;
59
75
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agy-cli-usage",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
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",
@@ -56,9 +56,9 @@
56
56
  },
57
57
  "license": "MIT",
58
58
  "devDependencies": {
59
- "@eslint/js": "^9.39.4",
60
- "@types/node": "^26.0.0",
61
- "eslint": "^9.39.4",
59
+ "@eslint/js": "^10.0.1",
60
+ "@types/node": "^26.1.2",
61
+ "eslint": "^10.6.0",
62
62
  "typescript": "^6.0.3",
63
63
  "typescript-eslint": "^8.62.1"
64
64
  }