agy-cli-usage 0.3.1 → 0.4.1

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.
@@ -16,12 +16,10 @@
16
16
  // and writes the token to a plain-JSON file)
17
17
  // If every backend fails, the caller falls back to the PTY path which drives
18
18
  // `agy` itself.
19
-
20
19
  import { execFileSync } from 'node:child_process';
21
20
  import { readFileSync, existsSync } from 'node:fs';
22
21
  import { homedir } from 'node:os';
23
22
  import { join } from 'node:path';
24
-
25
23
  // OAuth client for the Antigravity CLI. This is an installed/desktop ("public")
26
24
  // OAuth client: per Google's own docs the client secret of an installed app is
27
25
  // "obviously not treated as a secret" — it ships inside the agy binary and is
@@ -33,55 +31,43 @@ import { join } from 'node:path';
33
31
  const OAUTH_CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
34
32
  const OAUTH_CLIENT_SECRET = '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';
35
33
  const TOKEN_URL = 'https://oauth2.googleapis.com/token';
36
-
37
34
  const KEYRING_SERVICE = 'gemini';
38
35
  const KEYRING_ACCOUNT = 'antigravity';
39
36
  const B64_PREFIX = 'go-keyring-base64:';
40
-
41
- class CredentialError extends Error {}
42
-
37
+ class CredentialError extends Error {
38
+ }
43
39
  // --- raw keyring read --------------------------------------------------------
44
-
45
40
  async function readViaNapiEsm() {
46
- try {
47
- const mod = await import('@napi-rs/keyring');
48
- const Entry = mod.Entry ?? mod.default?.Entry;
49
- if (!Entry) return null;
50
- return new Entry(KEYRING_SERVICE, KEYRING_ACCOUNT).getPassword();
51
- } catch {
52
- return null;
53
- }
41
+ try {
42
+ const { Entry } = await import('@napi-rs/keyring');
43
+ if (!Entry)
44
+ return null;
45
+ return new Entry(KEYRING_SERVICE, KEYRING_ACCOUNT).getPassword() ?? null;
46
+ }
47
+ catch {
48
+ return null;
49
+ }
54
50
  }
55
-
56
51
  function readViaCli() {
57
- try {
58
- if (process.platform === 'darwin') {
59
- return execFileSync(
60
- 'security',
61
- ['find-generic-password', '-s', KEYRING_SERVICE, '-a', KEYRING_ACCOUNT, '-w'],
62
- { encoding: 'utf8' },
63
- ).trim();
52
+ try {
53
+ if (process.platform === 'darwin') {
54
+ return execFileSync('security', ['find-generic-password', '-s', KEYRING_SERVICE, '-a', KEYRING_ACCOUNT, '-w'], { encoding: 'utf8' }).trim();
55
+ }
56
+ if (process.platform === 'linux') {
57
+ return execFileSync('secret-tool', ['lookup', 'service', KEYRING_SERVICE, 'account', KEYRING_ACCOUNT], { encoding: 'utf8' }).trim();
58
+ }
64
59
  }
65
- if (process.platform === 'linux') {
66
- return execFileSync(
67
- 'secret-tool',
68
- ['lookup', 'service', KEYRING_SERVICE, 'account', KEYRING_ACCOUNT],
69
- { encoding: 'utf8' },
70
- ).trim();
60
+ catch {
61
+ return null;
71
62
  }
72
- } catch {
73
63
  return null;
74
- }
75
- return null;
76
64
  }
77
-
78
65
  // On Windows, agy stores the token in Credential Manager via Go's
79
66
  // zalando/go-keyring, whose target name is `service:account` ("gemini:antigravity").
80
67
  // @napi-rs/keyring (keyring-rs) uses a different target format and can't find it,
81
68
  // so we read the credential blob directly via the Win32 CredRead API through the
82
69
  // built-in powershell.exe (no extra dependency).
83
70
  const WIN_CRED_TARGET = `${KEYRING_SERVICE}:${KEYRING_ACCOUNT}`;
84
-
85
71
  const PS_READ_CRED = `$ErrorActionPreference='Stop'
86
72
  $sig=@'
87
73
  using System;
@@ -112,140 +98,136 @@ Add-Type -TypeDefinition $sig | Out-Null
112
98
  $b=[CredApi]::Read('${WIN_CRED_TARGET}')
113
99
  if($b -eq $null){ exit 1 }
114
100
  [Console]::Out.Write([Convert]::ToBase64String($b))`;
115
-
116
101
  function readViaWindowsCredman() {
117
- if (process.platform !== 'win32') return null;
118
- try {
119
- const encoded = Buffer.from(PS_READ_CRED, 'utf16le').toString('base64');
120
- const b64 = execFileSync(
121
- 'powershell.exe',
122
- ['-NoProfile', '-NonInteractive', '-EncodedCommand', encoded],
123
- { encoding: 'utf8' },
124
- ).trim();
125
- if (!b64) return null;
126
- const raw = Buffer.from(b64, 'base64');
127
- // go-keyring writes the value as UTF-8; tolerate UTF-16LE just in case.
128
- const utf8 = raw.toString('utf8');
129
- const looksValid = (s) => s.startsWith(B64_PREFIX) || s.trimStart().startsWith('{');
130
- if (looksValid(utf8)) return utf8;
131
- const utf16 = raw.toString('utf16le');
132
- if (looksValid(utf16)) return utf16;
133
- return utf8;
134
- } catch {
135
- return null;
136
- }
102
+ if (process.platform !== 'win32')
103
+ return null;
104
+ try {
105
+ const encoded = Buffer.from(PS_READ_CRED, 'utf16le').toString('base64');
106
+ const b64 = execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', encoded], { encoding: 'utf8' }).trim();
107
+ if (!b64)
108
+ return null;
109
+ const raw = Buffer.from(b64, 'base64');
110
+ // go-keyring writes the value as UTF-8; tolerate UTF-16LE just in case.
111
+ const utf8 = raw.toString('utf8');
112
+ const looksValid = (s) => s.startsWith(B64_PREFIX) || s.trimStart().startsWith('{');
113
+ if (looksValid(utf8))
114
+ return utf8;
115
+ const utf16 = raw.toString('utf16le');
116
+ if (looksValid(utf16))
117
+ return utf16;
118
+ return utf8;
119
+ }
120
+ catch {
121
+ return null;
122
+ }
137
123
  }
138
-
139
124
  // On headless Linux (no Secret Service) agy persists the token to a plain-JSON
140
125
  // file instead of the keyring. Same payload shape, no `go-keyring-base64:` prefix.
141
126
  function readViaFile() {
142
- const candidates = [
143
- process.env.AGY_OAUTH_TOKEN_FILE,
144
- join(homedir(), '.gemini', 'antigravity-cli', 'antigravity-oauth-token'),
145
- ].filter(Boolean);
146
- for (const path of candidates) {
147
- try {
148
- if (existsSync(path)) {
149
- const content = readFileSync(path, 'utf8').trim();
150
- if (content) return content;
151
- }
152
- } catch {
153
- // unreadable (perms) — try next candidate
127
+ const candidates = [
128
+ process.env.AGY_OAUTH_TOKEN_FILE,
129
+ join(homedir(), '.gemini', 'antigravity-cli', 'antigravity-oauth-token'),
130
+ ].filter((p) => Boolean(p));
131
+ for (const path of candidates) {
132
+ try {
133
+ if (existsSync(path)) {
134
+ const content = readFileSync(path, 'utf8').trim();
135
+ if (content)
136
+ return content;
137
+ }
138
+ }
139
+ catch {
140
+ // unreadable (perms) — try next candidate
141
+ }
154
142
  }
155
- }
156
- return null;
143
+ return null;
157
144
  }
158
-
159
145
  async function readRawSecret() {
160
- const fromNapi = await readViaNapiEsm();
161
- if (fromNapi) return fromNapi;
162
- const fromCli = readViaCli();
163
- if (fromCli) return fromCli;
164
- const fromWin = readViaWindowsCredman();
165
- if (fromWin) return fromWin;
166
- const fromFile = readViaFile();
167
- if (fromFile) return fromFile;
168
- return null;
146
+ const fromNapi = await readViaNapiEsm();
147
+ if (fromNapi)
148
+ return fromNapi;
149
+ const fromCli = readViaCli();
150
+ if (fromCli)
151
+ return fromCli;
152
+ const fromWin = readViaWindowsCredman();
153
+ if (fromWin)
154
+ return fromWin;
155
+ const fromFile = readViaFile();
156
+ if (fromFile)
157
+ return fromFile;
158
+ return null;
169
159
  }
170
-
171
160
  // --- decode ------------------------------------------------------------------
172
-
173
161
  export function decodeSecret(raw) {
174
- const payload = raw.startsWith(B64_PREFIX)
175
- ? Buffer.from(raw.slice(B64_PREFIX.length), 'base64').toString('utf8')
176
- : raw;
177
- let parsed;
178
- try {
179
- parsed = JSON.parse(payload);
180
- } catch {
181
- throw new CredentialError('Stored agy credential is not valid JSON');
182
- }
183
- const token = parsed.token ?? parsed;
184
- if (!token?.access_token) {
185
- throw new CredentialError('Stored agy credential has no access_token');
186
- }
187
- return {
188
- accessToken: token.access_token,
189
- refreshToken: token.refresh_token,
190
- expiry: token.expiry ? new Date(token.expiry) : null,
191
- authMethod: parsed.auth_method ?? null,
192
- };
162
+ const payload = raw.startsWith(B64_PREFIX)
163
+ ? Buffer.from(raw.slice(B64_PREFIX.length), 'base64').toString('utf8')
164
+ : raw;
165
+ let parsed;
166
+ try {
167
+ parsed = JSON.parse(payload);
168
+ }
169
+ catch {
170
+ throw new CredentialError('Stored agy credential is not valid JSON');
171
+ }
172
+ const token = (parsed.token ?? parsed);
173
+ const accessToken = token.access_token;
174
+ if (typeof accessToken !== 'string' || !accessToken) {
175
+ throw new CredentialError('Stored agy credential has no access_token');
176
+ }
177
+ const expiry = token.expiry;
178
+ return {
179
+ accessToken,
180
+ refreshToken: typeof token.refresh_token === 'string' ? token.refresh_token : null,
181
+ expiry: typeof expiry === 'string' ? new Date(expiry) : null,
182
+ authMethod: typeof parsed.auth_method === 'string' ? parsed.auth_method : null,
183
+ };
193
184
  }
194
-
195
185
  // --- refresh -----------------------------------------------------------------
196
-
197
186
  function isExpired(cred, skewMs = 60_000) {
198
- if (!cred.expiry) return false;
199
- return cred.expiry.getTime() - Date.now() < skewMs;
187
+ if (!cred.expiry)
188
+ return false;
189
+ return cred.expiry.getTime() - Date.now() < skewMs;
200
190
  }
201
-
202
191
  async function refreshAccessToken(refreshToken) {
203
- const body = new URLSearchParams({
204
- grant_type: 'refresh_token',
205
- refresh_token: refreshToken,
206
- client_id: OAUTH_CLIENT_ID,
207
- client_secret: OAUTH_CLIENT_SECRET,
208
- });
209
- const res = await fetch(TOKEN_URL, {
210
- method: 'POST',
211
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
212
- body: body.toString(),
213
- });
214
- if (!res.ok) {
215
- throw new CredentialError(`Token refresh failed: HTTP ${res.status} ${await res.text()}`);
216
- }
217
- const json = await res.json();
218
- return json.access_token;
192
+ const body = new URLSearchParams({
193
+ grant_type: 'refresh_token',
194
+ refresh_token: refreshToken,
195
+ client_id: OAUTH_CLIENT_ID,
196
+ client_secret: OAUTH_CLIENT_SECRET,
197
+ });
198
+ const res = await fetch(TOKEN_URL, {
199
+ method: 'POST',
200
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
201
+ body: body.toString(),
202
+ });
203
+ if (!res.ok) {
204
+ throw new CredentialError(`Token refresh failed: HTTP ${res.status} ${await res.text()}`);
205
+ }
206
+ const json = (await res.json());
207
+ return json.access_token;
219
208
  }
220
-
221
- // --- public API --------------------------------------------------------------
222
-
223
209
  /**
224
210
  * Returns a valid access token for the Cloud Code API, refreshing if needed.
225
211
  * Throws CredentialError if no credential can be read from any keyring backend
226
212
  * (the caller should then consider the PTY fallback).
227
- * @returns {Promise<{ accessToken: string, authMethod: string|null }>}
228
213
  */
229
214
  export async function getAccessToken() {
230
- const raw = await readRawSecret();
231
- if (!raw) {
232
- throw new CredentialError(
233
- 'Could not read agy credential from the OS keyring or token file. ' +
234
- 'Is agy logged in on this machine? (set AGY_OAUTH_TOKEN_FILE to override the path, ' +
235
- 'or use --source pty)',
236
- );
237
- }
238
- const cred = decodeSecret(raw);
239
- if (isExpired(cred) && cred.refreshToken) {
240
- const fresh = await refreshAccessToken(cred.refreshToken);
241
- return { accessToken: fresh, authMethod: cred.authMethod };
242
- }
243
- return { accessToken: cred.accessToken, authMethod: cred.authMethod };
215
+ const raw = await readRawSecret();
216
+ if (!raw) {
217
+ throw new CredentialError('Could not read agy credential from the OS keyring or token file. ' +
218
+ 'Is agy logged in on this machine? (set AGY_OAUTH_TOKEN_FILE to override the path, ' +
219
+ 'or use --source pty)');
220
+ }
221
+ const cred = decodeSecret(raw);
222
+ if (isExpired(cred) && cred.refreshToken) {
223
+ const fresh = await refreshAccessToken(cred.refreshToken);
224
+ return { accessToken: fresh, authMethod: cred.authMethod };
225
+ }
226
+ return { accessToken: cred.accessToken, authMethod: cred.authMethod };
244
227
  }
245
-
246
228
  /** Whether a keyring-based credential is readable at all (no refresh attempted). */
247
229
  export async function hasCredential() {
248
- return (await readRawSecret()) != null;
230
+ return (await readRawSecret()) != null;
249
231
  }
250
-
251
232
  export { CredentialError };
233
+ //# sourceMappingURL=credentials.js.map
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ import type { Snapshot } from './types.js';
3
+ export interface CliOptions {
4
+ json: boolean;
5
+ watch: number | null;
6
+ source: 'auto' | 'api' | 'pty';
7
+ channel: 'auto' | 'daily' | 'prod';
8
+ cache: boolean;
9
+ command: 'update' | null;
10
+ check: boolean;
11
+ version?: boolean;
12
+ help?: boolean;
13
+ }
14
+ /** Subset of options needed to produce a snapshot (also usable from server.ts). */
15
+ export interface SnapshotOptions {
16
+ source: 'auto' | 'api' | 'pty';
17
+ channel: 'auto' | 'daily' | 'prod';
18
+ cache: boolean;
19
+ }
20
+ export declare function getSnapshot(opts: SnapshotOptions): Promise<Snapshot>;
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env node
2
+ // agy-usage — Antigravity CLI (agy) usage/quota monitor.
3
+ //
4
+ // Usage:
5
+ // agy-usage one-shot panel (like agy's /usage)
6
+ // agy-usage --json machine-readable JSON
7
+ // agy-usage --watch [secs] refresh every N seconds (default 60)
8
+ // agy-usage --source api|pty|auto data source (default auto: api, fall back to pty)
9
+ // agy-usage --channel daily|prod Cloud Code host (default: auto-detect)
10
+ // agy-usage --no-cache bypass the 5-minute cache
11
+ // agy-usage --refresh force a fresh fetch (alias for --no-cache)
12
+ // agy-usage update [--check] self-update via npm
13
+ // agy-usage --version | -v print the installed version
14
+ import { getAccessToken, CredentialError } from './credentials.js';
15
+ import { fetchQuotaSummary } from './api.js';
16
+ import { captureUsageViaPty } from './pty-fallback.js';
17
+ import { fromApi, fromPty } from './quota.js';
18
+ import { renderPanel } from './render.js';
19
+ import { currentVersion, runUpdate } from './update.js';
20
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
21
+ import { homedir } from 'node:os';
22
+ import { join } from 'node:path';
23
+ const CACHE_DIR = join(process.env.XDG_CACHE_HOME || join(homedir(), '.cache'), 'agy-usage');
24
+ const CACHE_FILE = join(CACHE_DIR, 'quota.json');
25
+ const CACHE_TTL_MS = 5 * 60 * 1000;
26
+ const errMessage = (e) => (e instanceof Error ? e.message : String(e));
27
+ function parseArgs(argv) {
28
+ const o = {
29
+ json: false, watch: null, source: 'auto', channel: 'auto', cache: true, command: null, check: false,
30
+ };
31
+ for (let i = 0; i < argv.length; i++) {
32
+ const a = argv[i];
33
+ if (a === 'update' && o.command == null)
34
+ o.command = 'update';
35
+ else if (a === '--json')
36
+ o.json = true;
37
+ else if (a === '--watch') {
38
+ const n = Number(argv[i + 1]);
39
+ if (Number.isFinite(n)) {
40
+ o.watch = n;
41
+ i++;
42
+ }
43
+ else
44
+ o.watch = 60;
45
+ }
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 === '--no-cache' || a === '--refresh')
51
+ o.cache = false;
52
+ else if (a === '--check')
53
+ o.check = true;
54
+ else if (a === '-v' || a === '--version')
55
+ o.version = true;
56
+ else if (a === '-h' || a === '--help')
57
+ o.help = true;
58
+ }
59
+ return o;
60
+ }
61
+ const HELP = `agy-usage — Antigravity CLI (agy) usage/quota monitor
62
+
63
+ agy-usage one-shot panel
64
+ agy-usage --json machine-readable JSON
65
+ agy-usage --watch [secs] auto-refresh (default 60s)
66
+ agy-usage --source <auto|api|pty>
67
+ agy-usage --channel <auto|daily|prod>
68
+ agy-usage --no-cache | --refresh
69
+ agy-usage update [--check] self-update via npm (--check: report only)
70
+ agy-usage --version | -v
71
+ `;
72
+ // --- cache -------------------------------------------------------------------
73
+ function readCache() {
74
+ try {
75
+ const { ts, snap } = JSON.parse(readFileSync(CACHE_FILE, 'utf8'));
76
+ if (Date.now() - ts < CACHE_TTL_MS)
77
+ return snap;
78
+ }
79
+ catch {
80
+ /* no/expired cache */
81
+ }
82
+ return null;
83
+ }
84
+ function writeCache(snap) {
85
+ try {
86
+ mkdirSync(CACHE_DIR, { recursive: true });
87
+ writeFileSync(CACHE_FILE, JSON.stringify({ ts: Date.now(), snap }));
88
+ }
89
+ catch {
90
+ /* cache is best-effort */
91
+ }
92
+ }
93
+ export async function getSnapshot(opts) {
94
+ if (opts.cache && opts.source !== 'pty') {
95
+ const cached = readCache();
96
+ if (cached)
97
+ return cached;
98
+ }
99
+ let snap;
100
+ if (opts.source === 'pty') {
101
+ snap = fromPty(await captureUsageViaPty());
102
+ }
103
+ else {
104
+ try {
105
+ const { accessToken } = await getAccessToken();
106
+ const raw = await fetchQuotaSummary(accessToken, { channel: opts.channel === 'auto' ? undefined : opts.channel });
107
+ snap = fromApi(raw);
108
+ }
109
+ catch (err) {
110
+ if (opts.source === 'api')
111
+ throw err;
112
+ // auto: fall back to PTY
113
+ process.stderr.write(`[api failed: ${errMessage(err)}] falling back to PTY (agy)…\n`);
114
+ snap = fromPty(await captureUsageViaPty());
115
+ }
116
+ }
117
+ writeCache(snap);
118
+ return snap;
119
+ }
120
+ // --- main --------------------------------------------------------------------
121
+ async function once(opts) {
122
+ const snap = await getSnapshot(opts);
123
+ if (opts.json)
124
+ process.stdout.write(JSON.stringify(snap, null, 2) + '\n');
125
+ else
126
+ process.stdout.write(renderPanel(snap) + '\n');
127
+ }
128
+ async function main() {
129
+ const opts = parseArgs(process.argv.slice(2));
130
+ if (opts.help) {
131
+ process.stdout.write(HELP);
132
+ return;
133
+ }
134
+ if (opts.version) {
135
+ process.stdout.write(currentVersion() + '\n');
136
+ return;
137
+ }
138
+ if (opts.command === 'update') {
139
+ process.exit(await runUpdate({ checkOnly: opts.check }));
140
+ }
141
+ if (opts.watch != null) {
142
+ const intervalMs = Math.max(5, opts.watch) * 1000;
143
+ const tick = async () => {
144
+ try {
145
+ if (!opts.json)
146
+ process.stdout.write('\x1b[2J\x1b[H'); // clear screen
147
+ await once(opts);
148
+ }
149
+ catch (err) {
150
+ process.stderr.write(`error: ${errMessage(err)}\n`);
151
+ }
152
+ };
153
+ await tick();
154
+ setInterval(tick, intervalMs);
155
+ }
156
+ else {
157
+ await once(opts);
158
+ }
159
+ }
160
+ main().catch((err) => {
161
+ if (err instanceof CredentialError) {
162
+ process.stderr.write(`credential error: ${err.message}\n`);
163
+ }
164
+ else {
165
+ process.stderr.write(`error: ${errMessage(err)}\n`);
166
+ }
167
+ process.exit(1);
168
+ });
169
+ //# sourceMappingURL=main.js.map
@@ -0,0 +1,5 @@
1
+ import type { ParsedPanel } from './types.js';
2
+ /** Parse the reconstructed /usage screen text into { account, groups:[...] }. */
3
+ export declare function parsePanel(text: string): ParsedPanel;
4
+ /** Run agy, capture /usage, reconstruct + parse the panel. */
5
+ export declare function captureUsageViaPty(): Promise<ParsedPanel>;