agy-cli-usage 0.3.0 → 0.4.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.
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env node
2
+ // Optional lightweight HTTP endpoint for dashboard integration.
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 dist/src/server.js
7
+ // GET /quota -> normalized snapshot JSON
8
+ // GET /healthz -> { ok: true }
9
+ import { createServer } from 'node:http';
10
+ import { getSnapshot } from './main.js';
11
+ const PORT = Number(process.env.PORT) || 3007;
12
+ 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));
30
+ }
31
+ catch (err) {
32
+ res.writeHead(502, { 'Content-Type': 'application/json' });
33
+ res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
34
+ }
35
+ return;
36
+ }
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
+ });
43
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.js","sourceRoot":"","sources":["../../src/server.ts"],"names":[],"mappings":";AACA,gEAAgE;AAChE,gFAAgF;AAChF,qEAAqE;AACrE,EAAE;AACF,sCAAsC;AACtC,6CAA6C;AAC7C,iCAAiC;AAEjC,OAAO,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AAEzC,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAExC,MAAM,IAAI,GAAG,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,IAAI,CAAC;AAC9C,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,WAAW,CAAC;AAE7C,MAAM,MAAM,GAAG,YAAY,CAAC,KAAK,EAAE,GAAoB,EAAE,GAAmB,EAAE,EAAE;IAC9E,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,GAAG,IAAI,GAAG,EAAE,UAAU,GAAG,CAAC,OAAO,CAAC,IAAI,IAAI,WAAW,EAAE,CAAC,CAAC;IAEjF,IAAI,GAAG,CAAC,QAAQ,KAAK,UAAU,EAAE,CAAC;QAChC,GAAG,CAAC,SAAS,CAAC,GAAG,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE,CAAC,CAAC;QAC3D,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;QACtC,OAAO;IACT,CAAC;IAED,IAAI,GAAG,CAAC,QAAQ,KAAK,QAAQ,EAAE,CAAC;QAC9B,IAAI,CAAC;YACH,MAAM,OAAO,GAAG,GAAG,CAAC,YAAY,CAAC,GAAG,CAAC,SAAS,CAAC,KAAK,GAAG,CAAC;YACxD,MAAM,IAAI,GAAG,MAAM,WAAW,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,OAAO,EAAE,CAAC,CAAC;YACrF,GAAG,CAAC,SAAS,CAAC,GAAG,EAAE;gBACjB,cAAc,EAAE,kBAAkB;gBAClC,6BAA6B,EAAE,GAAG;gBAClC,eAAe,EAAE,qBAAqB;aACvC,CAAC,CAAC;YACH,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC;QAChC,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,GAAG,CAAC,SAAS,CAAC,GAAG,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE,CAAC,CAAC;YAC3D,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;QACvF,CAAC;QACD,OAAO;IACT,CAAC;IAED,GAAG,CAAC,SAAS,CAAC,GAAG,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE,CAAC,CAAC;IAC3D,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,WAAW,EAAE,CAAC,CAAC,CAAC;AAClD,CAAC,CAAC,CAAC;AAEH,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,IAAI,EAAE,GAAG,EAAE;IAC7B,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,8BAA8B,IAAI,IAAI,IAAI,kBAAkB,CAAC,CAAC;AACrF,CAAC,CAAC,CAAC"}
@@ -0,0 +1,67 @@
1
+ export type BucketKind = 'weekly' | '5h' | string;
2
+ export interface RawBucket {
3
+ bucketId?: string;
4
+ displayName?: string;
5
+ window?: string;
6
+ resetTime?: string;
7
+ description?: string;
8
+ remainingFraction?: number;
9
+ }
10
+ export interface RawGroup {
11
+ displayName?: string;
12
+ description?: string;
13
+ buckets?: RawBucket[];
14
+ }
15
+ export interface RawQuotaResponse {
16
+ groups?: RawGroup[];
17
+ description?: string;
18
+ }
19
+ /** Result of api.fetchQuotaSummary. */
20
+ export interface FetchResult {
21
+ raw: RawQuotaResponse;
22
+ host: string | null;
23
+ account: string | null;
24
+ tier: string | null;
25
+ }
26
+ export interface ParsedBucket {
27
+ kind: BucketKind;
28
+ label: string;
29
+ remainingFraction: number | null;
30
+ resetsInSeconds: number | null;
31
+ available: boolean;
32
+ description: string | null;
33
+ }
34
+ export interface ParsedGroup {
35
+ name: string;
36
+ models: string;
37
+ buckets: ParsedBucket[];
38
+ }
39
+ export interface ParsedPanel {
40
+ account: string | null;
41
+ groups: ParsedGroup[];
42
+ note?: string | null;
43
+ }
44
+ export interface Bucket {
45
+ kind: BucketKind;
46
+ label: string;
47
+ remainingFraction: number | null;
48
+ usedFraction: number | null;
49
+ resetAt: string | null;
50
+ resetsInSeconds: number | null;
51
+ available: boolean;
52
+ description: string | null;
53
+ }
54
+ export interface Group {
55
+ name: string;
56
+ models: string;
57
+ buckets: Bucket[];
58
+ }
59
+ export interface Snapshot {
60
+ account: string | null;
61
+ tier: string | null;
62
+ fetchedAt: string;
63
+ source: 'api' | 'pty';
64
+ host: string | null;
65
+ note: string | null;
66
+ groups: Group[];
67
+ }
@@ -0,0 +1,3 @@
1
+ // Shared types for agy-cli-usage.
2
+ export {};
3
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/types.ts"],"names":[],"mappings":"AAAA,kCAAkC"}
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Installed version, read from this package's package.json.
3
+ * NOTE: this module compiles to dist/src/update.js, so package.json (at the
4
+ * package root) is two levels up.
5
+ */
6
+ export declare function currentVersion(): string;
7
+ /**
8
+ * Compare two dotted versions numerically (prerelease tags ignored).
9
+ * Returns negative if a<b, 0 if equal, positive if a>b.
10
+ */
11
+ export declare function semverCompare(a: string, b: string): number;
12
+ /** Latest published version: prefer the user's configured registry (npm view), fall back to public. */
13
+ export declare function latestVersion(): Promise<string | null>;
14
+ /** Run the update flow. Returns the intended process exit code. */
15
+ export declare function runUpdate({ checkOnly }?: {
16
+ checkOnly?: boolean;
17
+ }): Promise<number>;
@@ -0,0 +1,87 @@
1
+ // Self-update + version helpers for the CLI.
2
+ //
3
+ // `agy-cli-usage update` check the registry and `npm install -g` if newer
4
+ // `agy-cli-usage update --check` report only, don't install
5
+ // `agy-cli-usage --version` print the installed version
6
+ import { execFileSync, spawnSync } from 'node:child_process';
7
+ import { readFileSync } from 'node:fs';
8
+ const PKG_NAME = 'agy-cli-usage';
9
+ /**
10
+ * Installed version, read from this package's package.json.
11
+ * NOTE: this module compiles to dist/src/update.js, so package.json (at the
12
+ * package root) is two levels up.
13
+ */
14
+ export function currentVersion() {
15
+ const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
16
+ return pkg.version;
17
+ }
18
+ /**
19
+ * Compare two dotted versions numerically (prerelease tags ignored).
20
+ * Returns negative if a<b, 0 if equal, positive if a>b.
21
+ */
22
+ export function semverCompare(a, b) {
23
+ const norm = (v) => String(v)
24
+ .replace(/^v/, '')
25
+ .split('-')[0]
26
+ .split('.')
27
+ .map((n) => parseInt(n, 10) || 0);
28
+ const pa = norm(a);
29
+ const pb = norm(b);
30
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
31
+ const d = (pa[i] || 0) - (pb[i] || 0);
32
+ if (d !== 0)
33
+ return d;
34
+ }
35
+ return 0;
36
+ }
37
+ /** Latest published version: prefer the user's configured registry (npm view), fall back to public. */
38
+ export async function latestVersion() {
39
+ try {
40
+ const out = execFileSync('npm', ['view', PKG_NAME, 'version'], {
41
+ encoding: 'utf8',
42
+ stdio: ['ignore', 'pipe', 'ignore'],
43
+ }).trim();
44
+ if (out)
45
+ return out;
46
+ }
47
+ catch {
48
+ // npm missing or offline — try the public registry directly
49
+ }
50
+ try {
51
+ const res = await fetch(`https://registry.npmjs.org/${PKG_NAME}/latest`);
52
+ if (res.ok)
53
+ return (await res.json()).version;
54
+ }
55
+ catch {
56
+ // offline
57
+ }
58
+ return null;
59
+ }
60
+ /** Run the update flow. Returns the intended process exit code. */
61
+ export async function runUpdate({ checkOnly = false } = {}) {
62
+ const current = currentVersion();
63
+ const latest = await latestVersion();
64
+ if (!latest) {
65
+ process.stderr.write('Could not determine the latest version (offline or npm unavailable).\n');
66
+ return 1;
67
+ }
68
+ if (semverCompare(latest, current) <= 0) {
69
+ process.stdout.write(`agy-cli-usage is up to date (${current}).\n`);
70
+ return 0;
71
+ }
72
+ process.stdout.write(`Update available: ${current} -> ${latest}\n`);
73
+ if (checkOnly) {
74
+ process.stdout.write('Run `agy-cli-usage update` to install it.\n');
75
+ return 0;
76
+ }
77
+ process.stdout.write(`Installing ${PKG_NAME}@${latest} globally…\n`);
78
+ const r = spawnSync('npm', ['install', '-g', `${PKG_NAME}@${latest}`], { stdio: 'inherit' });
79
+ if (r.error) {
80
+ process.stderr.write(`Failed to run npm: ${r.error.message}\nInstall manually: npm install -g ${PKG_NAME}@latest\n`);
81
+ return 1;
82
+ }
83
+ if (r.status === 0)
84
+ process.stdout.write(`Updated to ${latest}.\n`);
85
+ return r.status ?? 0;
86
+ }
87
+ //# sourceMappingURL=update.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"update.js","sourceRoot":"","sources":["../../src/update.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,24 +1,27 @@
1
1
  {
2
2
  "name": "agy-cli-usage",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
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
+ "types": "dist/src/main.d.ts",
6
7
  "bin": {
7
- "agy-cli-usage": "src/main.js",
8
- "agy-usage": "src/main.js"
8
+ "agy-cli-usage": "dist/src/main.js",
9
+ "agy-usage": "dist/src/main.js"
9
10
  },
10
11
  "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"
12
+ "build": "tsc",
13
+ "check": "tsc --noEmit",
14
+ "pretest": "tsc",
15
+ "test": "node --test dist/test/unit.test.js",
16
+ "start": "tsc && node dist/src/main.js",
17
+ "serve": "tsc && node dist/src/server.js",
18
+ "prepack": "tsc"
15
19
  },
16
20
  "engines": {
17
21
  "node": ">=18"
18
22
  },
19
23
  "files": [
20
- "src/",
21
- "server.js",
24
+ "dist/src/",
22
25
  "README.md",
23
26
  "CHANGELOG.md",
24
27
  "LICENSE"
@@ -49,5 +52,9 @@
49
52
  "optionalDependencies": {
50
53
  "node-pty": "^1.0.0"
51
54
  },
52
- "license": "MIT"
55
+ "license": "MIT",
56
+ "devDependencies": {
57
+ "@types/node": "^26.0.0",
58
+ "typescript": "^6.0.3"
59
+ }
53
60
  }
package/server.js DELETED
@@ -1,48 +0,0 @@
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 DELETED
@@ -1,92 +0,0 @@
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 };
@@ -1,186 +0,0 @@
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
- // 3. File fallback (headless Linux: agy can't reach a keyring
14
- // and writes the token to a plain-JSON file)
15
- // If every backend fails, the caller falls back to the PTY path which drives
16
- // `agy` itself.
17
-
18
- import { execFileSync } from 'node:child_process';
19
- import { readFileSync, existsSync } from 'node:fs';
20
- import { homedir } from 'node:os';
21
- import { join } from 'node:path';
22
-
23
- // OAuth client for the Antigravity CLI. This is an installed/desktop ("public")
24
- // OAuth client: per Google's own docs the client secret of an installed app is
25
- // "obviously not treated as a secret" — it ships inside the agy binary and is
26
- // identical for every user (the per-user identity is the keyring token, not
27
- // this). Verified: the same client_id appears in agy's browser consent URL
28
- // regardless of account, and the flow uses PKCE (code_challenge/S256), the
29
- // mechanism that secures public clients precisely because the secret is public.
30
- // Same pattern as Google's open-source gemini-cli.
31
- const OAUTH_CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
32
- const OAUTH_CLIENT_SECRET = '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';
33
- const TOKEN_URL = 'https://oauth2.googleapis.com/token';
34
-
35
- const KEYRING_SERVICE = 'gemini';
36
- const KEYRING_ACCOUNT = 'antigravity';
37
- const B64_PREFIX = 'go-keyring-base64:';
38
-
39
- class CredentialError extends Error {}
40
-
41
- // --- raw keyring read --------------------------------------------------------
42
-
43
- async function readViaNapiEsm() {
44
- try {
45
- const mod = await import('@napi-rs/keyring');
46
- const Entry = mod.Entry ?? mod.default?.Entry;
47
- if (!Entry) return null;
48
- return new Entry(KEYRING_SERVICE, KEYRING_ACCOUNT).getPassword();
49
- } catch {
50
- return null;
51
- }
52
- }
53
-
54
- function readViaCli() {
55
- try {
56
- if (process.platform === 'darwin') {
57
- return execFileSync(
58
- 'security',
59
- ['find-generic-password', '-s', KEYRING_SERVICE, '-a', KEYRING_ACCOUNT, '-w'],
60
- { encoding: 'utf8' },
61
- ).trim();
62
- }
63
- if (process.platform === 'linux') {
64
- return execFileSync(
65
- 'secret-tool',
66
- ['lookup', 'service', KEYRING_SERVICE, 'account', KEYRING_ACCOUNT],
67
- { encoding: 'utf8' },
68
- ).trim();
69
- }
70
- } catch {
71
- return null;
72
- }
73
- return null;
74
- }
75
-
76
- // On headless Linux (no Secret Service) agy persists the token to a plain-JSON
77
- // file instead of the keyring. Same payload shape, no `go-keyring-base64:` prefix.
78
- function readViaFile() {
79
- const candidates = [
80
- process.env.AGY_OAUTH_TOKEN_FILE,
81
- join(homedir(), '.gemini', 'antigravity-cli', 'antigravity-oauth-token'),
82
- ].filter(Boolean);
83
- for (const path of candidates) {
84
- try {
85
- if (existsSync(path)) {
86
- const content = readFileSync(path, 'utf8').trim();
87
- if (content) return content;
88
- }
89
- } catch {
90
- // unreadable (perms) — try next candidate
91
- }
92
- }
93
- return null;
94
- }
95
-
96
- async function readRawSecret() {
97
- const fromNapi = await readViaNapiEsm();
98
- if (fromNapi) return fromNapi;
99
- const fromCli = readViaCli();
100
- if (fromCli) return fromCli;
101
- const fromFile = readViaFile();
102
- if (fromFile) return fromFile;
103
- return null;
104
- }
105
-
106
- // --- decode ------------------------------------------------------------------
107
-
108
- export function decodeSecret(raw) {
109
- const payload = raw.startsWith(B64_PREFIX)
110
- ? Buffer.from(raw.slice(B64_PREFIX.length), 'base64').toString('utf8')
111
- : raw;
112
- let parsed;
113
- try {
114
- parsed = JSON.parse(payload);
115
- } catch {
116
- throw new CredentialError('Stored agy credential is not valid JSON');
117
- }
118
- const token = parsed.token ?? parsed;
119
- if (!token?.access_token) {
120
- throw new CredentialError('Stored agy credential has no access_token');
121
- }
122
- return {
123
- accessToken: token.access_token,
124
- refreshToken: token.refresh_token,
125
- expiry: token.expiry ? new Date(token.expiry) : null,
126
- authMethod: parsed.auth_method ?? null,
127
- };
128
- }
129
-
130
- // --- refresh -----------------------------------------------------------------
131
-
132
- function isExpired(cred, skewMs = 60_000) {
133
- if (!cred.expiry) return false;
134
- return cred.expiry.getTime() - Date.now() < skewMs;
135
- }
136
-
137
- async function refreshAccessToken(refreshToken) {
138
- const body = new URLSearchParams({
139
- grant_type: 'refresh_token',
140
- refresh_token: refreshToken,
141
- client_id: OAUTH_CLIENT_ID,
142
- client_secret: OAUTH_CLIENT_SECRET,
143
- });
144
- const res = await fetch(TOKEN_URL, {
145
- method: 'POST',
146
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
147
- body: body.toString(),
148
- });
149
- if (!res.ok) {
150
- throw new CredentialError(`Token refresh failed: HTTP ${res.status} ${await res.text()}`);
151
- }
152
- const json = await res.json();
153
- return json.access_token;
154
- }
155
-
156
- // --- public API --------------------------------------------------------------
157
-
158
- /**
159
- * Returns a valid access token for the Cloud Code API, refreshing if needed.
160
- * Throws CredentialError if no credential can be read from any keyring backend
161
- * (the caller should then consider the PTY fallback).
162
- * @returns {Promise<{ accessToken: string, authMethod: string|null }>}
163
- */
164
- export async function getAccessToken() {
165
- const raw = await readRawSecret();
166
- if (!raw) {
167
- throw new CredentialError(
168
- 'Could not read agy credential from the OS keyring or token file. ' +
169
- 'Is agy logged in on this machine? (set AGY_OAUTH_TOKEN_FILE to override the path, ' +
170
- 'or use --source pty)',
171
- );
172
- }
173
- const cred = decodeSecret(raw);
174
- if (isExpired(cred) && cred.refreshToken) {
175
- const fresh = await refreshAccessToken(cred.refreshToken);
176
- return { accessToken: fresh, authMethod: cred.authMethod };
177
- }
178
- return { accessToken: cred.accessToken, authMethod: cred.authMethod };
179
- }
180
-
181
- /** Whether a keyring-based credential is readable at all (no refresh attempted). */
182
- export async function hasCredential() {
183
- return (await readRawSecret()) != null;
184
- }
185
-
186
- export { CredentialError };