@wenathlan/getry 2.0.165 → 2.0.167

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.
Files changed (66) hide show
  1. package/README.md +1 -1
  2. package/accounts.ts +2087 -0
  3. package/antigravity.ts +2171 -0
  4. package/auth.ts +265 -0
  5. package/cli.ts +3 -3
  6. package/constants.ts +950 -0
  7. package/db.ts +810 -0
  8. package/docs/distributionlanes.md +1 -1
  9. package/docs/features.md +1 -2
  10. package/docs/gateway.md +2 -3
  11. package/docs/getry.md +33 -0
  12. package/engine.ts +1535 -0
  13. package/fingerprint.ts +1461 -0
  14. package/gateway.ts +1306 -0
  15. package/gatewaycheck.ts +142 -0
  16. package/intelligence.ts +169 -0
  17. package/json.ts +932 -0
  18. package/labels.ts +262 -0
  19. package/llm.ts +2709 -0
  20. package/models.ts +4017 -0
  21. package/nodeshim.ts +265 -0
  22. package/oauth.ts +2201 -0
  23. package/package.json +580 -225
  24. package/project.ts +1293 -0
  25. package/providers.ts +3238 -0
  26. package/quota.ts +1891 -0
  27. package/recovery.ts +2434 -0
  28. package/request.ts +4496 -0
  29. package/search.ts +1761 -0
  30. package/server.ts +766 -0
  31. package/streaming.ts +4398 -0
  32. package/{tests → test}/cli.test.ts +1 -1
  33. package/{tests → test}/gateway.test.ts +2 -2
  34. package/{tests → test}/gatewayversions.test.ts +1 -1
  35. package/{tests → test}/intelligence.test.ts +1 -1
  36. package/{tests → test}/keyrotation.test.ts +1 -1
  37. package/{tests → test}/onboardingstate.test.ts +1 -1
  38. package/{tests → test}/providerfixtures.ts +12 -12
  39. package/{tests → test}/researchfixtures.ts +1 -1
  40. package/{tests → test}/sandboxmodality.test.ts +1 -1
  41. package/thinking.ts +416 -0
  42. package/types.ts +809 -0
  43. package/utils.ts +263 -0
  44. package/validate.ts +61 -0
  45. package/versionregistry.ts +1325 -0
  46. /package/{tests → test}/debonairlane.test.ts +0 -0
  47. /package/{tests → test}/design.test.ts +0 -0
  48. /package/{tests → test}/family.test.ts +0 -0
  49. /package/{tests → test}/gatewayserver.ts +0 -0
  50. /package/{tests → test}/jsonpolicy.test.ts +0 -0
  51. /package/{tests → test}/llm.test.ts +0 -0
  52. /package/{tests → test}/modelroute.test.ts +0 -0
  53. /package/{tests → test}/promptlibrary.test.ts +0 -0
  54. /package/{tests → test}/providercli.test.ts +0 -0
  55. /package/{tests → test}/providerconstants.test.ts +0 -0
  56. /package/{tests → test}/providerfingerprint.test.ts +0 -0
  57. /package/{tests → test}/providermodels.test.ts +0 -0
  58. /package/{tests → test}/provideroauth.test.ts +0 -0
  59. /package/{tests → test}/routerotation.test.ts +0 -0
  60. /package/{tests → test}/serverauth.test.ts +0 -0
  61. /package/{tests → test}/serverengine.test.ts +0 -0
  62. /package/{tests → test}/serverproperty.test.ts +0 -0
  63. /package/{tests → test}/serverutils.test.ts +0 -0
  64. /package/{tests → test}/streamfallback.test.ts +0 -0
  65. /package/{tests → test}/thinkingladder.test.ts +0 -0
  66. /package/{tests → test}/torturellm.test.ts +0 -0
package/providers.ts ADDED
@@ -0,0 +1,3238 @@
1
+ /**
2
+ * providers — consolidated providers engine logics of the getry family.
3
+ * @module providers
4
+ * @family wave/logicfold2
5
+ * One keyed file per family: the former variant arms (providercli)
6
+ * retired into this seat with every export preserved under the same name.
7
+ */
8
+
9
+ import { getDefaultAccountManager } from "./accounts.js";
10
+ import { ANTIGRAVITY_VERSION_FALLBACK, ANTIGRAVITY_VERSION_MIN_SUPPORTED, CLIENT_METADATA_STRING, CLOUDCODE_BASE_URL, CLOUDCODE_DAILY_BASE, CODE_ASSIST_PATH_MAP, ENDPOINTS, FALLBACK_PROJECT_ID, FILE_PATHS, GEMINI_CLI_OAUTH_CLIENT_ID, GEMINI_CLI_OAUTH_CLIENT_SECRET, GEMINI_CLI_SCOPES, GEMINI_CLI_USER_AGENT, OAUTH_REVOKE_URL, OAUTH_TOKEN_URL, PLUGIN_VERSION, PROJECT_FALLBACK } from "./constants.js";
11
+ import { X_GOOG_API_CLIENT_GEMINI_CLI } from "./fingerprint.js";
12
+ import { ENDPOINT_ORDER, MODELS_2026_CATALOG, DEFAULT_MODEL_ID, MODEL_BY_ID, ModelCatalogEntry, SEARCH_MODEL_PREVIEW, buildGoogleProviderModelConfigs, buildOpenCodeModelDefinitions, detectTargetProvider } from "./models.js";
13
+ import { ANTIGRAVITY_CLI_CLIENT_ID, ANTIGRAVITY_CLI_CLIENT_SECRET, ANTIGRAVITY_CLI_GRPC_VERSION, ANTIGRAVITY_CLI_OAUTH_SCOPES, ANTIGRAVITY_CLI_REDIRECT_OFFICIAL, ANTIGRAVITY_CLI_VERSION_LATEST, GEMINI_OAUTH_CLIENT_ID, GEMINI_OAUTH_CLIENT_SECRET } from "./oauth.js";
14
+ import { ChatEvent, parseEventStream } from "./streaming.js";
15
+ import { DevThinkConfig, DevThinkPaths, VERSION_FALLBACK, atomicWrite, cfgDir, fetchVersionChain, loadConfigRaw, resolveCredential, saveConfigRaw, validateEmail, validateModelId, validateModelIdCharset, validateProjectId, validateSemver } from "@wenathlan/devthink/config";
16
+ import { FETCH_TIMEOUT_MS } from "@wenathlan/devthink/constants";
17
+ import { debugLogger, logsDir, redactSecrets } from "@wenathlan/devthink/core";
18
+ import { osStringRows } from "@wenathlan/devthink/os";
19
+
20
+ /* ===== block: providers ===== */
21
+ /**
22
+ * providers.ts — the chat provider transport of the workbench (root layer).
23
+ *
24
+ * One responsibility: turn a ChatRequest into a provider-specific streaming
25
+ * HTTP call and turn provider answers into a common ChatEvent stream. the
26
+ * PROVIDERS table carries the eleven known provider ids with their base
27
+ * urls, wire protocols (openai / anthropic / google) and the environment
28
+ * variable that feeds their credential — never a literal key. every wire
29
+ * family gets its own header and body builder; errors come back through
30
+ * cleanError which redacts anything credential-shaped before it surfaces.
31
+ * transport retries ride retryDelay backoff (3 attempts, 5xx only).
32
+ */
33
+
34
+ /* the ownership seam of the wave/ownership.getry split: the provider
35
+ * protocol surface and the retry/redact helpers are gateway-domain
36
+ * concerns this owner carries beside the provider transport (they moved
37
+ * with the wave — never duplicated inside the os gateway client), while
38
+ * the os config credential resolver stays the devthink seam the adapter
39
+ * imports through the published specifier. */
40
+
41
+
42
+
43
+ /** Identifies the public wire formats normalized by the gateway. */
44
+ export type ProviderProtocol = "openai" | "anthropic" | "google";
45
+
46
+ /** Returns bounded exponential retry timing for a transient request failure. */
47
+ export function retryDelay(attempt: number, baseMs = 250, maximumMs = 4_000): number {
48
+ return Math.min(maximumMs, baseMs * 2 ** Math.max(0, attempt));
49
+ }
50
+
51
+ /** Removes a bearer value from an error message before it reaches a terminal or browser surface. */
52
+ export function redactProviderError(message: string): string {
53
+ return message.replace(/bearer\s+[^\s]+/gi, "bearer [redacted]").slice(0, 500);
54
+ }
55
+
56
+ export type ChatRole = "system" | "user" | "assistant";
57
+
58
+ export type ChatMessage = {
59
+ role: ChatRole;
60
+ content: string;
61
+ };
62
+
63
+ export type ChatRequest = {
64
+ provider: string;
65
+ model: string;
66
+ messages: ChatMessage[];
67
+ temperature?: number | undefined;
68
+ maxTokens?: number | undefined;
69
+ signal?: AbortSignal | undefined;
70
+ };
71
+
72
+ export type ModelInfo = {
73
+ id: string;
74
+ provider: string;
75
+ contextWindow?: number | undefined;
76
+ supportsStreaming: boolean;
77
+ };
78
+
79
+ type ProviderDefinition = {
80
+ id: string;
81
+ baseUrl?: string;
82
+ protocol: ProviderProtocol;
83
+ env: string;
84
+ };
85
+
86
+ /** The known provider table: id → base url, wire protocol and credential env var. */
87
+ export const PROVIDERS: ProviderDefinition[] = [
88
+ { id: "openai", baseUrl: "https://api.openai.com/v1", protocol: "openai", env: "OPENAI_API_KEY" },
89
+ { id: "zai", baseUrl: "https://api.z.ai/api/paas/v4", protocol: "openai", env: "ZAI_API_KEY" },
90
+ { id: "openrouter", baseUrl: "https://openrouter.ai/api/v1", protocol: "openai", env: "OPENROUTER_API_KEY" },
91
+ { id: "anthropic", baseUrl: "https://api.anthropic.com", protocol: "anthropic", env: "ANTHROPIC_API_KEY" },
92
+ {
93
+ id: "google",
94
+ baseUrl: "https://generativelanguage.googleapis.com/v1beta",
95
+ protocol: "google",
96
+ env: "GEMINI_API_KEY",
97
+ },
98
+ {
99
+ id: "qwen",
100
+ baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
101
+ protocol: "openai",
102
+ env: "QWEN_API_KEY",
103
+ },
104
+ { id: "deepseek", baseUrl: "https://api.deepseek.com", protocol: "openai", env: "DEEPSEEK_API_KEY" },
105
+ { id: "groq", baseUrl: "https://api.groq.com/openai/v1", protocol: "openai", env: "GROQ_API_KEY" },
106
+ { id: "mistral", baseUrl: "https://api.mistral.ai/v1", protocol: "openai", env: "MISTRAL_API_KEY" },
107
+ { id: "xai", baseUrl: "https://api.x.ai/v1", protocol: "openai", env: "XAI_API_KEY" },
108
+ { id: "ollama", protocol: "openai", env: "OLLAMA_API_KEY" },
109
+ { id: "mimo", baseUrl: "https://api.xiaomimimo.com/v1", protocol: "openai", env: "MIMO_API_KEY" },
110
+ ];
111
+
112
+ /** Finds one provider definition by id (case-insensitive).
113
+ * @throws when the provider id is unknown. */
114
+ function findProvider(provider: string): ProviderDefinition {
115
+ const definition = PROVIDERS.find((item) => item.id === provider.toLowerCase());
116
+ if (!definition) throw new Error(`Unknown provider: ${provider}. Use devthink providers.`);
117
+ return definition;
118
+ }
119
+
120
+ /** Joins a base url and a path with exactly one slash between them. */
121
+ function joinUrl(baseUrl: string, path: string): string {
122
+ return `${baseUrl.replace(/\/$/, "")}/${path.replace(/^\//, "")}`;
123
+ }
124
+
125
+ /** Converts one provider error body into a redacted, single-line Error.
126
+ * @throws never — always returns the Error to throw at the call site. */
127
+ function cleanError(body: string, status: number): Error {
128
+ let detail = body.trim();
129
+ try {
130
+ const parsed = JSON.parse(body) as Record<string, unknown>;
131
+ const error = (parsed.error || parsed.message || parsed.detail) as unknown;
132
+ if (typeof error === "string") detail = error;
133
+ if (error && typeof error === "object") detail = JSON.stringify(error);
134
+ } catch {
135
+ detail = body.trim();
136
+ }
137
+ const safe = redactProviderError(detail);
138
+ return new Error(`${status} ${safe || "Provider request failed."}`);
139
+ }
140
+
141
+ /**
142
+ * Fetches with bounded retry: transparent on 5xx and network faults, immediate otherwise.
143
+ *
144
+ * @param input the absolute request url.
145
+ * @param init the fetch initializer (headers, body, signal ride through untouched).
146
+ * @param attempts total attempts (default 3); waits ride `retryDelay` backoff.
147
+ * @returns the last response — caller decides ok/error handling.
148
+ */
149
+ async function fetchWithRetry(input: string, init: RequestInit, attempts = 3): Promise<Response> {
150
+ let lastError: unknown;
151
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
152
+ try {
153
+ const response = await fetch(input, init);
154
+ if (response.ok || response.status < 500 || attempt === attempts) return response;
155
+ await new Promise((resolve) => setTimeout(resolve, retryDelay(attempt - 1)));
156
+ } catch (error) {
157
+ lastError = error;
158
+ if (attempt === attempts) throw error;
159
+ await new Promise((resolve) => setTimeout(resolve, retryDelay(attempt - 1)));
160
+ }
161
+ }
162
+ throw lastError instanceof Error ? lastError : new Error("Provider request failed.");
163
+ }
164
+
165
+ /** Bearer-token header set of the openai wire family (accept stays sse for streaming). */
166
+ function buildOpenAiHeaders(credential: string): HeadersInit {
167
+ return { accept: "text/event-stream", "content-type": "application/json", authorization: `Bearer ${credential}` };
168
+ }
169
+
170
+ /** x-api-key header set of the anthropic wire family, pinned to the 2023-06-01 version. */
171
+ function buildAnthropicHeaders(credential: string): HeadersInit {
172
+ return {
173
+ accept: "text/event-stream",
174
+ "content-type": "application/json",
175
+ "x-api-key": credential,
176
+ "anthropic-version": "2023-06-01",
177
+ };
178
+ }
179
+
180
+ /** Maps the common ChatRequest onto the google generateContent body (systemInstruction + contents + generationConfig). */
181
+ function buildGoogleBody(request: ChatRequest): Record<string, unknown> {
182
+ const system = request.messages
183
+ .filter((message) => message.role === "system")
184
+ .map((message) => ({ text: message.content }));
185
+ const contents = request.messages
186
+ .filter((message) => message.role !== "system")
187
+ .map((message) => ({
188
+ role: message.role === "assistant" ? "model" : "user",
189
+ parts: [{ text: message.content }],
190
+ }));
191
+ return {
192
+ ...(system.length ? { systemInstruction: { parts: system } } : {}),
193
+ contents,
194
+ generationConfig: {
195
+ ...(request.temperature === undefined ? {} : { temperature: request.temperature }),
196
+ ...(request.maxTokens === undefined ? {} : { maxOutputTokens: request.maxTokens }),
197
+ },
198
+ };
199
+ }
200
+
201
+ /** Maps the common ChatRequest onto the anthropic messages body (system folded into its own field). */
202
+ function buildAnthropicBody(request: ChatRequest): Record<string, unknown> {
203
+ const system = request.messages
204
+ .filter((message) => message.role === "system")
205
+ .map((message) => message.content)
206
+ .join("\n\n");
207
+ return {
208
+ model: request.model,
209
+ messages: request.messages.filter((message) => message.role !== "system"),
210
+ max_tokens: request.maxTokens ?? 4096,
211
+ stream: true,
212
+ ...(system ? { system } : {}),
213
+ ...(request.temperature === undefined ? {} : { temperature: request.temperature }),
214
+ };
215
+ }
216
+
217
+ /** Maps the common ChatRequest onto the openai chat/completions body. */
218
+ function buildOpenAiBody(request: ChatRequest): Record<string, unknown> {
219
+ return {
220
+ model: request.model,
221
+ messages: request.messages,
222
+ stream: true,
223
+ ...(request.temperature === undefined ? {} : { temperature: request.temperature }),
224
+ ...(request.maxTokens === undefined ? {} : { max_tokens: request.maxTokens }),
225
+ };
226
+ }
227
+
228
+ /**
229
+ * Lists the known providers with their base urls, protocols and credential env names.
230
+ *
231
+ * @returns defensive copies — callers cannot mutate the table.
232
+ */
233
+ export function listProviders(): ProviderDefinition[] {
234
+ return PROVIDERS.map((provider) => ({ ...provider }));
235
+ }
236
+
237
+ /**
238
+ * Resolves one provider to its concrete endpoint and credential.
239
+ *
240
+ * the base url precedence is config `providers.<id>.baseUrl` → active-provider
241
+ * `config.baseUrl` → the table default; the credential comes from
242
+ * `resolveCredential` (config first, then the provider's env var).
243
+ *
244
+ * @returns the definition plus the resolved baseUrl and credential.
245
+ * @throws when no base url can be derived or no credential is present.
246
+ */
247
+ export function resolveProvider(
248
+ provider: string,
249
+ config: DevThinkConfig,
250
+ paths?: DevThinkPaths,
251
+ ): ProviderDefinition & { baseUrl: string; credential: string } {
252
+ const definition = findProvider(provider);
253
+ const isActive = provider === (config.activeProvider || config.provider);
254
+ const baseUrl =
255
+ config.providers?.[provider]?.baseUrl || (isActive ? config.baseUrl : undefined) || definition.baseUrl;
256
+ if (!baseUrl) throw new Error(`Provider ${provider} requires baseUrl in config or --base-url.`);
257
+ const credential = resolveCredential(provider, config, paths);
258
+ if (!credential)
259
+ throw new Error(`No credential found for ${provider}. Set ${definition.env} or configure it explicitly.`);
260
+ return { ...definition, baseUrl, credential };
261
+ }
262
+
263
+ /**
264
+ * Streams one chat completion through the provider's native wire protocol.
265
+ *
266
+ * @param request the chat request (provider id, model, messages, sampling knobs, optional abort signal).
267
+ * @param config the resolved DevThink config (active provider, base urls, credentials).
268
+ * @param paths the resolved DevThink paths (credential file lookup).
269
+ * @returns an async generator of normalized ChatEvents (deltas, usage, done).
270
+ * @throws a redacted provider Error on non-ok responses after the retry budget.
271
+ */
272
+ export async function streamChat(
273
+ request: ChatRequest,
274
+ config: DevThinkConfig,
275
+ paths?: DevThinkPaths,
276
+ ): Promise<AsyncGenerator<ChatEvent>> {
277
+ const provider = resolveProvider(request.provider, config, paths);
278
+ const url =
279
+ provider.protocol === "google"
280
+ ? `${joinUrl(provider.baseUrl, `models/${encodeURIComponent(request.model)}:streamGenerateContent`)}?alt=sse`
281
+ : joinUrl(provider.baseUrl, provider.protocol === "anthropic" ? "/v1/messages" : "/chat/completions");
282
+ const headers =
283
+ provider.protocol === "google"
284
+ ? { accept: "text/event-stream", "content-type": "application/json", "x-goog-api-key": provider.credential }
285
+ : provider.protocol === "anthropic"
286
+ ? buildAnthropicHeaders(provider.credential)
287
+ : buildOpenAiHeaders(provider.credential);
288
+ const body =
289
+ provider.protocol === "google"
290
+ ? buildGoogleBody(request)
291
+ : provider.protocol === "anthropic"
292
+ ? buildAnthropicBody(request)
293
+ : buildOpenAiBody(request);
294
+ const response = await fetchWithRetry(url, {
295
+ method: "POST",
296
+ headers,
297
+ body: JSON.stringify(body),
298
+ ...(request.signal ? { signal: request.signal } : {}),
299
+ });
300
+ if (!response.ok) throw cleanError(await response.text(), response.status);
301
+ return parseEventStream(response, { provider: provider.id, model: request.model, signal: request.signal });
302
+ }
303
+
304
+ /**
305
+ * Lists the models the provider currently exposes for the resolved credential.
306
+ *
307
+ * @param providerName the provider id from the PROVIDERS table.
308
+ * @param config the resolved DevThink config.
309
+ * @param paths the resolved DevThink paths (credential file lookup).
310
+ * @returns model descriptors (id, provider, context window, streaming support); google reads inputTokenLimit, the openai family reads context_length.
311
+ * @throws a redacted provider Error on non-ok responses after the retry budget.
312
+ */
313
+ export async function listModels(
314
+ providerName: string,
315
+ config: DevThinkConfig,
316
+ paths?: DevThinkPaths,
317
+ ): Promise<ModelInfo[]> {
318
+ const provider = resolveProvider(providerName, config, paths);
319
+ if (provider.protocol === "google") {
320
+ const response = await fetchWithRetry(joinUrl(provider.baseUrl, "models"), {
321
+ headers: { "x-goog-api-key": provider.credential },
322
+ });
323
+ if (!response.ok) throw cleanError(await response.text(), response.status);
324
+ const payload = (await response.json()) as { models?: Array<{ name?: string; inputTokenLimit?: number }> };
325
+ return (payload.models || []).map((model) => ({
326
+ id: (model.name || "").replace(/^models\//, ""),
327
+ provider: provider.id,
328
+ contextWindow: model.inputTokenLimit,
329
+ supportsStreaming: true,
330
+ }));
331
+ }
332
+ const path = provider.protocol === "anthropic" ? "/v1/models" : "/models";
333
+ const headers =
334
+ provider.protocol === "anthropic"
335
+ ? buildAnthropicHeaders(provider.credential)
336
+ : buildOpenAiHeaders(provider.credential);
337
+ const response = await fetchWithRetry(joinUrl(provider.baseUrl, path), { headers });
338
+ if (!response.ok) throw cleanError(await response.text(), response.status);
339
+ const payload = (await response.json()) as { data?: Array<{ id?: string; context_length?: number }> };
340
+ return (payload.data || [])
341
+ .filter((model) => model.id)
342
+ .map((model) => ({
343
+ id: model.id as string,
344
+ provider: provider.id,
345
+ contextWindow: model["context_length"],
346
+ supportsStreaming: true,
347
+ }));
348
+ }
349
+
350
+ /* ===== block: providercli ===== */
351
+ /**
352
+ * @file providercli.ts
353
+ * @module gateway/providercli
354
+ * @description
355
+ * The provider account console of the ownership wave (wave/ownership.gateway,
356
+ * step 5 — the final double move): moved verbatim from devthink/server.ts
357
+ * (the grand-merge interned provider account console cli, its library
358
+ * barrel and the opencode server plugin entries), so the gateway owns its
359
+ * provider console family — one owner, the family consumes, and devthink
360
+ * re-exports the public faces for the compat consumers.
361
+ *
362
+ * Unified merge of all duplicate CLI generations into a single modular entry
363
+ * point. Zero external dependencies (only node:* builtins + global fetch).
364
+ *
365
+ * Merge provenance (every unique feature preserved):
366
+ * - BASE cli.ts (=cli(1).ts): modular command CLI with commands
367
+ * login / accounts / quota / config / models / status / doctor /
368
+ * version / interactive menu. Secure atomic writes (chmod 600),
369
+ * multi-account management, quota thresholds via config.ts.
370
+ * - FROM cli(2).ts (=cli(3).ts):
371
+ * logoutFlow + top-level logout command (OAuth token revocation +
372
+ * account removal, --all support, interactive picker);
373
+ * enableAccountFlow / disableAccountFlow as top-level commands;
374
+ * MODELS_2026_CATALOG (2026 antigravity model catalog);
375
+ * buildOpenCodeModelDefinitions() (rich provider model defs with
376
+ * bypass metadata); configureModelsFlow() writing
377
+ * provider.google.models (+ provider.antigravity.models and
378
+ * top-level model shortcuts) into opencode.json with --global /
379
+ * --path resolution; locateOpenCodeJson() +
380
+ * resolveOpenCodeJsonCandidates(); atomicWriteFileAtomic()
381
+ * (fs/promises); resolveAntigravityJsonPath();
382
+ * helpers confirm / redactEmail / fmtDuration / printHeader.
383
+ * v2.1.16: the MODELS_2026_CATALOG data moved to models.ts (single
384
+ * owner); the dead sync atomic writer twin was deleted.
385
+ * - FROM cli(4).ts: richest account listing (store version, activeIndex,
386
+ * activeIndexByFamily, addedAt/createdAt/expiry/projectId,
387
+ * remaining/limit, rateLimitResetTimes per account); quota results
388
+ * persisted back into the accounts file; diagnostics dumping the
389
+ * first 1000 chars of the raw accounts file.
390
+ * - FROM cli(5).ts: manage command (interactive per-account enable/disable
391
+ * toggle via auth.js accountManager.enable(email,bool)); login mode
392
+ * selection "1 add accounts / 2 fresh start (wipe all)";
393
+ * claude-sonnet low/high thinkingLevel variants in configure
394
+ * output; soft-quota (softQuotaUntil) display in account listing;
395
+ * debugLogger audit log on login; diagnostics reporting the logs
396
+ * dir and the default model.
397
+ * - cli(6).ts contained nothing unique (strict subset) - deleted.
398
+ *
399
+ * Sibling modules are consumed from their one owner only:
400
+ * ./oauth.js (the provider oauth family) ./accounts.js ./quota.js ./models.js
401
+ * ./versionregistry.js (in-package relatives since the ownership move — the
402
+ * cross-package hop died with it)
403
+ * @wenathlan/devthink/config (v2.1.14: validate.ts + system.ts consolidated
404
+ * into it) @wenathlan/devthink/constants @wenathlan/devthink/core — the
405
+ * os-local families stay at the os root behind the published specifiers
406
+ *
407
+ * The move's sanctioned changes (the step 5 list):
408
+ * - the getry faces the console consumed through the published specifiers
409
+ * (@wenathlan/getry/oauth and siblings) answer from in-package relatives
410
+ * now (./oauth.js, ./quota.js, ./models.js, ...) — the cross-package hop
411
+ * died with the move
412
+ * - the os-local families (config, core, constants, oslabels) answer from
413
+ * the published devthink specifiers (@wenathlan/devthink/config and
414
+ * siblings) — the same resolution they always had
415
+ * - the getAccountManager() fallback twin was DELETED (~160 lines): every
416
+ * call site answers getDefaultAccountManager({ disableBackgroundTimer:
417
+ * true }) from ./accounts.js — the twin raced the real manager on the
418
+ * same antigravity-accounts.json file
419
+ * - maskToken (the provider one, slice(0,6)) renamed maskProviderToken —
420
+ * the shadow-kill (the getry auth family owns masktoken; behavior kept)
421
+ * - buildGoogleProviderModelConfigs + buildOpenCodeModelDefinitions +
422
+ * detectTargetProvider moved to the models owner (models.ts, the
423
+ * "the opencode model definitions" section); this console imports them
424
+ * from ./models.js
425
+ * - providerServer/googleAuth/antigravityAuth merged into the antigravity
426
+ * owner (antigravity.ts) and re-exported here for compat
427
+ * - the oauth client pair mirror died: GEMINI_OAUTH_CLIENT_ID/SECRET
428
+ * import from ./oauth.js (the owner's CLIENT_ID/CLIENT_SECRET) under
429
+ * the historical call-site names
430
+ * - the dead invokedAsMain autorun block stayed dead in the merged file
431
+ * (its basename test never matched server.ts and its main() reference
432
+ * rode the composed boot) — it was dropped with the move instead of
433
+ * carrying a binding that no longer exists here
434
+ *
435
+ * Bypass constants (documented):
436
+ * client_id=681255809395-oo8f…b135j (.apps.googleusercontent.com)
437
+ * secret=GOCSPX-4uHgMPm…lXFsxl
438
+ * UA=GeminiCLI/0.57.0 (linux; x64; GitHub) google-api-nodejs-client/9.15.1
439
+ * X-Goog-Api-Client=gl-node/22.19.0
440
+ * Client-Metadata=ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI
441
+ * project fallback=rising-fact-p41fc
442
+ * isolated dir=~/.config/opencode (override with OPENCODE_CONFIG_DIR)
443
+ *
444
+ * @author ONDA 5 - Finalization (merge pass)
445
+ * @license MIT
446
+ * @version 6.0.0
447
+ */
448
+
449
+ import * as crypto from "node:crypto";
450
+ import * as fs from "node:fs";
451
+ import * as fsp from "node:fs/promises";
452
+ import * as os from "node:os";
453
+ import * as path from "node:path";
454
+
455
+ import * as readline from "node:readline/promises";
456
+ import * as accountsMod from "./accounts.js";
457
+ import * as configMod from "@wenathlan/devthink/config";
458
+ import * as constantsMod from "@wenathlan/devthink/constants";
459
+ // v2.1.16 single-owner fix: raw values come from constants.js, identity
460
+ // snapshots from fingerprint.js and model identification from models.js via
461
+ // static named imports (the namespace interop wrappers below stay for the
462
+ // historical CJS/ESM fallback chains — the local literal copies are gone).
463
+
464
+
465
+ import * as systemMod from "@wenathlan/devthink/core";
466
+
467
+
468
+ import * as modelsMod from "./models.js";
469
+
470
+ // ---------------------------------------------------------------------------
471
+ // Local module imports (ESM .js extension for Node resolver, source is .ts).
472
+ // Import from deduplicated base modules ONLY - never from "(N)" copies.
473
+ // (the ownership move: the getry faces answer from in-package relatives)
474
+ // ---------------------------------------------------------------------------
475
+ import * as oauthMod from "./oauth.js";
476
+ import * as authMod from "./oauth.js";
477
+ import * as quotaMod from "./quota.js";
478
+ import * as versionMod from "./versionregistry.js";
479
+ // (the twin deletion) the accounts owner's real manager answers every call
480
+ // site the fallback twin used to race
481
+
482
+ // (the model transforms) the opencode model definitions moved to the models
483
+ // owner — the configure flow and the export barrel speak them from there
484
+
485
+ // (the oauth mirror kill) the client pair imports from the oauth owner under
486
+ // the historical call-site names
487
+
488
+
489
+ // Resolve interop (CJS/ESM dual): pick default if exists or namespace
490
+ const oauth: any = (oauthMod as any).default ?? oauthMod;
491
+ const accountsPkg: any = (accountsMod as any).default ?? accountsMod;
492
+ const configPkg: any = (configMod as any).default ?? configMod;
493
+ const constantsPkg: any = (constantsMod as any).default ?? constantsMod;
494
+ const quotaPkg: any = (quotaMod as any).default ?? quotaMod;
495
+ const versionPkg: any = (versionMod as any).default ?? versionMod;
496
+ const authPkg: any = (authMod as any).default ?? authMod;
497
+ const modelsPkg: any = (modelsMod as any).default ?? modelsMod;
498
+ const systemPkg: any = (systemMod as any).default ?? systemMod;
499
+
500
+ // Destructure needed APIs with fallback to namespace exports
501
+ const authenticate = oauth.authenticate as typeof import("./oauth.js").authenticate;
502
+ // v2.1.15 Phase A: ALL_MODELS_2026 lives in models.ts (model identification
503
+ // is owned by models.ts alone).
504
+ // v2.1.16 single-owner fix: the last-resort fallback entry is derived from
505
+ // models.js — a MODEL_BY_ID lookup keyed by the models.js DEFAULT_MODEL_ID
506
+ // ("antigravity-gemini-3-pro", whose catalog record carries the very same
507
+ // name/context/output/api/quotaGroup the former local literal spelled out).
508
+ const MODELS_FALLBACK_ENTRY = MODEL_BY_ID[DEFAULT_MODEL_ID];
509
+ const ALL_MODELS_FALLBACK: ReadonlyArray<any> =
510
+ (modelsMod.ALL_MODELS_2026 as any) ??
511
+ (modelsPkg.ALL_MODELS_2026 as any) ??
512
+ (MODELS_FALLBACK_ENTRY ? [MODELS_FALLBACK_ENTRY] : []);
513
+
514
+ const loadConfig = (configMod.loadConfig as any) ?? (configPkg.loadConfig as any);
515
+ // grand-merge rename: the provider saveConfig collided with devthink's root
516
+ // saveConfig in merged config.ts and was renamed agsaveConfig — reference the
517
+ // renamed export (the provider config writer), not devthink's saveConfig.
518
+ const providerSaveConfig = (configMod.agsaveConfig as any) ?? (configPkg.agsaveConfig as any);
519
+ const updateConfig = (configMod.updateConfig as any) ?? (configPkg.updateConfig as any);
520
+ const DEFAULT_CONFIG = (configMod.DEFAULT_CONFIG as any) ?? (configPkg.DEFAULT_CONFIG as any);
521
+ const parseAndValidateConfig = (configMod.parseAndValidateConfig as any) ?? (configPkg.parseAndValidateConfig as any);
522
+ const getConfigPath = (configMod.getConfigPath as any) ?? (() => path.join(resolveBaseDir(), "antigravity.json"));
523
+
524
+ const AccountManagerClass = (accountsMod.AccountManager as any) ?? (accountsPkg as any);
525
+ // constants
526
+ const ALL_MODELS_2026: ReadonlyArray<any> = (modelsMod.ALL_MODELS_2026 as any) ?? ALL_MODELS_FALLBACK;
527
+ // the FILE_PATHS import is the real constants export (the fold alias retired)
528
+ // v2.1.16 single-owner fix: the fallback literal comes from constants.js
529
+ // (PROJECT_FALLBACK owner) instead of a local copy.
530
+ // the FALLBACK_PROJECT_ID import is the real constants export (the fold alias retired)
531
+ const PLUGIN_VERSION_FALLBACK = PLUGIN_VERSION;
532
+ // Ported from cli(2).ts: antigravity UA/version fallbacks (constants first, models.ts backup)
533
+ // v2.1.16 single-owner fix: the "1.19.2" literal comes from constants.js
534
+ // (ANTIGRAVITY_VERSION_FALLBACK owner) instead of a local copy.
535
+ const ANTIGRAVITY_VER_FB: string = (constantsMod as any).ANTIGRAVITY_VERSION_FALLBACK ?? ANTIGRAVITY_VERSION_FALLBACK;
536
+ const ANTIGRAVITY_UA_FB: string =
537
+ (constantsMod as any).ANTIGRAVITY_USER_AGENT_FALLBACK ?? `antigravity/${ANTIGRAVITY_VER_FB}`;
538
+
539
+ // quota
540
+ const QuotaManager = (quotaMod.QuotaManager as any) ?? (quotaPkg.QuotaManager as any);
541
+ const getDefaultQuotaManager = (quotaMod.getDefaultQuotaManager as any) ?? null;
542
+ const checkQuotaFn = (quotaMod.checkQuota as any) ?? null;
543
+ const getQuotaGroupsFn = (quotaMod.getQuotaGroups as any) ?? null;
544
+
545
+ // version
546
+ const getVersion = (versionMod.getVersion as any) ?? (async () => FALLBACK_PROJECT_ID);
547
+ // v2.1.16 single-owner fix: the "1.15.8" hardcode comes from constants.js
548
+ // (ANTIGRAVITY_VERSION_MIN_SUPPORTED owner) instead of a local literal.
549
+ const getVersionInfo =
550
+ (versionMod.getVersionInfo as any) ??
551
+ (async () => ({ version: ANTIGRAVITY_VERSION_MIN_SUPPORTED, source: "hardcoded" }));
552
+
553
+ // auth.js (v3 robin-hood account manager + direct retrieveQuota) - consumers ported from cli(4)/cli(5)
554
+ const AuthAccountManager = (authMod.accountManager as any) ?? (authPkg.accountManager as any) ?? null;
555
+ const authRetrieveQuota = (authMod.retrieveQuota as any) ?? (authPkg.retrieveQuota as any) ?? null;
556
+
557
+ // models.js (flat 2026-08-25 catalog driving provider.google.models config)
558
+ const FLAT_MODELS_2026: any[] = (modelsMod.MODELS_2026_08_25 as any) ?? (modelsPkg.MODELS_2026_08_25 as any) ?? [];
559
+ // v2.1.16 single-owner fix: the endpoint URLs of the defensive fallback
560
+ // object come from constants.js (ENDPOINTS raw host-map owner).
561
+ const CODE_ASSIST = (modelsMod.CODE_ASSIST_ENDPOINTS as any) ??
562
+ (modelsPkg.CODE_ASSIST_ENDPOINTS as any) ?? {
563
+ PROD: ENDPOINTS.PROD,
564
+ DAILY: ENDPOINTS.DAILY,
565
+ SANDBOX: ENDPOINTS.SANDBOX,
566
+ };
567
+ // v2.1.16 single-owner fix: DEFAULT_MODEL_ID answers only from models.js
568
+ // (the owner of the name) — the former local literal retired into the import.
569
+
570
+ // system.js (debug logger singleton + logs dir) - audit trail on login (cli(5) feature)
571
+ const debugLoggerImpl: any = (systemMod.debugLogger as any) ?? (systemPkg.debugLogger as any) ?? null;
572
+ const logsDirImpl: any = (systemMod.logsDir as any) ?? (systemPkg.logsDir as any) ?? null;
573
+
574
+ /** Append a redacted line to the rotating debug audit log (best-effort). */
575
+ function auditLog(msg: string): void {
576
+ try {
577
+ debugLoggerImpl?.get?.().log(msg);
578
+ } catch {
579
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
580
+ }
581
+ }
582
+
583
+ /** Resolve the antigravity logs directory (system.js logsDir with local fallback). */
584
+ function resolveLogsDir(): string {
585
+ try {
586
+ if (typeof logsDirImpl === "function") return logsDirImpl();
587
+ } catch {
588
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
589
+ }
590
+ return path.join(resolveBaseDir(), "antigravity-logs");
591
+ }
592
+
593
+ // oauth.js extras used by the ported model-definition builder (cli(2))
594
+ const OAUTH_SUPPORTED_MODELS: readonly string[] = (oauthMod.SUPPORTED_MODELS_2026 as any) ?? [];
595
+
596
+ // ===========================================================================
597
+ // 00. ANSI COLORS - zero dep
598
+ // ===========================================================================
599
+ const ANSI = {
600
+ reset: "\x1b[0m",
601
+ bold: "\x1b[1m",
602
+ dim: "\x1b[2m",
603
+ italic: "\x1b[3m",
604
+ underline: "\x1b[4m",
605
+ inverse: "\x1b[7m",
606
+ hidden: "\x1b[8m",
607
+ // foreground
608
+ black: "\x1b[30m",
609
+ red: "\x1b[31m",
610
+ green: "\x1b[32m",
611
+ yellow: "\x1b[33m",
612
+ blue: "\x1b[34m",
613
+ magenta: "\x1b[35m",
614
+ cyan: "\x1b[36m",
615
+ white: "\x1b[37m",
616
+ gray: "\x1b[90m",
617
+ brightRed: "\x1b[91m",
618
+ brightGreen: "\x1b[92m",
619
+ brightYellow: "\x1b[93m",
620
+ brightBlue: "\x1b[94m",
621
+ brightMagenta: "\x1b[95m",
622
+ brightCyan: "\x1b[96m",
623
+ brightWhite: "\x1b[97m",
624
+ // background
625
+ bgBlack: "\x1b[40m",
626
+ bgRed: "\x1b[41m",
627
+ bgGreen: "\x1b[42m",
628
+ bgYellow: "\x1b[43m",
629
+ bgBlue: "\x1b[44m",
630
+ };
631
+
632
+ function colorize(s: string, ...codes: string[]): string {
633
+ if (!s) return s;
634
+ // disable colors if NO_COLOR is set
635
+ if (process.env.NO_COLOR) return s;
636
+ return `${codes.join("")}${s}${ANSI.reset}`;
637
+ }
638
+ const C = {
639
+ bold: (s: string) => colorize(s, ANSI.bold),
640
+ dim: (s: string) => colorize(s, ANSI.dim),
641
+ red: (s: string) => colorize(s, ANSI.red),
642
+ green: (s: string) => colorize(s, ANSI.green),
643
+ yellow: (s: string) => colorize(s, ANSI.yellow),
644
+ cyan: (s: string) => colorize(s, ANSI.cyan),
645
+ magenta: (s: string) => colorize(s, ANSI.magenta),
646
+ blue: (s: string) => colorize(s, ANSI.blue),
647
+ gray: (s: string) => colorize(s, ANSI.gray),
648
+ white: (s: string) => colorize(s, ANSI.white),
649
+ b: (s: string) => colorize(s, ANSI.bold),
650
+ bgGreen: (s: string) => colorize(s, ANSI.bgGreen, ANSI.black),
651
+ bgRed: (s: string) => colorize(s, ANSI.bgRed, ANSI.white),
652
+ };
653
+
654
+ // Unified logger (plain-text markers instead of emoji glyphs)
655
+ function logInfo(msg: string) {
656
+ console.log(`${C.cyan("[i]")} ${msg}`);
657
+ }
658
+ function logOk(msg: string) {
659
+ console.log(`${C.green("[ok]")} ${msg}`);
660
+ }
661
+ function logWarn(msg: string) {
662
+ console.log(`${C.yellow("[!]")} ${msg}`);
663
+ }
664
+ function logErr(msg: string) {
665
+ console.error(`${C.red("[x]")} ${msg}`);
666
+ }
667
+ function logDim(msg: string) {
668
+ console.log(C.dim(msg));
669
+ }
670
+
671
+ // ===========================================================================
672
+ // 00B. SHARED INTERACTION + FORMAT HELPERS (ported from cli(2).ts)
673
+ // ===========================================================================
674
+
675
+ /**
676
+ * Ask one question on a promises-style readline interface and return the
677
+ * trimmed answer.
678
+ */
679
+ async function askQuestion(rl: readline.Interface, prompt: string): Promise<string> {
680
+ const ans = await rl.question(prompt);
681
+ return String(ans ?? "").trim();
682
+ }
683
+
684
+ /**
685
+ * Yes/no confirmation prompt (ported from cli(2).ts).
686
+ * Empty input returns {@link defaultYes}.
687
+ */
688
+ async function confirm(rl: readline.Interface, prompt: string, defaultYes = false): Promise<boolean> {
689
+ const suffix = defaultYes ? " [Y/n] " : " [y/N] ";
690
+ const ans = await askQuestion(rl, `${prompt}${suffix}`);
691
+ if (!ans) return defaultYes;
692
+ return /^(y|yes)$/i.test(ans);
693
+ }
694
+
695
+ /**
696
+ * Human friendly duration formatter (ported from cli(2).ts).
697
+ * Examples: "now", "42s", "3m 12s", "5h 03m", "2d 7h".
698
+ */
699
+ function fmtDuration(ms: number): string {
700
+ if (ms <= 0) return "now";
701
+ const s = Math.floor(ms / 1000);
702
+ if (s < 60) return `${s}s`;
703
+ const m = Math.floor(s / 60);
704
+ if (m < 60) return `${m}m ${s % 60}s`;
705
+ const h = Math.floor(m / 60);
706
+ if (h < 24) return `${h}h ${m % 60}m`;
707
+ const d = Math.floor(h / 24);
708
+ return `${d}d ${h % 24}h`;
709
+ }
710
+
711
+ /**
712
+ * Email redaction hook for logs/table output (ported from cli(2).ts).
713
+ * The operator-facing CLI intentionally shows the full address for UX;
714
+ * swap the implementation here to redact the local part when needed.
715
+ */
716
+ function redactEmail(email: string): string {
717
+ return email;
718
+ }
719
+
720
+ /**
721
+ * Boxed CLI banner printed by the ported flows (login/logout/configure/
722
+ * diagnostics style headers from cli(2).ts).
723
+ */
724
+ function printHeader(): void {
725
+ console.log(
726
+ C.bold(C.cyan("\n+---------------------------------------------------------+")) +
727
+ `\n${C.bold(C.cyan("|"))} ${C.bold("devthink")} ${C.dim(`v${CLI_VERSION}`)} - unified CLI ${C.bold(C.cyan("|"))}` +
728
+ `\n${C.bold(C.cyan("|"))} ${C.dim("direct cloudcode-pa.googleapis.com, no localhost v1 proxy")} ${C.bold(C.cyan("|"))}` +
729
+ `\n${C.bold(C.cyan("+---------------------------------------------------------+"))}\n`,
730
+ );
731
+ }
732
+
733
+ // ===========================================================================
734
+ // 01. PATHS & SECURE FILE UTILS - atomic + 600
735
+ // ===========================================================================
736
+ function resolveBaseDir(): string {
737
+ const env = process.env.OPENCODE_CONFIG_DIR?.trim();
738
+ if (env && env.length > 0) return path.resolve(env);
739
+ return path.join(os.homedir(), ".config", "opencode");
740
+ }
741
+ function resolveAccountsPath(): string {
742
+ try {
743
+ const fp = (FILE_PATHS as any).authDir ? path.join((FILE_PATHS as any).baseDir, "antigravity-accounts.json") : null;
744
+ if (fp) return fp;
745
+ } catch {
746
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
747
+ }
748
+ return path.join(resolveBaseDir(), "antigravity-accounts.json");
749
+ }
750
+ function resolveConfigPath(): string {
751
+ try {
752
+ if (typeof getConfigPath === "function") return getConfigPath();
753
+ } catch {
754
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
755
+ }
756
+ return path.join(resolveBaseDir(), "antigravity.json");
757
+ }
758
+ function ensureDirExists(dir: string) {
759
+ try {
760
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
761
+ } catch {
762
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
763
+ }
764
+ try {
765
+ fs.chmodSync(dir, 0o700);
766
+ } catch {
767
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
768
+ }
769
+ }
770
+ function atomicWriteFileSecure(filePath: string, content: string, mode = 0o600): void {
771
+ const dir = path.dirname(filePath);
772
+ ensureDirExists(dir);
773
+ const tmp = `${filePath}.tmp.${crypto.randomBytes(6).toString("hex")}.${process.pid}`;
774
+ try {
775
+ fs.writeFileSync(tmp, content, { encoding: "utf8", mode });
776
+ try {
777
+ fs.chmodSync(tmp, mode);
778
+ } catch {
779
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
780
+ }
781
+ fs.renameSync(tmp, filePath);
782
+ try {
783
+ fs.chmodSync(filePath, mode);
784
+ } catch {
785
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
786
+ }
787
+ } catch (e) {
788
+ try {
789
+ fs.unlinkSync(tmp);
790
+ } catch {
791
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
792
+ }
793
+ throw e;
794
+ }
795
+ }
796
+ function readJsonSafe<T>(filePath: string, fallback: T): T {
797
+ try {
798
+ if (!fs.existsSync(filePath)) return fallback;
799
+ try {
800
+ fs.chmodSync(filePath, 0o600);
801
+ } catch {
802
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
803
+ }
804
+ const raw = fs.readFileSync(filePath, "utf8");
805
+ return JSON.parse(raw) as T;
806
+ } catch {
807
+ return fallback;
808
+ }
809
+ }
810
+ function maskProviderToken(t: string): string {
811
+ if (!t) return "***";
812
+ if (t.length <= 12) return "***";
813
+ return `${t.slice(0, 6)}...${t.slice(-4)}`;
814
+ }
815
+ function formatDate(ms?: number): string {
816
+ if (!ms) return "-";
817
+ try {
818
+ return new Date(ms).toISOString().replace("T", " ").slice(0, 19);
819
+ } catch {
820
+ return String(ms);
821
+ }
822
+ }
823
+
824
+ // ---------------------------------------------------------------------------
825
+ // 01B. ASYNC ATOMIC WRITERS (ported from cli(2).ts)
826
+ // ---------------------------------------------------------------------------
827
+
828
+ /** mkdir recursive + chmod, promise flavored (never throws). */
829
+ async function ensureDirAsync(dir: string): Promise<void> {
830
+ try {
831
+ await fsp.mkdir(dir, { recursive: true, mode: 0o700 });
832
+ } catch {
833
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
834
+ }
835
+ try {
836
+ await fsp.chmod(dir, 0o700);
837
+ } catch {
838
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
839
+ }
840
+ }
841
+
842
+ /**
843
+ * Atomic write using node:fs/promises (ported from cli(2).ts):
844
+ * temp file in the same directory with random suffix, chmod 600, rename.
845
+ */
846
+ async function atomicWriteFileAtomic(filePath: string, content: string, mode = 0o600): Promise<void> {
847
+ const dir = path.dirname(filePath);
848
+ await ensureDirAsync(dir);
849
+ const rand = crypto.randomBytes(6).toString("hex");
850
+ const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${rand}.tmp`);
851
+ try {
852
+ await fsp.writeFile(tmp, content, { encoding: "utf8", mode });
853
+ try {
854
+ await fsp.chmod(tmp, mode);
855
+ } catch {
856
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
857
+ }
858
+ await fsp.rename(tmp, filePath);
859
+ try {
860
+ await fsp.chmod(filePath, mode);
861
+ } catch {
862
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
863
+ }
864
+ } finally {
865
+ try {
866
+ await fsp.unlink(tmp);
867
+ } catch {
868
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
869
+ }
870
+ }
871
+ }
872
+
873
+ /**
874
+ * Sibling of {@link atomicWriteFileAtomic} on the sync fs API
875
+ * (ported from cli(2).ts): tmp + chmod 600 + rename.
876
+ * v2.1.16 single-owner fix: DELETED — a dead twin of the live
877
+ * {@link atomicWriteFileSecure} above with zero callers anywhere in the repo
878
+ * (audit 16-B); the export barrel entry was removed with it.
879
+ */
880
+
881
+ /** Async JSON reader returning null for missing/invalid files, {} for empty. */
882
+ async function readJsonFileSafe(filePath: string): Promise<any | null> {
883
+ try {
884
+ const txt = await fsp.readFile(filePath, "utf8");
885
+ if (!txt.trim()) return {};
886
+ return JSON.parse(txt);
887
+ } catch {
888
+ return null;
889
+ }
890
+ }
891
+
892
+ // ---------------------------------------------------------------------------
893
+ // 01C. OPENCODE.JSON LOCATION (ported from cli(2).ts)
894
+ // ---------------------------------------------------------------------------
895
+
896
+ /** Where opencode.json was found (or where it would be created). */
897
+ interface OpenCodeJsonLocation {
898
+ path: string;
899
+ exists: boolean;
900
+ source: "cwd" | "global" | "env" | "custom";
901
+ }
902
+
903
+ /** Deterministic candidate list for opencode.json discovery (env wins first). */
904
+ function resolveOpenCodeJsonCandidates(): string[] {
905
+ const candidates: string[] = [];
906
+ // OPENCODE_CONFIG (opencode >= 1.18) points at the exact config FILE - the
907
+ // strongest signal there is, so it goes first (plus its dir's canonical
908
+ // name, in case the file itself is named differently).
909
+ const envFile = process.env.OPENCODE_CONFIG?.trim();
910
+ if (envFile) {
911
+ const absFile = path.resolve(envFile);
912
+ candidates.push(absFile, path.join(path.dirname(absFile), "opencode.json"));
913
+ }
914
+ const envDir = process.env.OPENCODE_CONFIG_DIR?.trim();
915
+ if (envDir) {
916
+ candidates.push(path.join(path.resolve(envDir), "opencode.json"));
917
+ }
918
+ candidates.push(path.join(process.cwd(), "opencode.json"));
919
+ candidates.push(path.join(process.cwd(), ".opencode", "opencode.json"));
920
+ candidates.push(path.join(os.homedir(), ".config", "opencode", "opencode.json"));
921
+ candidates.push(path.join(os.homedir(), ".opencode", "opencode.json"));
922
+ // dedup preserving order
923
+ return Array.from(new Set(candidates));
924
+ }
925
+
926
+ /** Best-effort classification of where a located opencode.json came from. */
927
+ function classifyOpenCodeJsonSource(cand: string): OpenCodeJsonLocation["source"] {
928
+ const envFile = process.env.OPENCODE_CONFIG?.trim();
929
+ if (envFile && cand === path.resolve(envFile)) return "env";
930
+ const envDir = process.env.OPENCODE_CONFIG_DIR?.trim();
931
+ if (envDir && cand === path.join(path.resolve(envDir), "opencode.json")) return "env";
932
+ if (cand.includes(process.cwd())) return "cwd";
933
+ return "global";
934
+ }
935
+
936
+ /**
937
+ * Locate opencode.json honoring --path overrides and --global preference
938
+ * (ported from cli(2).ts).
939
+ */
940
+ async function locateOpenCodeJson(customPath?: string, preferGlobal = false): Promise<OpenCodeJsonLocation> {
941
+ if (customPath) {
942
+ const abs = path.resolve(customPath);
943
+ let exists = false;
944
+ try {
945
+ await fsp.access(abs, fs.constants.F_OK);
946
+ exists = true;
947
+ } catch {
948
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
949
+ }
950
+ return { path: abs, exists, source: "custom" };
951
+ }
952
+
953
+ const candidates = resolveOpenCodeJsonCandidates();
954
+ const ordered = preferGlobal ? [...candidates].reverse() : candidates;
955
+
956
+ for (const cand of ordered) {
957
+ try {
958
+ await fsp.access(cand, fs.constants.F_OK);
959
+ return { path: cand, exists: true, source: classifyOpenCodeJsonSource(cand) };
960
+ } catch {
961
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
962
+ }
963
+ }
964
+
965
+ // nothing exists - pick the creation target based on the preference flag
966
+ if (preferGlobal) {
967
+ return { path: path.join(resolveBaseDir(), "opencode.json"), exists: false, source: "global" };
968
+ }
969
+ return { path: path.join(process.cwd(), "opencode.json"), exists: false, source: "cwd" };
970
+ }
971
+
972
+ /**
973
+ * Path of the plugin config file antigravity.json (ported from cli(2).ts).
974
+ * Honors an explicit custom path, otherwise delegates to config.getConfigPath.
975
+ */
976
+ function resolveAntigravityJsonPath(custom?: string): string {
977
+ if (custom) return path.resolve(custom);
978
+ try {
979
+ if (typeof getConfigPath === "function") {
980
+ const p = getConfigPath();
981
+ if (p) return p;
982
+ }
983
+ } catch {
984
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
985
+ }
986
+ return path.join(resolveBaseDir(), "antigravity.json");
987
+ }
988
+
989
+ // ===========================================================================
990
+ // 02. ACCOUNT MANAGER FACTORY (singleton aware)
991
+ // (the twin deletion of the ownership move, step 5: the ~160-line
992
+ // getAccountManager fallback twin — the in-memory manager that raced the
993
+ // real one on the same antigravity-accounts.json file — was DELETED; every
994
+ // call site answers getDefaultAccountManager({ disableBackgroundTimer:
995
+ // true }) from ./accounts.js, the accounts owner's real manager.)
996
+ // ===========================================================================
997
+
998
+ // ===========================================================================
999
+ // 03. CONFIG HELPERS
1000
+ // ===========================================================================
1001
+ function loadAntigravityConfig(): any {
1002
+ try {
1003
+ if (typeof loadConfig === "function") return loadConfig();
1004
+ } catch {
1005
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
1006
+ }
1007
+ return readJsonSafe<any>(
1008
+ resolveConfigPath(),
1009
+ DEFAULT_CONFIG ?? {
1010
+ cli_first: true,
1011
+ toast_scope: "all",
1012
+ soft_quota_threshold_percent: 90,
1013
+ quota_fallback: "auto",
1014
+ quota_refresh_interval_minutes: 15,
1015
+ soft_quota_cache_ttl_minutes: 5,
1016
+ pid_offset_enabled: true,
1017
+ google_search_enabled: false,
1018
+ debug: false,
1019
+ version_cache_ttl: 60,
1020
+ },
1021
+ );
1022
+ }
1023
+ function saveAntigravityConfigAtomic(cfg: any): any {
1024
+ try {
1025
+ if (typeof providerSaveConfig === "function") return providerSaveConfig(cfg);
1026
+ } catch {
1027
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
1028
+ }
1029
+ // manual atomic
1030
+ const validated = (() => {
1031
+ try {
1032
+ if (typeof parseAndValidateConfig === "function") return parseAndValidateConfig(cfg, { strict: false });
1033
+ } catch {
1034
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
1035
+ }
1036
+ return cfg;
1037
+ })();
1038
+ atomicWriteFileSecure(resolveConfigPath(), JSON.stringify(validated, null, 2) + "\n", 0o600);
1039
+ return validated;
1040
+ }
1041
+
1042
+ // ===========================================================================
1043
+ // 04. QUOTA HELPERS
1044
+ // ===========================================================================
1045
+ async function fetchQuotaForAccount(email: string | undefined, mgr: any, qManager: any): Promise<any> {
1046
+ const targetEmail = email ?? mgr.getActiveAccount()?.email ?? mgr.listAccounts()?.[0]?.email;
1047
+ if (!targetEmail) throw new Error("No account configured. Run `login` first.");
1048
+ const token = await mgr.getValidAccessToken(targetEmail);
1049
+ const projectId = mgr.findAccount?.(targetEmail)?.projectId ?? FALLBACK_PROJECT_ID;
1050
+ // Prefer quota.ts checkQuota if available
1051
+ if (checkQuotaFn && qManager) {
1052
+ // qManager already bound to token? use checkQuota directly
1053
+ try {
1054
+ if (typeof getQuotaGroupsFn === "function") {
1055
+ const groups = await getQuotaGroupsFn(token, projectId, { source: "antigravity" });
1056
+ return { email: targetEmail, groups, token, projectId };
1057
+ }
1058
+ } catch {
1059
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
1060
+ }
1061
+ }
1062
+ // fallback: direct call to retrieveUserQuotaSummary using bypass headers
1063
+ // v2.1.16 single-owner fix: identity headers from constants.js /
1064
+ // fingerprint.js; base URLs and the v1internal method path from
1065
+ // constants.js (CLOUDCODE_*_BASE + CODE_ASSIST_PATH_MAP) — byte-identical
1066
+ // URLs and header values as the former local literals.
1067
+ const headers: Record<string, string> = {
1068
+ Authorization: `Bearer ${token}`,
1069
+ "User-Agent": GEMINI_CLI_USER_AGENT,
1070
+ "X-Goog-Api-Client": X_GOOG_API_CLIENT_GEMINI_CLI,
1071
+ "Client-Metadata": CLIENT_METADATA_STRING,
1072
+ "Content-Type": "application/json",
1073
+ };
1074
+ const endpoints = [
1075
+ `${CLOUDCODE_DAILY_BASE}${CODE_ASSIST_PATH_MAP.RETRIEVE_USER_QUOTA_SUMMARY}`,
1076
+ `${CLOUDCODE_BASE_URL}${CODE_ASSIST_PATH_MAP.RETRIEVE_USER_QUOTA_SUMMARY}`,
1077
+ ];
1078
+ let lastErr: any = null;
1079
+ for (const url of endpoints) {
1080
+ try {
1081
+ const controller = new AbortController();
1082
+ // v2.1.16 single-owner fix: raw 10s timeout literal replaced by the
1083
+ // constants.js FETCH_TIMEOUT_MS owner (same 10_000 value).
1084
+ const t = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
1085
+ const res = await fetch(url, {
1086
+ method: "POST",
1087
+ headers,
1088
+ body: JSON.stringify({ project: projectId }),
1089
+ signal: controller.signal,
1090
+ });
1091
+ clearTimeout(t);
1092
+ if (!res.ok) {
1093
+ lastErr = new Error(`${url} -> ${res.status}`);
1094
+ continue;
1095
+ }
1096
+ const j: any = await res.json();
1097
+ return { email: targetEmail, raw: j, token, projectId, endpoint: url };
1098
+ } catch (e) {
1099
+ lastErr = e;
1100
+ }
1101
+ }
1102
+ throw lastErr ?? new Error("Failed to fetch quota");
1103
+ }
1104
+
1105
+ /**
1106
+ * Persist fetched quota numbers back into the accounts file
1107
+ * (ported from cli(4).ts: quota writes remaining/limit/lastUsed per account).
1108
+ * Pure best-effort: never throws, preserves every other field atomically.
1109
+ */
1110
+ async function persistQuotaResultToAccountsFile(
1111
+ email: string,
1112
+ data: { remaining?: number | undefined; limit?: number | undefined },
1113
+ ): Promise<void> {
1114
+ try {
1115
+ const p = resolveAccountsPath();
1116
+ const store = readJsonSafe<any>(p, { version: 3, accounts: [] });
1117
+ const arr: any[] = store.accounts ?? [];
1118
+ const idx = arr.findIndex((a: any) => String(a.email).toLowerCase() === email.toLowerCase());
1119
+ if (idx < 0) return;
1120
+ const acc = arr[idx];
1121
+ if (!acc) return;
1122
+ if (typeof data.remaining === "number" && Number.isFinite(data.remaining)) acc.remaining = data.remaining;
1123
+ if (typeof data.limit === "number" && Number.isFinite(data.limit)) acc.limit = data.limit;
1124
+ acc.lastUsed = Date.now();
1125
+ store.updatedAt = Date.now();
1126
+ atomicWriteFileSecure(p, JSON.stringify(store, null, 2) + "\n", 0o600);
1127
+ } catch {
1128
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
1129
+ }
1130
+ }
1131
+
1132
+ // ===========================================================================
1133
+ // 05. COMMAND IMPLEMENTATIONS
1134
+ // ===========================================================================
1135
+
1136
+ async function cmdLogin(args: string[], loginRl?: readline.Interface): Promise<void> {
1137
+ const noBrowser = args.includes("--no-browser") || args.includes("--noBrowser");
1138
+ const timeoutIdx = args.indexOf("--timeout");
1139
+ let timeoutMs = 300000;
1140
+ if (timeoutIdx >= 0 && args[timeoutIdx + 1]) {
1141
+ const v = Number(args[timeoutIdx + 1]);
1142
+ if (!Number.isNaN(v) && v > 0) timeoutMs = v;
1143
+ }
1144
+ // --email option only for hint, real email comes from OAuth userinfo
1145
+ const emailOptIdx = args.findIndex((a) => a === "--email" || a.startsWith("--email="));
1146
+ let hintEmail: string | undefined;
1147
+ if (emailOptIdx >= 0) {
1148
+ const a = args[emailOptIdx]!;
1149
+ if (a.includes("=")) hintEmail = a.split("=")[1];
1150
+ else hintEmail = args[emailOptIdx + 1];
1151
+ }
1152
+
1153
+ // Login mode selection (ported from cli(5).ts):
1154
+ // 1 = add accounts (merge into store) | 2 = fresh start (wipe all first)
1155
+ let freshStart = args.includes("--fresh") || args.includes("--fresh-start") || args.includes("--wipe-all");
1156
+ if (!freshStart && (Boolean(loginRl) || process.stdin.isTTY === true)) {
1157
+ console.log("[login] mode selection: 1 = add accounts (merge) | 2 = fresh start (wipe all accounts)");
1158
+ const ans = loginRl ? await askQuestion(loginRl, "mode 1/2 [1]: ") : await promptLoginModeChoice();
1159
+ freshStart = ans.trim() === "2";
1160
+ }
1161
+
1162
+ console.log(C.bold("\nAntigravity OAuth Login - Gemini CLI Bypass"));
1163
+ console.log(
1164
+ C.dim(
1165
+ `Client: ${GEMINI_OAUTH_CLIENT_ID.includes("681255809395") ? "gemini-cli default" : "custom override"} (id redacted in logs)`,
1166
+ ),
1167
+ );
1168
+ console.log(C.dim(`Project fallback: ${FALLBACK_PROJECT_ID}`));
1169
+ console.log(C.dim(`Headers: GeminiCLI/0.57.0 gl-node/22.19.0 ideType=IDE_UNSPECIFIED`));
1170
+ console.log("");
1171
+
1172
+ if (!authenticate) {
1173
+ logErr("oauth.ts did not expose authenticate. Check the imports.");
1174
+ process.exit(1);
1175
+ }
1176
+
1177
+ const mgr = getDefaultAccountManager({ disableBackgroundTimer: true });
1178
+
1179
+ try {
1180
+ const result = await authenticate({
1181
+ noBrowser,
1182
+ callbackTimeoutMs: timeoutMs,
1183
+ onLog: (lvl: string, msg: string) => {
1184
+ if (lvl === "info") logDim(`[oauth] ${msg}`);
1185
+ else if (lvl === "warn") logWarn(`[oauth] ${msg}`);
1186
+ else logErr(`[oauth] ${msg}`);
1187
+ },
1188
+ });
1189
+
1190
+ const { tokens, userInfo } = result as any;
1191
+ const email = (userInfo?.email ?? hintEmail ?? "").toLowerCase();
1192
+ if (!email) throw new Error("Could not determine the user email.");
1193
+
1194
+ logOk(`Authenticated as ${C.bold(userInfo?.name ?? email)} <${email}>`);
1195
+
1196
+ // Fresh-start wipe (cli(5) "mode 2"): remove every stored account first
1197
+ if (freshStart) {
1198
+ const previous: any[] = mgr.listAccounts?.() ?? [];
1199
+ for (const oldAcc of previous) {
1200
+ try {
1201
+ await mgr.removeAccount(oldAcc.email);
1202
+ } catch {
1203
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
1204
+ }
1205
+ }
1206
+ logWarn(`Fresh start: removed ${previous.length} previous account(s) before adding.`);
1207
+ }
1208
+
1209
+ // Defensive: Google may not return a refresh_token if the prior consent is still
1210
+ // active on the user's Google account. Pre-empt with prompt=consent in oauth.ts
1211
+ // (auth.ts:265) and revokeGoogleToken() on logout, but still guard here.
1212
+ // Hint: if login keeps failing with a stale consent, review/revoke granted apps at
1213
+ // https://myaccount.google.com/permissions and retry.
1214
+ const refreshToken = tokens.refresh_token ?? tokens.refreshToken ?? result.tokens?.refresh_token;
1215
+ if (!refreshToken) {
1216
+ logErr(
1217
+ "Google did not return a refresh_token. Re-run login, or visit https://myaccount.google.com/permissions to remove the prior consent and try again.",
1218
+ );
1219
+ process.exitCode = 1;
1220
+ return;
1221
+ }
1222
+
1223
+ // Add to store
1224
+ const account = mgr.addAccount({
1225
+ email,
1226
+ refreshToken,
1227
+ accessToken: tokens.access_token ?? tokens.accessToken,
1228
+ expiryDate: tokens.expires_at ?? Date.now() + (tokens.expires_in ?? 3600) * 1000,
1229
+ projectId: FALLBACK_PROJECT_ID,
1230
+ enabled: true,
1231
+ oauthClientKey: "gemini-cli",
1232
+ oauthClientConfig: {
1233
+ clientId: GEMINI_OAUTH_CLIENT_ID,
1234
+ clientSecret: GEMINI_OAUTH_CLIENT_SECRET,
1235
+ },
1236
+ scopes: [...GEMINI_CLI_SCOPES],
1237
+ });
1238
+
1239
+ console.log("");
1240
+ logOk(`Account ${C.b(`${email}`)} added/saved successfully.`);
1241
+ console.log(C.dim(` Store: ${resolveAccountsPath()} (chmod 600)`));
1242
+ console.log(
1243
+ C.dim(
1244
+ ` AccessToken: ${maskProviderToken(account.accessToken ?? tokens.access_token)} expires ${formatDate(account.expiryDate)}`,
1245
+ ),
1246
+ );
1247
+ console.log(C.dim(` RefreshToken: ${maskProviderToken(account.refreshToken)}`));
1248
+ console.log("");
1249
+
1250
+ // Audit trail on successful login (ported from cli(5).ts)
1251
+ auditLog(`login ${email}`);
1252
+
1253
+ // Auto set active if first or none active
1254
+ try {
1255
+ const active = mgr.getActiveAccount?.();
1256
+ if (!active || active.email.toLowerCase() !== email) {
1257
+ mgr.setActive?.(email, { sticky: false });
1258
+ console.log(C.gray(`-> Set as active (use set-active to switch)`));
1259
+ }
1260
+ } catch {
1261
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
1262
+ }
1263
+
1264
+ // Verify quota quickly
1265
+ try {
1266
+ logInfo("Checking initial quota...");
1267
+ const q = await fetchQuotaForAccount(email, mgr, null);
1268
+ if (q.raw?.quotaGroups || q.groups) logOk("Quota reachable via bypass headers.");
1269
+ else logWarn("Quota returned empty, but the account is valid.");
1270
+ } catch (e) {
1271
+ logWarn(`Could not verify quota right now: ${(e as Error).message}`);
1272
+ }
1273
+ } catch (e) {
1274
+ logErr(`Login failed: ${(e as Error).message}`);
1275
+ console.error(C.dim((e as Error).stack ?? ""));
1276
+ process.exit(1);
1277
+ }
1278
+ }
1279
+
1280
+ /** Temp readline used when the login mode question runs outside the menu. */
1281
+ async function promptLoginModeChoice(): Promise<string> {
1282
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1283
+ try {
1284
+ return await askQuestion(rl, "mode 1/2 [1]: ");
1285
+ } finally {
1286
+ rl.close();
1287
+ }
1288
+ }
1289
+
1290
+ async function cmdAccounts(args: string[]): Promise<void> {
1291
+ const sub = (args[0] ?? "list").toLowerCase();
1292
+ const mgr = getDefaultAccountManager({ disableBackgroundTimer: true });
1293
+
1294
+ switch (sub) {
1295
+ case "list":
1296
+ case "ls": {
1297
+ const jsonFlag = args.includes("--json");
1298
+ /* the richest listing reads the legacy flat-file fields the v3 store
1299
+ * carries beside its typed face (addedAt, softQuotaUntil, remaining,
1300
+ * rateLimitResetTimes, ...): the deleted getAccountManager twin typed
1301
+ * them loosely, so the listing keeps that loose view of the same rows
1302
+ * (one cast at the binding — every read below stays verbatim). */
1303
+ const accounts = (mgr.listAccounts?.() ?? []) as any[];
1304
+ const activeEmail = (() => {
1305
+ try {
1306
+ return mgr.getActiveAccount?.()?.email ?? null;
1307
+ } catch {
1308
+ return null;
1309
+ }
1310
+ })();
1311
+ if (jsonFlag) {
1312
+ console.log(
1313
+ JSON.stringify(
1314
+ {
1315
+ activeEmail,
1316
+ accounts: accounts.map((a: any) => ({ ...a, refreshToken: maskProviderToken(a.refreshToken) })),
1317
+ count: accounts.length,
1318
+ },
1319
+ null,
1320
+ 2,
1321
+ ),
1322
+ );
1323
+ return;
1324
+ }
1325
+ console.log(C.bold(`\nAntigravity Accounts (${accounts.length}) - ${resolveAccountsPath()}`));
1326
+ if (accounts.length === 0) {
1327
+ logWarn("No accounts configured. Run: cli.ts login");
1328
+ return;
1329
+ }
1330
+ console.log(
1331
+ C.dim(`Active: ${activeEmail ?? "none"} | Strategy: ${(mgr as any).store?.rotationStrategy ?? "round-robin"}`),
1332
+ );
1333
+ // Richest listing (ported from cli(4).ts): store version + family indexes
1334
+ const storeMeta = readJsonSafe<any>(resolveAccountsPath(), { version: 3 } as any);
1335
+ console.log(
1336
+ C.dim(
1337
+ `Store: version=${storeMeta.version ?? "?"} activeIndex=${storeMeta.activeIndex ?? "?"} activeIndexByFamily=${JSON.stringify(storeMeta.activeIndexByFamily ?? {})}`,
1338
+ ),
1339
+ );
1340
+ console.log("");
1341
+ const nowMs = Date.now();
1342
+ for (const acc of accounts) {
1343
+ const isActive = activeEmail && acc.email.toLowerCase() === activeEmail.toLowerCase();
1344
+ const enabled =
1345
+ acc.enabled !== false && acc.disabled !== true && !(acc.disabledUntil && acc.disabledUntil > nowMs);
1346
+ const statusIcon = enabled ? C.green("*") : C.red("o");
1347
+ const activeMark = isActive ? C.bold(C.bgGreen(" ACTIVE ")) + " " : " ";
1348
+ const fail = acc.failureCount ? C.yellow(` fail=${acc.failureCount}`) : "";
1349
+ const disabledReason = acc.disabledReason ? C.dim(` (${acc.disabledReason})`) : "";
1350
+ // Soft-quota marker (ported from cli(5).ts)
1351
+ const softLeft =
1352
+ acc.softQuotaUntil && acc.softQuotaUntil > nowMs
1353
+ ? C.yellow(` soft-quota=${fmtDuration(acc.softQuotaUntil - nowMs)} left`)
1354
+ : "";
1355
+ const exhLeft =
1356
+ acc.quotaExhaustedUntil && acc.quotaExhaustedUntil > nowMs
1357
+ ? C.yellow(` quota-cooldown=${fmtDuration(acc.quotaExhaustedUntil - nowMs)} left`)
1358
+ : "";
1359
+ // remaining/limit + rate-limit windows (ported from cli(4).ts)
1360
+ const remain =
1361
+ acc.remaining !== undefined && acc.limit !== undefined
1362
+ ? C.dim(` remaining=${acc.remaining}/${acc.limit}`)
1363
+ : "";
1364
+ const rateLimits =
1365
+ acc.rateLimitResetTimes && Object.keys(acc.rateLimitResetTimes).length
1366
+ ? C.dim(` rateLimits=${JSON.stringify(acc.rateLimitResetTimes)}`)
1367
+ : "";
1368
+ console.log(
1369
+ `${statusIcon} ${activeMark}${C.bold(redactEmail(String(acc.email)))} ${enabled ? C.green("enabled") : C.red("disabled")} ${C.dim(`project=${acc.projectId ?? FALLBACK_PROJECT_ID}`)}${fail}${disabledReason}${softLeft}${exhLeft}${remain}${rateLimits}`,
1370
+ );
1371
+ console.log(
1372
+ ` ${C.dim(`addedAt=${formatDate(acc.addedAt)} created=${formatDate(acc.createdAt)} lastUsed=${formatDate(acc.lastUsedAt)} expiry=${formatDate(acc.expiryDate ?? acc.expiry)} token=${maskProviderToken(acc.accessToken ?? "")} refresh=${maskProviderToken(acc.refreshToken)}`)}`,
1373
+ );
1374
+ }
1375
+ console.log("");
1376
+ break;
1377
+ }
1378
+ case "add": {
1379
+ // manual add via flags --email --refresh-token
1380
+ const emailIdx = args.findIndex((a) => a === "--email" || a.startsWith("--email="));
1381
+ const refreshIdx = args.findIndex(
1382
+ (a) =>
1383
+ a === "--refresh-token" ||
1384
+ a.startsWith("--refresh-token=") ||
1385
+ a === "--refresh" ||
1386
+ a.startsWith("--refresh="),
1387
+ );
1388
+ let email = "";
1389
+ let refresh = "";
1390
+ if (emailIdx >= 0) {
1391
+ const v = args[emailIdx]!;
1392
+ email = v.includes("=") ? v.split("=").slice(1).join("=") : (args[emailIdx + 1] ?? "");
1393
+ }
1394
+ if (refreshIdx >= 0) {
1395
+ const v = args[refreshIdx]!;
1396
+ refresh = v.includes("=") ? v.split("=").slice(1).join("=") : (args[refreshIdx + 1] ?? "");
1397
+ }
1398
+ if (!email || !refresh) {
1399
+ console.log(C.bold("Usage: accounts add --email <email> --refresh-token <token> [--project <id>]"));
1400
+ process.exit(1);
1401
+ }
1402
+ const projIdx = args.findIndex((a) => a === "--project" || a.startsWith("--project="));
1403
+ let project: string = FALLBACK_PROJECT_ID;
1404
+ if (projIdx >= 0) {
1405
+ const v = args[projIdx]!;
1406
+ project = v.includes("=") ? v.split("=").slice(1).join("=") : (args[projIdx + 1] ?? FALLBACK_PROJECT_ID);
1407
+ }
1408
+ const acc = mgr.addAccount({
1409
+ email,
1410
+ refreshToken: refresh,
1411
+ projectId: project,
1412
+ enabled: true,
1413
+ oauthClientKey: "gemini-cli",
1414
+ });
1415
+ logOk(`Account ${email} added. Total: ${mgr.listAccounts().length}`);
1416
+ console.log(C.dim(` ${JSON.stringify({ email: acc.email, projectId: acc.projectId, enabled: acc.enabled })}`));
1417
+ break;
1418
+ }
1419
+ case "remove":
1420
+ case "rm":
1421
+ case "delete":
1422
+ case "del": {
1423
+ const email = args[1];
1424
+ if (!email) {
1425
+ console.log("Usage: accounts remove <email>");
1426
+ process.exit(1);
1427
+ }
1428
+ const ok = mgr.removeAccount(email);
1429
+ if (ok) logOk(`Removed ${email}`);
1430
+ else logErr(`Not found ${email}`);
1431
+ break;
1432
+ }
1433
+ case "enable": {
1434
+ const email = args[1];
1435
+ if (!email) {
1436
+ console.log("Usage: accounts enable <email>");
1437
+ process.exit(1);
1438
+ }
1439
+ try {
1440
+ await enableAccountFlow(email);
1441
+ } catch (e) {
1442
+ logErr((e as Error).message);
1443
+ }
1444
+ break;
1445
+ }
1446
+ case "disable": {
1447
+ const email = args[1];
1448
+ if (!email) {
1449
+ console.log("Usage: accounts disable <email> [--reason <txt>]");
1450
+ process.exit(1);
1451
+ }
1452
+ const reasonIdx = args.indexOf("--reason");
1453
+ const reason = reasonIdx >= 0 ? args[reasonIdx + 1] : "manually disabled via CLI";
1454
+ try {
1455
+ await disableAccountFlow(email, reason ?? "manually disabled via CLI");
1456
+ } catch (e) {
1457
+ logErr((e as Error).message);
1458
+ }
1459
+ break;
1460
+ }
1461
+ case "set-active":
1462
+ case "active":
1463
+ case "use": {
1464
+ const email = args[1];
1465
+ if (!email) {
1466
+ console.log("Usage: accounts set-active <email> [--sticky]");
1467
+ process.exit(1);
1468
+ }
1469
+ const sticky = args.includes("--sticky");
1470
+ try {
1471
+ mgr.setActive(email, { sticky });
1472
+ logOk(`Active now: ${email} ${sticky ? "(sticky)" : "(round-robin)"}`);
1473
+ } catch (e) {
1474
+ logErr((e as Error).message);
1475
+ process.exit(1);
1476
+ }
1477
+ break;
1478
+ }
1479
+ case "import": {
1480
+ const importPathIdx = args.findIndex((a) => a === "--path" || a.startsWith("--path="));
1481
+ let customPath: string | undefined;
1482
+ if (importPathIdx >= 0) {
1483
+ const v = args[importPathIdx]!;
1484
+ customPath = v.includes("=") ? v.split("=").slice(1).join("=") : args[importPathIdx + 1];
1485
+ }
1486
+ try {
1487
+ if (typeof (mgr as any).importFromAntigravityManager === "function") {
1488
+ const res = await (mgr as any).importFromAntigravityManager(customPath ? [customPath] : undefined);
1489
+ console.log(
1490
+ C.bold(
1491
+ `\nImport result: imported=${(res as any).imported} updated=${(res as any).updated} skipped=${(res as any).skipped}`,
1492
+ ),
1493
+ );
1494
+ if ((res as any).errors?.length) console.log(C.yellow(`Errors: ${(res as any).errors.join("; ")}`));
1495
+ if ((res as any).sources?.length) console.log(C.dim(`Sources: ${(res as any).sources.join(", ")}`));
1496
+ } else if (typeof (mgr as any).importFromAntigravity === "function") {
1497
+ const res = await (mgr as any).importFromAntigravity();
1498
+ console.log(JSON.stringify(res, null, 2));
1499
+ } else {
1500
+ logWarn(
1501
+ "importFromAntigravityManager is not available in this AccountManager version. Trying manual import...",
1502
+ );
1503
+ // manual fallback: scan known paths
1504
+ const candidates = customPath
1505
+ ? [customPath]
1506
+ : [
1507
+ path.join(os.homedir(), ".config", "antigravity", "accounts.json"),
1508
+ path.join(os.homedir(), ".antigravity", "accounts.json"),
1509
+ path.join(os.homedir(), ".config", "opencode", "antigravity-manager.json"),
1510
+ ];
1511
+ let count = 0;
1512
+ for (const p of candidates) {
1513
+ if (!fs.existsSync(p)) continue;
1514
+ try {
1515
+ const data = JSON.parse(fs.readFileSync(p, "utf8"));
1516
+ const arr = Array.isArray(data) ? data : (data.accounts ?? []);
1517
+ for (const raw of arr) {
1518
+ const email = (raw.email ?? raw.user_email)?.toLowerCase();
1519
+ const rt = raw.refreshToken ?? raw.refresh_token;
1520
+ if (email && rt) {
1521
+ mgr.addAccount({ email, refreshToken: rt, projectId: raw.projectId ?? FALLBACK_PROJECT_ID });
1522
+ count++;
1523
+ }
1524
+ }
1525
+ logOk(`Imported ${arr.length} from ${p}`);
1526
+ } catch (e) {
1527
+ logWarn(`Failed ${p}: ${(e as Error).message}`);
1528
+ }
1529
+ }
1530
+ console.log(C.dim(`Manual import total: ${count}`));
1531
+ }
1532
+ } catch (e) {
1533
+ logErr(`Import failed: ${(e as Error).message}`);
1534
+ }
1535
+ break;
1536
+ }
1537
+ default:
1538
+ console.log(C.bold("\naccounts subcommands:"));
1539
+ console.log(" list [--json] List accounts");
1540
+ console.log(" add --email --refresh-token Add manually");
1541
+ console.log(" remove <email> Remove account");
1542
+ console.log(" enable <email> Enable account");
1543
+ console.log(" disable <email> Disable account");
1544
+ console.log(" set-active <email> [--sticky] Set active + strategy");
1545
+ console.log(" import [--path <file>] Import from antigravity-manager");
1546
+ break;
1547
+ }
1548
+ }
1549
+
1550
+ function formatQuotaBar(remaining: number, limit: number): string {
1551
+ const cfg = loadAntigravityConfig();
1552
+ const softPct = cfg.soft_quota_threshold_percent ?? 90;
1553
+ const used = limit - remaining;
1554
+ const pctUsed = limit > 0 ? (used / limit) * 100 : 0;
1555
+ const barLen = 20;
1556
+ const filled = Math.round((pctUsed / 100) * barLen);
1557
+ const bar = "#".repeat(filled) + ".".repeat(barLen - filled);
1558
+ let col = C.green;
1559
+ if (pctUsed >= softPct) col = C.yellow;
1560
+ if (pctUsed >= 98) col = C.red;
1561
+ return `${col(bar)} ${pctUsed.toFixed(1)}% used (${remaining}/${limit} remaining)`;
1562
+ }
1563
+
1564
+ async function cmdQuota(args: string[]): Promise<void> {
1565
+ const jsonFlag = args.includes("--json");
1566
+ const allFlag = args.includes("--all");
1567
+ let emailArg: string | undefined;
1568
+ // positional email unless flag
1569
+ for (const a of args) {
1570
+ if (!a.startsWith("-") && a.includes("@")) {
1571
+ emailArg = a;
1572
+ break;
1573
+ }
1574
+ }
1575
+ const emailIdx = args.findIndex((a) => a === "--email" || a.startsWith("--email="));
1576
+ if (emailIdx >= 0) {
1577
+ const v = args[emailIdx]!;
1578
+ emailArg = v.includes("=") ? v.split("=").slice(1).join("=") : args[emailIdx + 1];
1579
+ }
1580
+
1581
+ const mgr = getDefaultAccountManager({ disableBackgroundTimer: true });
1582
+ const stores = mgr.listAccounts?.() ?? [];
1583
+ if (stores.length === 0) {
1584
+ logWarn("No accounts. Run login first.");
1585
+ return;
1586
+ }
1587
+
1588
+ const targets = allFlag ? stores.map((s: any) => s.email) : [emailArg ?? stores[0]?.email];
1589
+ const cfg = loadAntigravityConfig();
1590
+ const softThresh = (cfg.soft_quota_threshold_percent ?? 90) / 100;
1591
+
1592
+ console.log(
1593
+ C.bold(
1594
+ `\nQuota Check - soft threshold ${cfg.soft_quota_threshold_percent}% | refresh ${cfg.quota_refresh_interval_minutes}min`,
1595
+ ),
1596
+ );
1597
+ console.log(C.dim(`Bypass: GeminiCLI/0.57.0 gl-node/22.19.0 Client-Metadata=IDE_UNSPECIFIED`));
1598
+ console.log("");
1599
+
1600
+ let qManagerInst: any = null;
1601
+ try {
1602
+ if (QuotaManager)
1603
+ qManagerInst = new QuotaManager({ projectFallback: FALLBACK_PROJECT_ID, softThreshold: softThresh });
1604
+ else if (getDefaultQuotaManager) qManagerInst = getDefaultQuotaManager({ softThreshold: softThresh });
1605
+ } catch {
1606
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
1607
+ }
1608
+
1609
+ for (const email of targets) {
1610
+ if (!email) continue;
1611
+ console.log(C.bold(`- ${email} -`));
1612
+ try {
1613
+ const token = await mgr.getValidAccessToken(email);
1614
+ const quotaResult = await fetchQuotaForAccount(email, mgr, qManagerInst);
1615
+ const raw = (quotaResult as any).raw ?? {};
1616
+ const groups = (quotaResult as any).groups ?? [];
1617
+
1618
+ // Persist results back to the accounts file (ported from cli(4).ts):
1619
+ // sum numeric bucket values, otherwise fall back to auth.js retrieveQuota.
1620
+ let persistedRemaining: number | undefined;
1621
+ let persistedLimit: number | undefined;
1622
+ const bucketArr: any[] = Array.isArray(raw?.quotaBuckets)
1623
+ ? raw.quotaBuckets
1624
+ : Array.isArray(raw?.buckets)
1625
+ ? raw.buckets
1626
+ : [];
1627
+ if (bucketArr.length > 0) {
1628
+ let rSum = 0;
1629
+ let lSum = 0;
1630
+ let numeric = true;
1631
+ for (const b of bucketArr) {
1632
+ const r = Number(b?.remaining ?? b?.remainingQuota);
1633
+ const l = Number(b?.limit ?? b?.quotaLimit ?? b?.max);
1634
+ if (!Number.isFinite(r) || !Number.isFinite(l)) {
1635
+ numeric = false;
1636
+ break;
1637
+ }
1638
+ rSum += r;
1639
+ lSum += l;
1640
+ }
1641
+ if (numeric) {
1642
+ persistedRemaining = rSum;
1643
+ persistedLimit = lSum;
1644
+ }
1645
+ }
1646
+ if (
1647
+ (persistedRemaining === undefined || persistedLimit === undefined) &&
1648
+ typeof authRetrieveQuota === "function"
1649
+ ) {
1650
+ try {
1651
+ const qq = await authRetrieveQuota(token, (quotaResult as any).projectId ?? FALLBACK_PROJECT_ID);
1652
+ if (qq) {
1653
+ persistedRemaining = Number(qq.remaining);
1654
+ persistedLimit = Number(qq.limit);
1655
+ }
1656
+ } catch {
1657
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
1658
+ }
1659
+ }
1660
+ if (persistedRemaining !== undefined || persistedLimit !== undefined) {
1661
+ await persistQuotaResultToAccountsFile(email, { remaining: persistedRemaining, limit: persistedLimit });
1662
+ }
1663
+
1664
+ if (jsonFlag) {
1665
+ console.log(
1666
+ JSON.stringify(
1667
+ {
1668
+ email,
1669
+ raw,
1670
+ groups,
1671
+ endpoint: (quotaResult as any).endpoint,
1672
+ projectId: (quotaResult as any).projectId,
1673
+ remaining: persistedRemaining,
1674
+ limit: persistedLimit,
1675
+ },
1676
+ null,
1677
+ 2,
1678
+ ),
1679
+ );
1680
+ continue;
1681
+ }
1682
+
1683
+ // Parse raw if coming from API
1684
+ if (raw && typeof raw === "object") {
1685
+ // raw may have structure with quotaBuckets or similar
1686
+ const buckets = raw.quotaBuckets ?? raw.buckets ?? raw.quota ?? [];
1687
+ if (Array.isArray(buckets) && buckets.length) {
1688
+ for (const b of buckets) {
1689
+ const name = b.displayName ?? b.group ?? b.name ?? b.id ?? "unknown";
1690
+ const remaining = b.remaining ?? b.remainingQuota ?? 0;
1691
+ const limit = b.limit ?? b.quotaLimit ?? b.max ?? remaining + (b.used ?? 0);
1692
+ const reset = b.resetTime ?? b.reset_at ?? b.resetAt;
1693
+ console.log(
1694
+ ` ${C.cyan(name)}: ${formatQuotaBar(remaining, limit)} ${reset ? C.dim(`reset=${formatDate(new Date(reset).getTime())}`) : ""}`,
1695
+ );
1696
+ if (b.models?.length)
1697
+ console.log(
1698
+ ` ${C.dim(`models: ${b.models.slice(0, 5).join(", ")}${b.models.length > 5 ? "..." : ""}`)}`,
1699
+ );
1700
+ }
1701
+ } else if (Object.keys(raw).length) {
1702
+ // pretty print all top-level keys
1703
+ console.log(C.dim(` raw keys: ${Object.keys(raw).join(", ")}`));
1704
+ // try to interpret known fields
1705
+ for (const k of Object.keys(raw)) {
1706
+ const v = (raw as any)[k];
1707
+ if (v && typeof v === "object" && "remaining" in v) {
1708
+ console.log(` ${C.cyan(k)}: ${formatQuotaBar((v as any).remaining ?? 0, (v as any).limit ?? 1000)}`);
1709
+ }
1710
+ }
1711
+ if (Array.isArray((raw as any).quotaGroups)) {
1712
+ for (const g of (raw as any).quotaGroups) {
1713
+ console.log(` ${C.cyan(g.displayName ?? g.id)} remaining=${g.remaining ?? "?"} limit=${g.limit ?? "?"}`);
1714
+ }
1715
+ } else {
1716
+ console.log(` ${C.dim(JSON.stringify(raw).slice(0, 800))}`);
1717
+ }
1718
+ }
1719
+ }
1720
+
1721
+ if (groups.length) {
1722
+ for (const g of groups) {
1723
+ console.log(
1724
+ ` ${C.cyan(g.id ?? g.group)}: remaining=${(g as any).remaining ?? "?"} used=${(g as any).used ?? "?"} limit=${(g as any).limit ?? "?"}`,
1725
+ );
1726
+ if ((g as any).models) console.log(` ${C.dim(`models: ${(g as any).models.join(", ")}`)}`);
1727
+ }
1728
+ }
1729
+
1730
+ if (!raw || (Object.keys(raw).length === 0 && (!groups || groups.length === 0))) {
1731
+ logWarn(" Empty quota - endpoint may have returned 1.0 (expected for gemini-cli), trying daily endpoint...");
1732
+ }
1733
+
1734
+ if (persistedRemaining !== undefined && persistedLimit !== undefined) {
1735
+ console.log(
1736
+ ` ${C.green("[saved]")} remaining=${persistedRemaining}/${persistedLimit} -> ${path.basename(resolveAccountsPath())}`,
1737
+ );
1738
+ }
1739
+
1740
+ // Evaluate threshold warnings
1741
+ const thresholdPct = cfg.soft_quota_threshold_percent ?? 90;
1742
+ console.log(
1743
+ C.dim(
1744
+ ` soft_threshold=${thresholdPct}% | project=${(quotaResult as any).projectId ?? FALLBACK_PROJECT_ID} | endpoint=${(quotaResult as any).endpoint ?? "quota_manager"}`,
1745
+ ),
1746
+ );
1747
+ } catch (e) {
1748
+ logErr(` ${email}: ${(e as Error).message}`);
1749
+ if (jsonFlag) console.log(JSON.stringify({ email, error: (e as Error).message }, null, 2));
1750
+ }
1751
+ console.log("");
1752
+ }
1753
+ }
1754
+
1755
+ async function cmdConfig(args: string[]): Promise<void> {
1756
+ const sub = (args[0] ?? "list").toLowerCase();
1757
+ const cfg = loadAntigravityConfig();
1758
+
1759
+ switch (sub) {
1760
+ case "list":
1761
+ case "ls":
1762
+ case "show": {
1763
+ const jsonFlag = args.includes("--json");
1764
+ if (jsonFlag) {
1765
+ console.log(JSON.stringify(cfg, null, 2));
1766
+ return;
1767
+ }
1768
+ console.log(C.bold(`\nAntigravity Config - ${resolveConfigPath()} (chmod 600)`));
1769
+ console.log("");
1770
+ for (const [k, v] of Object.entries(cfg)) {
1771
+ console.log(
1772
+ ` ${C.cyan(k)}: ${C.bold(String(v))} ${C.dim(`(default: ${String((DEFAULT_CONFIG as any)?.[k] ?? "-")})`)}`,
1773
+ );
1774
+ }
1775
+ console.log("");
1776
+ console.log(C.dim("Tunable keys:"));
1777
+ console.log(
1778
+ C.dim(" cli_first (bool), toast_scope (all|project|minimal|none), soft_quota_threshold_percent (0-100)"),
1779
+ );
1780
+ console.log(C.dim(" quota_fallback (true|false|auto|<model>), quota_refresh_interval_minutes (1-1440)"));
1781
+ console.log(
1782
+ C.dim(
1783
+ " soft_quota_cache_ttl_minutes, pid_offset_enabled (bool), google_search_enabled (bool), debug (bool), version_cache_ttl",
1784
+ ),
1785
+ );
1786
+ break;
1787
+ }
1788
+ case "get": {
1789
+ const key = args[1];
1790
+ if (!key) {
1791
+ console.log("Usage: config get <key>");
1792
+ process.exit(1);
1793
+ }
1794
+ const val = (cfg as any)[key];
1795
+ if (val === undefined) {
1796
+ logWarn(`Key ${key} not found`);
1797
+ process.exit(1);
1798
+ }
1799
+ console.log(`${key}=${JSON.stringify(val)}`);
1800
+ break;
1801
+ }
1802
+ case "set": {
1803
+ const key = args[1];
1804
+ const rawVal = args[2];
1805
+ if (!key || rawVal === undefined) {
1806
+ console.log(
1807
+ "Usage: config set <key> <value>\nExample: config set soft_quota_threshold_percent 85\n config set cli_first false",
1808
+ );
1809
+ process.exit(1);
1810
+ }
1811
+ // parse value smartly
1812
+ let parsed: any = rawVal;
1813
+ if (rawVal === "true") parsed = true;
1814
+ else if (rawVal === "false") parsed = false;
1815
+ else if (!Number.isNaN(Number(rawVal)) && rawVal.trim() !== "") {
1816
+ const n = Number(rawVal);
1817
+ if (Number.isFinite(n)) parsed = n;
1818
+ } else {
1819
+ // strip quotes
1820
+ parsed = rawVal.replace(/^['"]|['"]$/g, "");
1821
+ }
1822
+
1823
+ // Special handling: model catalog validation for quota_fallback when a non-auto/non-bool string
1824
+ if (key === "quota_fallback" && typeof parsed === "string" && !["true", "false", "auto"].includes(parsed)) {
1825
+ // allow model ids
1826
+ const isModel = ALL_MODELS_2026.some((m: any) => m.id === parsed);
1827
+ if (!isModel) {
1828
+ logWarn(
1829
+ `Value ${parsed} is not a known model. Allowed: ${ALL_MODELS_2026.map((m: any) => m.id).join(", ")} or auto/true/false`,
1830
+ );
1831
+ // continue anyway
1832
+ }
1833
+ }
1834
+
1835
+ const partial: any = { [key]: parsed };
1836
+ try {
1837
+ const updated = (() => {
1838
+ try {
1839
+ if (typeof updateConfig === "function") return updateConfig(partial);
1840
+ } catch {
1841
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
1842
+ }
1843
+ // manual merge atomic
1844
+ const current = loadAntigravityConfig();
1845
+ const mergedCfg = { ...current, ...partial };
1846
+ // validate
1847
+ let validated = mergedCfg;
1848
+ try {
1849
+ if (typeof parseAndValidateConfig === "function")
1850
+ validated = parseAndValidateConfig(mergedCfg, { strict: false });
1851
+ } catch {
1852
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
1853
+ }
1854
+ saveAntigravityConfigAtomic(validated);
1855
+ return validated;
1856
+ })();
1857
+ logOk(`Config ${C.bold(key)}=${C.bold(String(parsed))} saved.`);
1858
+ console.log(C.dim(JSON.stringify(updated, null, 2)));
1859
+ } catch (e) {
1860
+ logErr(`Failed to save config: ${(e as Error).message}`);
1861
+ process.exit(1);
1862
+ }
1863
+ break;
1864
+ }
1865
+ case "reset":
1866
+ case "defaults": {
1867
+ try {
1868
+ saveAntigravityConfigAtomic(DEFAULT_CONFIG ?? cfg);
1869
+ logOk("Config reset to defaults.");
1870
+ console.log(C.dim(JSON.stringify(DEFAULT_CONFIG, null, 2)));
1871
+ } catch (e) {
1872
+ logErr((e as Error).message);
1873
+ }
1874
+ break;
1875
+ }
1876
+ default:
1877
+ console.log(C.bold("config subcommands: list [--json], get <key>, set <key> <value>, reset"));
1878
+ console.log(C.dim("Examples:"));
1879
+ console.log(C.dim(" config set soft_quota_threshold_percent 85"));
1880
+ console.log(C.dim(" config set quota_refresh_interval_minutes 30"));
1881
+ console.log(C.dim(" config set toast_scope minimal"));
1882
+ break;
1883
+ }
1884
+ }
1885
+
1886
+ async function cmdModels(args: string[]): Promise<void> {
1887
+ const sub = (args[0] ?? "list").toLowerCase();
1888
+ const jsonFlag = args.includes("--json");
1889
+ const apiFilter = (() => {
1890
+ const idx = args.indexOf("--api");
1891
+ if (idx >= 0) return args[idx + 1];
1892
+ const apiEq = args.find((a) => a.startsWith("--api="));
1893
+ if (apiEq) return apiEq.split("=")[1];
1894
+ return undefined;
1895
+ })();
1896
+
1897
+ if (sub === "info" || sub === "show") {
1898
+ const id = args[1];
1899
+ if (!id) {
1900
+ console.log("Usage: models info <model-id>");
1901
+ process.exit(1);
1902
+ }
1903
+ const model = (ALL_MODELS_2026 as any[]).find((m: any) => m.id === id);
1904
+ if (!model) {
1905
+ logErr(`Model ${id} not found. Use models list`);
1906
+ process.exit(1);
1907
+ }
1908
+ if (jsonFlag) {
1909
+ console.log(JSON.stringify(model, null, 2));
1910
+ return;
1911
+ }
1912
+ console.log(C.bold(`\n${model.name} (${model.id})`));
1913
+ console.log(` ${C.cyan("API")}: ${model.api} | ${C.cyan("Provider")}: ${model.provider ?? "google"}`);
1914
+ console.log(` ${C.cyan("Context")}: ${model.context} | Output: ${model.output}`);
1915
+ console.log(` ${C.cyan("Family")}: ${model.family} | QuotaGroup: ${model.quotaGroup}`);
1916
+ if (model.preview) console.log(` ${C.yellow("preview=true")}`);
1917
+ if (model.thinking) console.log(` ${C.magenta("thinking=true")}`);
1918
+ if (model.customTools) console.log(` ${C.blue("customTools=true")}`);
1919
+ // routing info if available
1920
+ try {
1921
+ // v2.1.15 Phase A: MODEL_ROUTING lives in models.ts (model identification
1922
+ // is owned by models.ts alone).
1923
+ const routing = (modelsMod.MODEL_ROUTING as any)?.[id] ?? (modelsPkg.MODEL_ROUTING as any)?.[id];
1924
+ if (routing) {
1925
+ console.log(
1926
+ ` ${C.cyan("Routing")}: endpoint=${routing.endpoint} cloudModelId=${routing.cloudModelId} stream=${routing.stream}`,
1927
+ );
1928
+ }
1929
+ } catch {
1930
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
1931
+ }
1932
+ console.log("");
1933
+ return;
1934
+ }
1935
+
1936
+ // list
1937
+ let list = ALL_MODELS_2026 as any[];
1938
+ if (apiFilter) list = list.filter((m) => m.api === apiFilter);
1939
+ if (jsonFlag) {
1940
+ console.log(JSON.stringify(list, null, 2));
1941
+ return;
1942
+ }
1943
+
1944
+ console.log(C.bold(`\nCatalog ALL_MODELS_2026 (${list.length} models)`));
1945
+ console.log(C.dim(`Fallback project: ${FALLBACK_PROJECT_ID} | Client: GeminiCLI/0.57.0`));
1946
+ console.log("");
1947
+ console.log(C.dim(` ${"ID".padEnd(38)} ${"API".padEnd(14)} ${"CTX".padEnd(8)} QUOTA-GROUP`));
1948
+ console.log(C.dim(" " + "-".repeat(80)));
1949
+ for (const m of list) {
1950
+ const idPad = String(m.id).padEnd(38);
1951
+ const apiPad = String(m.api ?? "").padEnd(14);
1952
+ const ctx = String(m.context).padEnd(8);
1953
+ const color = m.api === "antigravity" ? C.cyan : C.green;
1954
+ const preview = m.preview ? C.yellow(" preview") : "";
1955
+ const think = m.thinking ? C.magenta(" thinking") : "";
1956
+ console.log(` ${color(idPad)} ${apiPad} ${ctx} ${m.quotaGroup}${preview}${think}`);
1957
+ }
1958
+ console.log("");
1959
+ console.log(C.dim("Use: models info <id> for details"));
1960
+ console.log(C.dim("Filter: --api antigravity | --api gemini-cli"));
1961
+ }
1962
+
1963
+ async function cmdStatus(_args: string[]): Promise<void> {
1964
+ const mgr = getDefaultAccountManager({ disableBackgroundTimer: true });
1965
+ const cfg = loadAntigravityConfig();
1966
+ const accounts = mgr.listAccounts?.() ?? [];
1967
+ const active = (() => {
1968
+ try {
1969
+ return mgr.getActiveAccount?.();
1970
+ } catch {
1971
+ return null;
1972
+ }
1973
+ })();
1974
+ console.log(C.bold("\nAntigravity Status"));
1975
+ console.log(
1976
+ C.dim(`BaseDir: ${resolveBaseDir()} | AccountsFile: ${resolveAccountsPath()} | ConfigFile: ${resolveConfigPath()}`),
1977
+ );
1978
+ console.log("");
1979
+ console.log(
1980
+ ` ${C.cyan("Accounts")}: ${accounts.length} total, ${accounts.filter((a: any) => a.enabled !== false).length} enabled`,
1981
+ );
1982
+ if (active)
1983
+ console.log(` ${C.cyan("Active")}: ${C.bold(active.email)} (project=${active.projectId ?? FALLBACK_PROJECT_ID})`);
1984
+ else console.log(` ${C.cyan("Active")}: ${C.yellow("none")}`);
1985
+ console.log(
1986
+ ` ${C.cyan("Config")}: cli_first=${cfg.cli_first} toast_scope=${cfg.toast_scope} soft_quota=${cfg.soft_quota_threshold_percent}% refresh=${cfg.quota_refresh_interval_minutes}min fallback=${cfg.quota_fallback}`,
1987
+ );
1988
+ // file perms
1989
+ for (const p of [resolveAccountsPath(), resolveConfigPath()]) {
1990
+ try {
1991
+ if (!fs.existsSync(p)) {
1992
+ console.log(` ${C.dim(p)}: ${C.yellow("does not exist")}`);
1993
+ continue;
1994
+ }
1995
+ const stat = fs.statSync(p);
1996
+ const mode = (stat.mode & 0o777).toString(8);
1997
+ const ok = mode === "600" || mode === "700";
1998
+ console.log(
1999
+ ` ${C.dim(p)}: mode=${ok ? C.green(mode) : C.red(mode)} size=${stat.size}b mtime=${formatDate(stat.mtimeMs)}`,
2000
+ );
2001
+ } catch (e) {
2002
+ console.log(` ${C.dim(p)}: ${C.red((e as Error).message)}`);
2003
+ }
2004
+ }
2005
+ // version cache
2006
+ try {
2007
+ const vInfo = await getVersionInfo();
2008
+ console.log(` ${C.cyan("Antigravity Version")}: ${vInfo.version} (source=${vInfo.source})`);
2009
+ } catch {
2010
+ console.log(` ${C.cyan("Antigravity Version")}: ${C.yellow("failed to fetch - using fallback")}`);
2011
+ }
2012
+ console.log("");
2013
+ // quick quota summary for active
2014
+ if (active) {
2015
+ try {
2016
+ console.log(C.dim("Checking quick quota for the active account..."));
2017
+ const q = await fetchQuotaForAccount(active.email, mgr, null);
2018
+ const buckets = (q.raw?.quotaBuckets ?? q.raw?.buckets ?? []) as any[];
2019
+ if (buckets.length) {
2020
+ for (const b of buckets.slice(0, 5)) {
2021
+ const name = b.displayName ?? b.group ?? "quota";
2022
+ console.log(` ${C.cyan(name)} remaining=${b.remaining ?? "?"} limit=${b.limit ?? "?"}`);
2023
+ }
2024
+ } else {
2025
+ console.log(C.dim(" Raw quota available, but without detailed buckets (daily endpoint vs base)"));
2026
+ }
2027
+ } catch (e) {
2028
+ logWarn(` Quota check failed: ${(e as Error).message}`);
2029
+ }
2030
+ }
2031
+ console.log("");
2032
+ }
2033
+
2034
+ async function cmdDoctor(_args: string[]): Promise<void> {
2035
+ console.log(C.bold("\nDoctor - Antigravity Diagnostics"));
2036
+ console.log("");
2037
+ const checks: Array<{ name: string; ok: boolean; msg: string }> = [];
2038
+ // dir
2039
+ try {
2040
+ const base = resolveBaseDir();
2041
+ ensureDirExists(base);
2042
+ const stat = fs.statSync(base);
2043
+ checks.push({ name: "baseDir exists", ok: true, msg: `${base} mode=${(stat.mode & 0o777).toString(8)}` });
2044
+ } catch (e) {
2045
+ checks.push({ name: "baseDir exists", ok: false, msg: (e as Error).message });
2046
+ }
2047
+ // accounts file
2048
+ try {
2049
+ const p = resolveAccountsPath();
2050
+ if (fs.existsSync(p)) {
2051
+ const st = fs.statSync(p);
2052
+ const mode = (st.mode & 0o777).toString(8);
2053
+ checks.push({
2054
+ name: "accounts file",
2055
+ ok: mode === "600",
2056
+ msg: `${p} mode=${mode} ${mode !== "600" ? "(must be 600)" : ""}`,
2057
+ });
2058
+ // try to chmod fix
2059
+ if (mode !== "600") {
2060
+ try {
2061
+ fs.chmodSync(p, 0o600);
2062
+ checks.push({ name: "chmod fix accounts", ok: true, msg: "fixed to 600" });
2063
+ } catch {
2064
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
2065
+ }
2066
+ }
2067
+ const data = readJsonSafe<any>(p, { accounts: [] });
2068
+ checks.push({ name: "accounts valid JSON", ok: true, msg: `${data.accounts?.length ?? 0} accounts` });
2069
+ // check tokens expiring
2070
+ const now = Date.now();
2071
+ const expiring = (data.accounts ?? []).filter((a: any) => a.expiryDate && a.expiryDate < now + 600000);
2072
+ checks.push({
2073
+ name: "token expiry",
2074
+ ok: expiring.length === 0,
2075
+ msg: expiring.length ? `${expiring.length} token(s) expiring <10min` : "all valid",
2076
+ });
2077
+ } else {
2078
+ checks.push({ name: "accounts file", ok: false, msg: `${p} does not exist - run login` });
2079
+ }
2080
+ } catch (e) {
2081
+ checks.push({ name: "accounts file", ok: false, msg: (e as Error).message });
2082
+ }
2083
+ // config file
2084
+ try {
2085
+ const p = resolveConfigPath();
2086
+ if (fs.existsSync(p)) {
2087
+ const st = fs.statSync(p);
2088
+ checks.push({ name: "config file", ok: true, msg: `${p} mode=${(st.mode & 0o777).toString(8)}` });
2089
+ const cfgRaw = readJsonSafe<any>(p, {});
2090
+ checks.push({
2091
+ name: "config valid",
2092
+ ok: Object.keys(cfgRaw).length > 0,
2093
+ msg: `${Object.keys(cfgRaw).length} keys`,
2094
+ });
2095
+ } else {
2096
+ checks.push({ name: "config file", ok: false, msg: `${p} does not exist - defaults will be created` });
2097
+ saveAntigravityConfigAtomic(DEFAULT_CONFIG ?? { cli_first: true });
2098
+ checks.push({ name: "config created", ok: true, msg: "defaults created" });
2099
+ }
2100
+ } catch (e) {
2101
+ checks.push({ name: "config file", ok: false, msg: (e as Error).message });
2102
+ }
2103
+ // bypass headers constants
2104
+ checks.push({
2105
+ name: "bypass constants",
2106
+ ok: GEMINI_OAUTH_CLIENT_ID.includes("681255809395"),
2107
+ msg: `client_id=${GEMINI_OAUTH_CLIENT_ID.includes("681255809395") ? "gemini-cli-default" : "custom"} UA=GeminiCLI/0.57.0 X-Goog=gl-node/22.19.0`,
2108
+ });
2109
+ // fetch project?
2110
+ checks.push({ name: "fallback project", ok: !!FALLBACK_PROJECT_ID, msg: FALLBACK_PROJECT_ID });
2111
+
2112
+ // Logs dir reporting (ported from cli(5).ts diagnostics)
2113
+ try {
2114
+ const ld = resolveLogsDir();
2115
+ checks.push({
2116
+ name: "logs dir",
2117
+ ok: fs.existsSync(ld),
2118
+ msg: `${ld}${fs.existsSync(ld) ? "" : " (not created yet)"}`,
2119
+ });
2120
+ } catch (e) {
2121
+ checks.push({ name: "logs dir", ok: false, msg: (e as Error).message });
2122
+ }
2123
+
2124
+ // Default model reporting (ported from cli(5).ts diagnostics)
2125
+ try {
2126
+ const def = FLAT_MODELS_2026.find((m: any) => m.id === DEFAULT_MODEL_ID);
2127
+ checks.push({
2128
+ name: "default model",
2129
+ ok: Boolean(def),
2130
+ msg: def ? `${def.id} (${def.name}) release=${def.release ?? "?"}` : DEFAULT_MODEL_ID,
2131
+ });
2132
+ } catch (e) {
2133
+ checks.push({ name: "default model", ok: false, msg: (e as Error).message });
2134
+ }
2135
+
2136
+ // opencode.json location (via the ported locateOpenCodeJson resolver)
2137
+ try {
2138
+ const loc = await locateOpenCodeJson();
2139
+ checks.push({
2140
+ name: "opencode.json",
2141
+ ok: loc.exists,
2142
+ msg: `${loc.path}${loc.exists ? ` (found via ${loc.source})` : ` (missing, would create via ${loc.source})`}`,
2143
+ });
2144
+ } catch (e) {
2145
+ checks.push({ name: "opencode.json", ok: false, msg: (e as Error).message });
2146
+ }
2147
+
2148
+ for (const ch of checks) {
2149
+ console.log(` ${ch.ok ? C.green("[ok]") : C.red("[x]")} ${C.bold(ch.name)}: ${ch.msg}`);
2150
+ }
2151
+ console.log("");
2152
+ // Raw accounts file dump - first 1000 chars (ported from cli(4).ts diagnostics)
2153
+ try {
2154
+ const ap = resolveAccountsPath();
2155
+ if (fs.existsSync(ap)) {
2156
+ const rawAcc = fs.readFileSync(ap, "utf8");
2157
+ console.log(C.dim("Accounts file raw (first 1000 chars):"));
2158
+ console.log(C.dim(rawAcc.slice(0, 1000)));
2159
+ console.log("");
2160
+ }
2161
+ } catch {
2162
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
2163
+ }
2164
+ // suggestions
2165
+ console.log(C.dim("Suggestions:"));
2166
+ console.log(C.dim(" - If a token is expiring: AccountManager auto-refreshes on use, or run `login` again"));
2167
+ console.log(C.dim(" - If mode != 600: the CLI fixes permissions automatically on the next write"));
2168
+ console.log(C.dim(" - To force bypass headers: always use gl-node/22.19.0 and Client-Metadata IDE_UNSPECIFIED"));
2169
+ console.log("");
2170
+ }
2171
+
2172
+ async function cmdVersion(_args: string[]): Promise<void> {
2173
+ const jsonFlag = _args.includes("--json");
2174
+ try {
2175
+ const info = await getVersionInfo();
2176
+ if (jsonFlag) {
2177
+ console.log(
2178
+ JSON.stringify(
2179
+ {
2180
+ plugin: PLUGIN_VERSION_FALLBACK,
2181
+ antigravity: info.version,
2182
+ full: info,
2183
+ client_id: GEMINI_OAUTH_CLIENT_ID.includes("681255809395") ? "gemini-cli-default" : "custom",
2184
+ project: FALLBACK_PROJECT_ID,
2185
+ models: ALL_MODELS_2026.length,
2186
+ },
2187
+ null,
2188
+ 2,
2189
+ ),
2190
+ );
2191
+ return;
2192
+ }
2193
+ console.log(C.bold("\nVersions"));
2194
+ console.log(` Plugin devthink: ${C.bold(PLUGIN_VERSION_FALLBACK)}`);
2195
+ console.log(
2196
+ ` Antigravity: ${C.bold(info.version)} full=${(info as any).fullVersion ?? "-"} source=${info.source}`,
2197
+ );
2198
+ console.log(
2199
+ ` ClientID: ${C.dim(GEMINI_OAUTH_CLIENT_ID.includes("681255809395") ? "gemini-cli-default (redacted)" : "custom (redacted)")} (Gemini CLI 0.57.0)`,
2200
+ );
2201
+ console.log(` Project fallback: ${FALLBACK_PROJECT_ID}`);
2202
+ console.log(` Models catalog: ${ALL_MODELS_2026.length} (antigravity-first)`);
2203
+ console.log(` BaseDir: ${resolveBaseDir()}`);
2204
+ } catch (e) {
2205
+ console.log(C.bold(`\nFallback version: ${PLUGIN_VERSION_FALLBACK}`));
2206
+ console.log(C.dim(`Error fetching remote version: ${(e as Error).message}`));
2207
+ }
2208
+ }
2209
+
2210
+ // ===========================================================================
2211
+ // 05B. LOGOUT / ENABLE / DISABLE / MANAGE (ported from cli(2).ts + cli(5).ts)
2212
+ // ===========================================================================
2213
+
2214
+ /**
2215
+ * Best-effort OAuth token revocation against Google's revoke endpoint.
2216
+ * A 400 response means the token was already invalid/revoked - counted as OK.
2217
+ */
2218
+ async function revokeGoogleToken(token?: string | null): Promise<boolean> {
2219
+ if (!token) return false;
2220
+ try {
2221
+ // v2.1.16 single-owner fix: revoke endpoint from constants.js
2222
+ // (OAUTH_REVOKE_URL owner) instead of a local literal.
2223
+ const res = await fetch(OAUTH_REVOKE_URL, {
2224
+ method: "POST",
2225
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
2226
+ body: new URLSearchParams({ token }).toString(),
2227
+ });
2228
+ return res.ok || res.status === 400;
2229
+ } catch {
2230
+ return false;
2231
+ }
2232
+ }
2233
+
2234
+ /**
2235
+ * Logout flow (ported from cli(2).ts, extended with token revocation):
2236
+ * revokes the Google refresh/access token and removes the account from the
2237
+ * store. Supports a single email, --all, and an interactive picker when no
2238
+ * email is given. Pass {@link sharedRl} when called from the interactive menu.
2239
+ */
2240
+ async function logoutFlow(emailArg?: string, all = false, sharedRl?: readline.Interface): Promise<void> {
2241
+ printHeader();
2242
+ const rl = sharedRl ?? readline.createInterface({ input: process.stdin, output: process.stdout });
2243
+ const ownedRl = !sharedRl;
2244
+ try {
2245
+ const mgr: any = getDefaultAccountManager({ disableBackgroundTimer: true });
2246
+ const accounts: any[] = mgr.listAccounts?.() ?? [];
2247
+
2248
+ if (accounts.length === 0) {
2249
+ logWarn("No accounts registered.");
2250
+ return;
2251
+ }
2252
+
2253
+ if (all) {
2254
+ console.log(C.bold(`Logout --all: removing ${accounts.length} account(s)`));
2255
+ const okAll = await confirm(
2256
+ rl,
2257
+ C.red(`Confirm removal of ALL ${accounts.length} accounts (tokens will be revoked)?`),
2258
+ false,
2259
+ );
2260
+ if (!okAll) {
2261
+ console.log(C.dim("Cancelled."));
2262
+ return;
2263
+ }
2264
+ let removedCount = 0;
2265
+ for (const acc of accounts) {
2266
+ await revokeGoogleToken(acc.refreshToken ?? acc.refresh_token ?? acc.accessToken);
2267
+ try {
2268
+ await mgr.removeAccount(acc.email);
2269
+ removedCount++;
2270
+ console.log(C.dim(` removed ${acc.email}`));
2271
+ } catch (e) {
2272
+ console.log(C.red(` failed ${acc.email}: ${(e as Error).message}`));
2273
+ }
2274
+ }
2275
+ logOk(`All accounts removed (${removedCount}). Store: ${resolveAccountsPath()}`);
2276
+ auditLog(`logout --all removed=${removedCount}`);
2277
+ return;
2278
+ }
2279
+
2280
+ let targetEmail = emailArg?.toLowerCase().trim();
2281
+
2282
+ if (!targetEmail) {
2283
+ if (accounts.length === 1) {
2284
+ targetEmail = String(accounts[0]?.email ?? "").toLowerCase();
2285
+ } else {
2286
+ // interactive choose
2287
+ console.log(C.bold("Logout - choose an account to remove:\n"));
2288
+ accounts.forEach((acc, i) => {
2289
+ const mark = acc.disabled === true || acc.enabled === false ? C.red("[disabled]") : C.green("[active]");
2290
+ console.log(
2291
+ ` ${C.cyan(String(i + 1))}. ${redactEmail(String(acc.email))} ${mark} project=${acc.projectId ?? FALLBACK_PROJECT_ID}`,
2292
+ );
2293
+ });
2294
+ const ans = await askQuestion(rl, `\nEnter number (1-${accounts.length}) or email: `);
2295
+ const num = Number(ans);
2296
+ if (Number.isFinite(num) && num >= 1 && num <= accounts.length) {
2297
+ targetEmail = String(accounts[num - 1]?.email ?? "").toLowerCase();
2298
+ } else if (ans.includes("@")) {
2299
+ targetEmail = ans.toLowerCase();
2300
+ } else {
2301
+ logWarn("Invalid input, cancelled.");
2302
+ return;
2303
+ }
2304
+ }
2305
+ }
2306
+
2307
+ if (!targetEmail) {
2308
+ logWarn("No email provided, cancelled.");
2309
+ return;
2310
+ }
2311
+
2312
+ const chosen: string = targetEmail;
2313
+ const existing = mgr.findAccount?.(chosen) ?? accounts.find((a) => String(a.email).toLowerCase() === chosen);
2314
+ if (!existing) {
2315
+ logErr(`Account not found: ${chosen}`);
2316
+ console.log(C.dim(`Existing accounts: ${accounts.map((a) => a.email).join(", ")}`));
2317
+ process.exitCode = 1;
2318
+ return;
2319
+ }
2320
+
2321
+ const okOne = await confirm(
2322
+ rl,
2323
+ `Remove account ${C.bold(existing.email)} (its refresh token will be revoked)?`,
2324
+ false,
2325
+ );
2326
+ if (!okOne) {
2327
+ console.log(C.dim("Cancelled."));
2328
+ return;
2329
+ }
2330
+
2331
+ const revoked = await revokeGoogleToken(existing.refreshToken ?? existing.refresh_token ?? existing.accessToken);
2332
+ console.log(
2333
+ revoked
2334
+ ? C.dim(" token revoked via oauth2.googleapis.com/revoke")
2335
+ : C.dim(" token revocation skipped/failed (removing the account locally anyway)"),
2336
+ );
2337
+
2338
+ const removed = await mgr.removeAccount(existing.email);
2339
+ if (removed) {
2340
+ logOk(`Account removed: ${existing.email}`);
2341
+ } else {
2342
+ logErr(`Failed to remove: ${existing.email}`);
2343
+ process.exitCode = 1;
2344
+ }
2345
+ auditLog(`logout ${existing.email} revoked=${revoked}`);
2346
+ } finally {
2347
+ if (ownedRl) rl.close();
2348
+ }
2349
+ }
2350
+
2351
+ /** Top-level enable command (ported from cli(2).ts enableAccountFlow). */
2352
+ async function enableAccountFlow(email: string): Promise<void> {
2353
+ printHeader();
2354
+ const mgr: any = getDefaultAccountManager({ disableBackgroundTimer: true });
2355
+ try {
2356
+ if (typeof mgr.enableAccount === "function") mgr.enableAccount(email);
2357
+ else if (typeof mgr.enable === "function") await mgr.enable(email);
2358
+ else throw new Error("account manager does not support enable");
2359
+ logOk(`Account enabled: ${email}`);
2360
+ } catch (e) {
2361
+ logErr(`Account not found or enable failed: ${email} (${(e as Error).message})`);
2362
+ process.exitCode = 1;
2363
+ }
2364
+ }
2365
+
2366
+ /** Top-level disable command (ported from cli(2).ts disableAccountFlow). */
2367
+ async function disableAccountFlow(email: string, reason = "manually disabled via CLI"): Promise<void> {
2368
+ printHeader();
2369
+ const mgr: any = getDefaultAccountManager({ disableBackgroundTimer: true });
2370
+ try {
2371
+ if (typeof mgr.disableAccount === "function") mgr.disableAccount(email, reason);
2372
+ else if (typeof mgr.disable === "function") await mgr.disable(email);
2373
+ else throw new Error("account manager does not support disable");
2374
+ logOk(`Account disabled: ${email} (skipped in rotation)`);
2375
+ } catch (e) {
2376
+ logErr(`Account not found or disable failed: ${email} (${(e as Error).message})`);
2377
+ process.exitCode = 1;
2378
+ }
2379
+ }
2380
+
2381
+ /**
2382
+ * Interactive per-account enable/disable toggle
2383
+ * (ported from cli(5).ts manage command, backed by auth.js accountManager).
2384
+ * Pass {@link sharedRl} when invoked from the interactive menu.
2385
+ */
2386
+ async function cmdManage(_args: string[], sharedRl?: readline.Interface): Promise<void> {
2387
+ const rl = sharedRl ?? readline.createInterface({ input: process.stdin, output: process.stdout });
2388
+ const ownedRl = !sharedRl;
2389
+ try {
2390
+ await cmdAccounts(["list"]);
2391
+ const email = await askQuestion(rl, "Email to toggle enable/disable: ");
2392
+ if (!email) {
2393
+ logWarn("Cancelled.");
2394
+ return;
2395
+ }
2396
+ const enStr = await askQuestion(rl, "Enable true/false [true]: ");
2397
+ const en = enStr.trim().toLowerCase() !== "false";
2398
+
2399
+ let applied = false;
2400
+ // Preferred path: auth.js accountManager.enable(email, bool) on the v3 store
2401
+ if (typeof AuthAccountManager === "function") {
2402
+ try {
2403
+ const m = new AuthAccountManager();
2404
+ if (typeof m.load === "function") {
2405
+ try {
2406
+ await m.load();
2407
+ } catch {
2408
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
2409
+ }
2410
+ }
2411
+ if (typeof m.enable === "function") {
2412
+ await m.enable(email, en);
2413
+ applied = true;
2414
+ }
2415
+ } catch (e) {
2416
+ logWarn(`auth.js manager toggle failed: ${(e as Error).message}`);
2417
+ }
2418
+ }
2419
+ if (!applied) {
2420
+ const mgr: any = getDefaultAccountManager({ disableBackgroundTimer: true });
2421
+ if (en) mgr.enableAccount(email);
2422
+ else mgr.disableAccount(email, "disabled via manage command");
2423
+ }
2424
+ auditLog(`manage ${en ? "enable" : "disable"} ${email}`);
2425
+ console.log(`${en ? "enabled" : "disabled"} ${email}`);
2426
+ } finally {
2427
+ if (ownedRl) rl.close();
2428
+ }
2429
+ }
2430
+
2431
+ // ===========================================================================
2432
+ // 05C. MODELS 2026 CATALOG + OPENCODE.JSON CONFIGURE (ported from cli(2)/(4)/(5))
2433
+ // (the model transforms of the ownership move: buildGoogleProviderModelConfigs
2434
+ // + buildOpenCodeModelDefinitions + detectTargetProvider moved to the models
2435
+ // owner — the imports at the top answer them)
2436
+ // ===========================================================================
2437
+
2438
+ const CLI_VERSION = "6.0.0" as const;
2439
+
2440
+ /**
2441
+ * Configure models flow (merge of cli(2).ts configureModelsFlow with the
2442
+ * cli(4)/cli(5) provider.google.models writer):
2443
+ * - locates opencode.json via locateOpenCodeJson (--global / --path aware);
2444
+ * - backs up an existing file with invalid JSON before overwriting;
2445
+ * - detects the TARGET provider by MODEL IDS ONLY (any user-chosen provider
2446
+ * name carrying antigravity-* / gemini-* models, e.g. "casas-bahia"),
2447
+ * falling back to "google" when none exists yet;
2448
+ * - ADD-ONLY merge: user model entries are NEVER overwritten (custom name /
2449
+ * limit / options / thinking variants are preserved verbatim), catalog
2450
+ * models are appended only when the id is missing;
2451
+ * - user provider options/npm/name are never touched (no forced baseUrl,
2452
+ * no __comment pollution); minimal block fields are set only when
2453
+ * creating a brand-new provider;
2454
+ * - preserves the file's existing indentation and trailing newline, and
2455
+ * skips the write entirely when nothing changed;
2456
+ * - writes everything with atomicWriteFileAtomic (tmp + chmod 600 + rename).
2457
+ */
2458
+ async function configureModelsFlow(
2459
+ opts: { global?: boolean | undefined; customPath?: string | undefined } = {},
2460
+ ): Promise<void> {
2461
+ printHeader();
2462
+ console.log(C.bold("Configure models - writing plugin model definitions into opencode.json\n"));
2463
+
2464
+ const loc = await locateOpenCodeJson(opts.customPath, opts.global === true);
2465
+ console.log(C.dim(`Target candidate: ${loc.path} (exists=${loc.exists}, source=${loc.source})`));
2466
+ console.log(C.dim(`Global config dir: ${resolveBaseDir()}`));
2467
+ console.log("");
2468
+
2469
+ let existing: any = {};
2470
+ let rawOriginal = "";
2471
+ if (loc.exists) {
2472
+ rawOriginal = await fsp.readFile(loc.path, "utf8").catch(() => "");
2473
+ const parsed = await readJsonFileSafe(loc.path);
2474
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
2475
+ existing = parsed;
2476
+ logOk(`Existing opencode.json loaded (${Object.keys(parsed).length} top-level keys)`);
2477
+ } else {
2478
+ logWarn("Existing opencode.json contains invalid JSON - backing it up and starting fresh");
2479
+ try {
2480
+ const backupPath = `${loc.path}.backup-${Date.now()}.json`;
2481
+ await fsp.copyFile(loc.path, backupPath);
2482
+ console.log(C.dim(` backup -> ${backupPath}`));
2483
+ } catch {
2484
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
2485
+ }
2486
+ existing = {};
2487
+ rawOriginal = "";
2488
+ }
2489
+ } else {
2490
+ console.log(C.dim("opencode.json does not exist yet - a new one will be created"));
2491
+ }
2492
+
2493
+ const target = detectTargetProvider(existing);
2494
+ const defs = buildOpenCodeModelDefinitions();
2495
+ const googleModels = buildGoogleProviderModelConfigs();
2496
+
2497
+ // Merge strategy - ADD-ONLY, user wins:
2498
+ // - plugin: unique array merge; never duplicates devthink when a variant
2499
+ // (@wenathlan/devthink, devthink@latest, ...) is already listed
2500
+ // - provider.<target>.models: catalog ids appended ONLY when missing;
2501
+ // existing user entries (custom limits/options/variants) untouched
2502
+ // - provider npm/name/options: set only when creating a brand-new block
2503
+ // - top-level models: additive merge keeping user overrides
2504
+ const merged: any = { ...existing };
2505
+
2506
+ if (!merged.$schema) merged.$schema = "https://opencode.ai/config.json";
2507
+
2508
+ const existingPlugins: any[] = Array.isArray(merged.plugin)
2509
+ ? merged.plugin
2510
+ : Array.isArray(merged.plugins)
2511
+ ? merged.plugins
2512
+ : [];
2513
+ const pluginsSet = new Set<string>(existingPlugins.map((p: any) => String(p)));
2514
+ // v2.1.21 — the plugin reference is name-agnostic: any variant already
2515
+ // listed counts ("@wenathlan/devthink", "devthink@npm:@wenathlan/devthink@latest"
2516
+ // — the scope-free npm alias —, "file:.../devthink", ...) and is never
2517
+ // duplicated. When nothing exists we add the canonical npm name.
2518
+ // (A package literally named "devthink" outside the @wenathlan scope cannot exist on npmjs.org — the
2519
+ // registry blocks the name with 403 "too similar to madge/panene/sane" —
2520
+ // so the bare spelling is the npm ALIAS, documented in the README.)
2521
+ const hasdevthinkvariant = [...pluginsSet].some((p) => /(^|[/@])devthink([@/]|$)/.test(p));
2522
+ if (!hasdevthinkvariant) {
2523
+ pluginsSet.add("@wenathlan/devthink");
2524
+ }
2525
+ merged.plugin = Array.from(pluginsSet);
2526
+
2527
+ if (!merged.provider || typeof merged.provider !== "object") merged.provider = {};
2528
+ if (!merged.provider[target.id] || typeof merged.provider[target.id] !== "object") merged.provider[target.id] = {};
2529
+ const targetBlock = merged.provider[target.id];
2530
+ const existingModels = targetBlock.models && typeof targetBlock.models === "object" ? targetBlock.models : {};
2531
+ const addedModels: Record<string, any> = {};
2532
+ for (const [id, def] of Object.entries(googleModels)) {
2533
+ if (!existingModels[id]) addedModels[id] = def;
2534
+ }
2535
+ targetBlock.models = { ...existingModels, ...addedModels };
2536
+ if (!target.existed) {
2537
+ // brand-new provider: minimal fields only; options the user later adds
2538
+ // (or loader-injected credentials) always win - nothing is forced
2539
+ if (!targetBlock.name) targetBlock.name = target.id;
2540
+ if (!targetBlock.npm) targetBlock.npm = "@ai-sdk/openai-compatible";
2541
+ if (targetBlock.options === undefined) targetBlock.options = {};
2542
+ }
2543
+
2544
+ if (!merged.models || typeof merged.models !== "object") merged.models = {};
2545
+ const topModels = merged.models;
2546
+ for (const k of Object.keys(defs.topLevelModels)) {
2547
+ const v = defs.topLevelModels[k];
2548
+ if (!topModels[k]) {
2549
+ topModels[k] = v;
2550
+ } else {
2551
+ topModels[k] = {
2552
+ ...(v as any),
2553
+ ...topModels[k],
2554
+ provider: topModels[k].provider ?? target.id,
2555
+ model: topModels[k].model ?? (v as any).model,
2556
+ };
2557
+ }
2558
+ }
2559
+ merged.models = topModels;
2560
+
2561
+ // Preserve the user's formatting: detect indentation + trailing newline
2562
+ const detectIndent = (raw: string): { indent: string; trailingNewline: boolean } => {
2563
+ let indent = " ";
2564
+ const m = raw.match(/\n([ \t]+)"/);
2565
+ if (m?.[1]) indent = m[1];
2566
+ return { indent, trailingNewline: raw === "" ? true : raw.endsWith("\n") };
2567
+ };
2568
+ const fmt = detectIndent(rawOriginal);
2569
+ const indentArg: number | "\t" = fmt.indent.includes("\t") ? "\t" : Math.min(Math.max(fmt.indent.length, 1), 8);
2570
+ const content = JSON.stringify(merged, null, indentArg) + (fmt.trailingNewline ? "\n" : "");
2571
+
2572
+ if (rawOriginal !== "" && content === rawOriginal) {
2573
+ logOk(`opencode.json already up to date - nothing to add (target provider "${target.id}")`);
2574
+ console.log(
2575
+ C.dim(
2576
+ ` provider.${target.id}.models: ${Object.keys(targetBlock.models).length} definitions (all present, user entries preserved)`,
2577
+ ),
2578
+ );
2579
+ return;
2580
+ }
2581
+
2582
+ try {
2583
+ await atomicWriteFileAtomic(loc.path, content, 0o600);
2584
+ logOk(`opencode.json written atomically (chmod 600) -> ${loc.path}`);
2585
+ console.log(
2586
+ C.dim(
2587
+ ` target provider: ${target.id} (${target.existed ? "detected by model ids, user block preserved" : "created (nothing existed yet)"})`,
2588
+ ),
2589
+ );
2590
+ console.log(C.dim(` plugin: ${JSON.stringify(merged.plugin)}`));
2591
+ console.log(
2592
+ C.dim(
2593
+ ` provider.${target.id}.models: +${Object.keys(addedModels).length} added, ${Object.keys(existingModels).length} existing preserved (user customizations untouched)`,
2594
+ ),
2595
+ );
2596
+ console.log(C.dim(` top-level models: ${Object.keys(topModels).length} entries (additive)`));
2597
+ console.log(C.dim(` indentation preserved: ${JSON.stringify(fmt.indent)}`));
2598
+ console.log(`\n${C.bold("Verify:")} ${C.cyan(`cat ${loc.path}`)}`);
2599
+ } catch (e) {
2600
+ logErr(`Failed to write opencode.json: ${(e as Error).message}`);
2601
+ process.exitCode = 1;
2602
+ }
2603
+ }
2604
+
2605
+ // ===========================================================================
2606
+ // 06. INTERACTIVE MENU
2607
+ // ===========================================================================
2608
+ async function interactiveMenu(): Promise<void> {
2609
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
2610
+ let exit = false;
2611
+ const mgr = getDefaultAccountManager({ disableBackgroundTimer: true });
2612
+
2613
+ const menu = `
2614
+ ${C.bold("============================================================")}
2615
+ ${C.bold(" Opencode Antigravity Auth - Interactive CLI (unified) ")}
2616
+ ${C.bold("============================================================")}
2617
+ ${C.dim(`Dir: ${resolveBaseDir()} | Client: GeminiCLI/0.57.0 | gl-node/22.19.0 | project=${FALLBACK_PROJECT_ID}`)}
2618
+
2619
+ ${C.cyan("1")} Login OAuth (PKCE + browser) - add or fresh start
2620
+ ${C.cyan("2")} List accounts (rich v3 format)
2621
+ ${C.cyan("3")} Add account manually
2622
+ ${C.cyan("4")} Set active account / rotation strategy
2623
+ ${C.cyan("5")} Enable / Disable account
2624
+ ${C.cyan("6")} Remove account
2625
+ ${C.cyan("7")} Check quotas (active or all) - persists results
2626
+ ${C.cyan("8")} Configure thresholds / catalog
2627
+ ${C.cyan("9")} Models (catalog ALL_MODELS_2026)
2628
+ ${C.cyan("10")} Status + Doctor/Diagnostics
2629
+ ${C.cyan("11")} Version
2630
+ ${C.cyan("12")} Logout (revoke token + remove account)
2631
+ ${C.cyan("13")} Manage accounts (toggle enable/disable)
2632
+ ${C.cyan("14")} Configure models -> opencode.json (--global/custom)
2633
+ ${C.cyan("0")} Exit
2634
+ `;
2635
+
2636
+ while (!exit) {
2637
+ console.log(menu);
2638
+ const choice = (await rl.question(`${C.bold("Choose [0-14]: ")}`)).trim();
2639
+ switch (choice) {
2640
+ case "1": {
2641
+ const noB = (await askQuestion(rl, "No-browser? (y/N): ")).toLowerCase();
2642
+ await cmdLogin(noB ? ["--no-browser"] : [], rl);
2643
+ break;
2644
+ }
2645
+ case "2": {
2646
+ await cmdAccounts(["list"]);
2647
+ break;
2648
+ }
2649
+ case "3": {
2650
+ const email = await askQuestion(rl, "Email: ");
2651
+ const rt = await askQuestion(rl, "Refresh token: ");
2652
+ if (!email || !rt) {
2653
+ logWarn("Cancelled");
2654
+ break;
2655
+ }
2656
+ const proj = (await askQuestion(rl, `Project ID [${FALLBACK_PROJECT_ID}]: `)) || FALLBACK_PROJECT_ID;
2657
+ try {
2658
+ mgr.addAccount({ email, refreshToken: rt, projectId: proj, enabled: true });
2659
+ logOk(`Added ${email}`);
2660
+ } catch (e) {
2661
+ logErr((e as Error).message);
2662
+ }
2663
+ break;
2664
+ }
2665
+ case "4": {
2666
+ await cmdAccounts(["list"]);
2667
+ const email = await askQuestion(rl, "Email to activate: ");
2668
+ if (!email) break;
2669
+ const sticky = (await askQuestion(rl, "Sticky mode? (y/N): ")).toLowerCase();
2670
+ const stickyOn = /^(y|yes)$/.test(sticky);
2671
+ try {
2672
+ mgr.setActive(email, { sticky: stickyOn });
2673
+ logOk(`Active ${email} sticky=${stickyOn}`);
2674
+ } catch (e) {
2675
+ logErr((e as Error).message);
2676
+ }
2677
+ break;
2678
+ }
2679
+ case "5": {
2680
+ await cmdAccounts(["list"]);
2681
+ const email = await askQuestion(rl, "Email to toggle enable/disable: ");
2682
+ if (!email) break;
2683
+ const act = (await askQuestion(rl, "Enable (e) / Disable (d)? [e/d]: ")).toLowerCase();
2684
+ try {
2685
+ if (act === "d") {
2686
+ mgr.disableAccount(email, "manual interactive");
2687
+ logOk(`Disabled ${email}`);
2688
+ } else {
2689
+ mgr.enableAccount(email);
2690
+ logOk(`Enabled ${email}`);
2691
+ }
2692
+ } catch (e) {
2693
+ logErr((e as Error).message);
2694
+ }
2695
+ break;
2696
+ }
2697
+ case "6": {
2698
+ await cmdAccounts(["list"]);
2699
+ const email = await askQuestion(rl, "Email to remove: ");
2700
+ if (!email) break;
2701
+ const conf = await askQuestion(rl, `Confirm removal of ${email}? Type YES: `);
2702
+ if (conf !== "YES") {
2703
+ logWarn("Cancelled");
2704
+ break;
2705
+ }
2706
+ const ok = mgr.removeAccount(email);
2707
+ if (ok) logOk(`Removed ${email}`);
2708
+ else logErr("Not found");
2709
+ break;
2710
+ }
2711
+ case "7": {
2712
+ const all = (await askQuestion(rl, "All accounts? (y/N): ")).toLowerCase();
2713
+ const json = (await askQuestion(rl, "JSON output? (y/N): ")).toLowerCase();
2714
+ const args: string[] = [];
2715
+ if (/^(y|yes)$/.test(all)) args.push("--all");
2716
+ if (/^(y|yes)$/.test(json)) args.push("--json");
2717
+ await cmdQuota(args);
2718
+ break;
2719
+ }
2720
+ case "8": {
2721
+ await cmdConfig(["list"]);
2722
+ const k = await askQuestion(rl, "Key to change (or enter to go back): ");
2723
+ if (!k) break;
2724
+ const v = await askQuestion(rl, `New value for ${k}: `);
2725
+ if (!v) break;
2726
+ await cmdConfig(["set", k, v]);
2727
+ break;
2728
+ }
2729
+ case "9": {
2730
+ const api = await askQuestion(rl, "Filter API (antigravity/gemini-cli/enter for all): ");
2731
+ if (api) await cmdModels(["list", "--api", api]);
2732
+ else await cmdModels(["list"]);
2733
+ const mid = await askQuestion(rl, "Show model info (id) or enter to go back: ");
2734
+ if (mid) await cmdModels(["info", mid]);
2735
+ break;
2736
+ }
2737
+ case "10": {
2738
+ await cmdStatus([]);
2739
+ await cmdDoctor([]);
2740
+ break;
2741
+ }
2742
+ case "11": {
2743
+ await cmdVersion([]);
2744
+ break;
2745
+ }
2746
+ case "12": {
2747
+ const allAns = (await askQuestion(rl, "Logout ALL accounts? (y/N): ")).toLowerCase();
2748
+ await logoutFlow(undefined, /^(y|yes)$/.test(allAns), rl);
2749
+ break;
2750
+ }
2751
+ case "13": {
2752
+ await cmdManage([], rl);
2753
+ break;
2754
+ }
2755
+ case "14": {
2756
+ const gAns = (
2757
+ await askQuestion(rl, "Use global ~/.config/opencode/opencode.json instead of cwd/opencode.json? (y/N): ")
2758
+ ).toLowerCase();
2759
+ await configureModelsFlow({ global: /^(y|yes)$/.test(gAns) });
2760
+ break;
2761
+ }
2762
+ case "0":
2763
+ case "q":
2764
+ case "quit":
2765
+ case "exit": {
2766
+ exit = true;
2767
+ break;
2768
+ }
2769
+ default:
2770
+ logWarn("Invalid option");
2771
+ break;
2772
+ }
2773
+ if (!exit) {
2774
+ await askQuestion(rl, C.dim("\n[press enter to continue]"));
2775
+ }
2776
+ }
2777
+ rl.close();
2778
+ console.log(C.green("\nGoodbye!"));
2779
+ }
2780
+
2781
+ // ===========================================================================
2782
+ // 07. HELP & MAIN ROUTER
2783
+ // ===========================================================================
2784
+ function printHelp(): void {
2785
+ console.log(
2786
+ C.bold(`
2787
+ devthink provider CLI (unified merge) - production-ready
2788
+ Writes to ~/.config/opencode/ (600) atomically | honors OPENCODE_CONFIG_DIR
2789
+ Bypass: client_id 681255809395-oo8f…b135j / UA GeminiCLI/0.57.0 / gl-node/22.19.0 / Client-Metadata IDE_UNSPECIFIED / project rising-fact-p41fc
2790
+
2791
+ Usage:
2792
+ cli.ts <command> [subcommand] [flags]
2793
+
2794
+ Commands:
2795
+ login [--no-browser] [--timeout <ms>] [--email <email>] [--fresh]
2796
+ OAuth PKCE Antigravity via oauth.ts (local browser callback on 127.0.0.1).
2797
+ Interactive mode selection: 1 = add accounts, 2 = fresh start (wipe all).
2798
+ Writes a debugLogger audit line on success.
2799
+
2800
+ logout [<email>] [--all]
2801
+ Revoke the Google refresh token (oauth2.googleapis.com/revoke) and remove
2802
+ the account. Without an email: single-account auto pick or interactive
2803
+ chooser. --all removes every account after confirmation.
2804
+
2805
+ accounts list [--json]
2806
+ accounts add --email <email> --refresh-token <token> [--project <id>]
2807
+ accounts remove <email>
2808
+ accounts enable <email>
2809
+ accounts disable <email> [--reason <text>]
2810
+ accounts set-active <email> [--sticky]
2811
+ accounts import [--path <file>]
2812
+ Rich listing: store version, activeIndexByFamily, addedAt/created/expiry,
2813
+ remaining/limit, rateLimits, soft-quota cooldowns.
2814
+
2815
+ enable <email>
2816
+ disable <email>
2817
+ Top-level single-account toggles.
2818
+
2819
+ manage
2820
+ Interactive per-account enable/disable toggle (robin-hood v3 store).
2821
+
2822
+ quota [email] [--all] [--json] [--email <email>]
2823
+ Checks quotas via quota.ts (daily-cloudcode-pa + prod) with config
2824
+ thresholds; persists remaining/limit back into the accounts file.
2825
+
2826
+ config list [--json]
2827
+ config get <key>
2828
+ config set <key> <value>
2829
+ config reset
2830
+ Tunables: soft_quota_threshold_percent (0-100), quota_refresh_interval_minutes
2831
+ (1-1440), toast_scope, cli_first, quota_fallback, etc. Model catalog
2832
+ ALL_MODELS_2026 from constants.ts.
2833
+
2834
+ models list [--api antigravity|gemini-cli] [--json]
2835
+ models info <model-id>
2836
+
2837
+ configure [--global] [--path <file>]
2838
+ Writes model definitions into opencode.json: provider.google.models
2839
+ (2026-08-25 catalog with thinkingLevel variants incl claude-sonnet
2840
+ low/high and claude-opus budgets), provider.antigravity.models (rich
2841
+ bypass metadata) and top-level model shortcuts. Atomic chmod 600 write.
2842
+
2843
+ status
2844
+ Accounts summary + config + permissions + quick quota
2845
+
2846
+ doctor | diagnostics
2847
+ File/permission/token-expiry diagnostics, logs dir, default model,
2848
+ opencode.json location, raw accounts file head (first 1000 chars)
2849
+
2850
+ version [--json]
2851
+ Plugin + remote antigravity manifest versions
2852
+
2853
+ menu | interactive
2854
+ help | --help | -h
2855
+
2856
+ Env:
2857
+ OPENCODE_CONFIG_DIR Override ~/.config/opencode
2858
+ ANTIGRAVITY_DEBUG Debug logging (1/true/debug)
2859
+ ANTIGRAVITY_CLI_AUTORUN Set to 0 to disable auto-run when executed directly
2860
+ NO_COLOR Disable ANSI colors
2861
+
2862
+ No args => interactive menu (readline/promises), zero deps.
2863
+
2864
+ Examples:
2865
+ npx tsx cli.ts login --no-browser
2866
+ npx tsx cli.ts logout --all
2867
+ npx tsx cli.ts accounts list
2868
+ npx tsx cli.ts enable user@example.com
2869
+ npx tsx cli.ts quota --all
2870
+ npx tsx cli.ts config set soft_quota_threshold_percent 85
2871
+ npx tsx cli.ts configure --global
2872
+ npx tsx cli.ts models list --api antigravity
2873
+ npx tsx cli.ts status
2874
+ `),
2875
+ );
2876
+ }
2877
+
2878
+ /**
2879
+ * Family entry of the grand merge: the single devthink binary routes the
2880
+ * extension command family here (`devthink ext <command>`); the argv is
2881
+ * spliced into the process position the original entry reading expects,
2882
+ * the family main runs untouched and the caller argv is restored.
2883
+ *
2884
+ * @param argv the family arguments (without the family prefix).
2885
+ */
2886
+ export async function runproviderfamily(argv: string[]): Promise<void> {
2887
+ const saved = process.argv.slice();
2888
+ process.argv = [saved[0], saved[1], ...argv];
2889
+ try {
2890
+ await providerconsolemain();
2891
+ } finally {
2892
+ process.argv = saved;
2893
+ }
2894
+ }
2895
+
2896
+ async function providerconsolemain(): Promise<void> {
2897
+ const argv = process.argv.slice(2);
2898
+ const cmd = (argv[0] ?? "").toLowerCase();
2899
+
2900
+ /** Read the value of a "--name value" / "--name=value" style flag. */
2901
+ const getFlagValue = (name: string): string | undefined => {
2902
+ const idx = argv.findIndex((a) => a === `--${name}` || a.startsWith(`--${name}=`));
2903
+ if (idx === -1) return undefined;
2904
+ const a = argv[idx];
2905
+ if (a && a.includes("=")) return a.split("=").slice(1).join("=").trim();
2906
+ return argv[idx + 1]?.trim();
2907
+ };
2908
+
2909
+ // If no command, interactive
2910
+ if (!cmd || cmd === "menu" || cmd === "interactive" || cmd === "i") {
2911
+ if (argv.includes("--help") || argv.includes("-h")) {
2912
+ printHelp();
2913
+ return;
2914
+ }
2915
+ // check if TTY; if not TTY and no args, show help
2916
+ if (!process.stdout.isTTY && !argv.length) {
2917
+ printHelp();
2918
+ return;
2919
+ }
2920
+ if (!argv.length) {
2921
+ await interactiveMenu();
2922
+ return;
2923
+ }
2924
+ }
2925
+
2926
+ switch (cmd) {
2927
+ case "help":
2928
+ case "--help":
2929
+ case "-h":
2930
+ printHelp();
2931
+ break;
2932
+ case "login":
2933
+ case "auth":
2934
+ case "signin":
2935
+ await cmdLogin(argv.slice(1));
2936
+ break;
2937
+ case "logout":
2938
+ case "signout": {
2939
+ const restArgs = argv.slice(1);
2940
+ const emailArg = restArgs.find((a) => !a.startsWith("-") && a.includes("@"));
2941
+ const allFlag = restArgs.some((a) => a === "--all" || a === "-a");
2942
+ await logoutFlow(emailArg, allFlag);
2943
+ break;
2944
+ }
2945
+ case "accounts":
2946
+ case "account":
2947
+ case "acc":
2948
+ await cmdAccounts(argv.slice(1));
2949
+ break;
2950
+ case "enable": {
2951
+ const email = argv.slice(1).find((a) => !a.startsWith("-") && a.includes("@")) ?? getFlagValue("email");
2952
+ if (!email) {
2953
+ logErr("enable requires an email: enable <email> [--email <email>]");
2954
+ process.exitCode = 2;
2955
+ break;
2956
+ }
2957
+ await enableAccountFlow(email);
2958
+ break;
2959
+ }
2960
+ case "disable": {
2961
+ const email = argv.slice(1).find((a) => !a.startsWith("-") && a.includes("@")) ?? getFlagValue("email");
2962
+ if (!email) {
2963
+ logErr("disable requires an email: disable <email> [--email <email>]");
2964
+ process.exitCode = 2;
2965
+ break;
2966
+ }
2967
+ await disableAccountFlow(email);
2968
+ break;
2969
+ }
2970
+ case "manage":
2971
+ case "accounts-manage":
2972
+ await cmdManage(argv.slice(1));
2973
+ break;
2974
+ case "quota":
2975
+ case "quotas":
2976
+ case "q":
2977
+ await cmdQuota(argv.slice(1));
2978
+ break;
2979
+ case "config":
2980
+ case "cfg":
2981
+ case "settings":
2982
+ await cmdConfig(argv.slice(1));
2983
+ break;
2984
+ case "configure":
2985
+ case "configure-models":
2986
+ case "config-models":
2987
+ case "setup": {
2988
+ const globalFlag = argv.includes("--global") || argv.includes("-g");
2989
+ const customPath = getFlagValue("path") ?? getFlagValue("file") ?? argv.slice(1).find((a) => a.endsWith(".json"));
2990
+ await configureModelsFlow({ global: globalFlag, customPath });
2991
+ break;
2992
+ }
2993
+ case "models":
2994
+ case "model":
2995
+ case "m":
2996
+ await cmdModels(argv.slice(1));
2997
+ break;
2998
+ case "status":
2999
+ case "st":
3000
+ await cmdStatus(argv.slice(1));
3001
+ break;
3002
+ case "doctor":
3003
+ case "doc":
3004
+ case "check":
3005
+ case "diagnostics":
3006
+ await cmdDoctor(argv.slice(1));
3007
+ break;
3008
+ case "version":
3009
+ case "ver":
3010
+ case "v":
3011
+ await cmdVersion(argv.slice(1));
3012
+ break;
3013
+ default:
3014
+ logErr(`Unknown command: ${cmd}`);
3015
+ printHelp();
3016
+ process.exit(1);
3017
+ }
3018
+ }
3019
+
3020
+ export type { ModelCatalogEntry, OpenCodeJsonLocation };
3021
+ // ---------------------------------------------------------------------------
3022
+ // Public API (~30 exports incl runCLI + default { runCLI, interactiveMenu })
3023
+ // ---------------------------------------------------------------------------
3024
+ export {
3025
+ ANSI,
3026
+ atomicWriteFileAtomic,
3027
+ atomicWriteFileSecure,
3028
+ buildOpenCodeModelDefinitions,
3029
+ C,
3030
+ CLI_VERSION,
3031
+ cmdAccounts,
3032
+ cmdConfig,
3033
+ cmdDoctor,
3034
+ cmdLogin,
3035
+ cmdManage,
3036
+ cmdModels,
3037
+ cmdQuota,
3038
+ cmdStatus,
3039
+ cmdVersion,
3040
+ configureModelsFlow,
3041
+ confirm,
3042
+ disableAccountFlow,
3043
+ enableAccountFlow,
3044
+ fetchQuotaForAccount,
3045
+ fmtDuration,
3046
+ getDefaultAccountManager as getAccountManager,
3047
+ interactiveMenu,
3048
+ locateOpenCodeJson,
3049
+ logoutFlow,
3050
+ printHeader,
3051
+ printHelp,
3052
+ providerconsolemain as runCLI,
3053
+ redactEmail,
3054
+ resolveAccountsPath,
3055
+ resolveAntigravityJsonPath,
3056
+ resolveBaseDir,
3057
+ resolveConfigPath,
3058
+ resolveOpenCodeJsonCandidates,
3059
+ };
3060
+
3061
+ export const providercli = {
3062
+ runCLI: providerconsolemain,
3063
+ interactiveMenu,
3064
+ };
3065
+
3066
+ /* ── Merged: the grand merge section ── the correlated provider family library barrel logics of the merged repository interned here, one surface without duplicate variations ── */
3067
+ /**
3068
+ * @file providercli.ts — the barrel section of the provider family.
3069
+ * @description The library barrel of the merged provider family —
3070
+ * library-first, root-first. The provider family modules of the merged
3071
+ * repository live as the flat root modules (constants, config, models,
3072
+ * accounts, fingerprint, project, quota, recovery, search, core, request,
3073
+ * streaming, oauth, antigravity, devthink, server) and every import path
3074
+ * of the provider surface resolves through this barrel.
3075
+ *
3076
+ * The namespace re-exports keep the historical surface: several siblings
3077
+ * (constants/config/oauth/accounts/fingerprint/search) intentionally share
3078
+ * exported names such as PROJECT_FALLBACK, MODELS_2026 or OAUTH_SCOPES —
3079
+ * every feature stays reachable through its module namespace below. The
3080
+ * consolidation history rides the mapping: the provider oauth family lives
3081
+ * in oauth.ts, request-helpers merged into request.ts, system merged into
3082
+ * config.ts (with the debug utilities in debug.ts) — the OAuth,
3083
+ * RequestHelpers and System namespaces below alias the consolidated modules
3084
+ * so every historical import path keeps resolving.
3085
+ */
3086
+
3087
+ export * as Accounts from "./accounts.js";
3088
+ export * as Constants from "@wenathlan/devthink/constants";
3089
+ export * as Core from "@wenathlan/devthink/core";
3090
+ export * as Fingerprint from "./fingerprint.js";
3091
+ // the provider oauth family answers the OAuth namespace (the grand merge
3092
+ // consolidation interned the family in oauth.ts — node surface).
3093
+ export * as OAuth from "./oauth.js";
3094
+ // the provider oauth family answers the AntigravityCli compat namespace.
3095
+ export * as AntigravityCli from "./oauth.js";
3096
+ // merged group additions: auth/core/system/models namespaces
3097
+ export * as Auth from "./oauth.js";
3098
+
3099
+ /** System — the v2.1.14 system utilities surface: config.ts absorbed the
3100
+ * system module (cfgDir, the version fallback chain, atomic writes, the
3101
+ * legacy validator aliases and the raw JSON IO helpers) while the debug
3102
+ * utilities (logsDir, redactSecrets, the debugLogger singleton) live in
3103
+ * debug.ts, so the namespace carries exactly the exports the system
3104
+ * shim used to publish. */
3105
+
3106
+
3107
+
3108
+ export const System = {
3109
+ atomicWrite,
3110
+ cfgDir,
3111
+ fetchVersionChain,
3112
+ loadConfigRaw,
3113
+ saveConfigRaw,
3114
+ validateEmail,
3115
+ validateModelId,
3116
+ validateModelIdCharset,
3117
+ validateProjectId,
3118
+ validateSemver,
3119
+ VERSION_FALLBACK,
3120
+ debugLogger,
3121
+ logsDir,
3122
+ redactSecrets,
3123
+ };
3124
+ export * as PluginModule from "./antigravity.js";
3125
+ // default plugin export for opencode compatibility (the antigravity plugin family)
3126
+ export { default as opencodePluginModule, getPlugin, plugin } from "./antigravity.js";
3127
+ export * as Config from "@wenathlan/devthink/config";
3128
+ export * as Debug from "@wenathlan/devthink/core";
3129
+ export * as Models from "./models.js";
3130
+ export * as Models2026 from "./models.js";
3131
+ export * as Project from "./project.js";
3132
+ export * as Quota from "./quota.js";
3133
+ export * as Recovery from "./recovery.js";
3134
+ // v2.1.14: request-helpers.ts was consolidated into request.ts; namespace kept for compat.
3135
+ export * as RequestHelpers from "./request.js";
3136
+ export * as Request from "./request.js";
3137
+ export * as Search from "./search.js";
3138
+ // the CLI namespace left the providers barrel (the browser purity law):
3139
+ // cli.js value-imports node:process (stdin, stdout) and ./server.js, so the
3140
+ // namespace re-export dragged the whole hono server stack into every
3141
+ // browser consumer of this barrel — the dev boot died on the externalized
3142
+ // node:process stub and the serve-static module. The CLI keeps its own
3143
+ // published face (@wenathlan/getry/cli) and the os server re-exports it
3144
+ // directly from the owner; nothing in the tree consumed providers.CLI.
3145
+ export * as Streaming from "./streaming.js";
3146
+ export * as Version from "./versionregistry.js";
3147
+
3148
+ // v2.1.16 single-owner doctrine: VERSION derives from constants.js
3149
+ // PLUGIN_VERSION (raw value owner) and the two bypass aggregates assemble
3150
+ // every value from its owner module instead of locally-reassembled literals.
3151
+
3152
+
3153
+ /* the identity snapshot import rides the account console cli section above — one declaration per binding */
3154
+
3155
+ // version / identity
3156
+ export const VERSION: string = PLUGIN_VERSION;
3157
+ export const PLUGIN_ID = "devthink" as const;
3158
+
3159
+ // headline model catalog re-exports
3160
+ export { ALL_MODELS_2026, MODEL_BY_ID, MODEL_ROUTING } from "./models.js";
3161
+
3162
+ /**
3163
+ * Gemini CLI bypass aggregate: exact identification the official Gemini CLI
3164
+ * sends when talking to cloudcode-pa. Consumed by bypass flows that must not
3165
+ * require a user project id. Every value is imported from its owner — the
3166
+ * OAuth pair, UA, Client-Metadata string, project fallback and base URL from
3167
+ * constants.js, the gl-node snapshot from fingerprint.js and the v1internal
3168
+ * method paths via constants.js CODE_ASSIST_PATH_MAP.
3169
+ */
3170
+ export const GEMINI_CLI_BYPASS = {
3171
+ clientId: GEMINI_CLI_OAUTH_CLIENT_ID,
3172
+ clientSecret: GEMINI_CLI_OAUTH_CLIENT_SECRET,
3173
+ userAgent: GEMINI_CLI_USER_AGENT,
3174
+ xGoogApiClient: X_GOOG_API_CLIENT_GEMINI_CLI,
3175
+ clientMetadata: CLIENT_METADATA_STRING,
3176
+ fallbackProject: PROJECT_FALLBACK,
3177
+ endpoints: {
3178
+ loadCodeAssist: `${CLOUDCODE_BASE_URL}${CODE_ASSIST_PATH_MAP.LOAD_CODE_ASSIST}`,
3179
+ generateContent: `${CLOUDCODE_BASE_URL}${CODE_ASSIST_PATH_MAP.GENERATE_CONTENT}`,
3180
+ streamGenerateContent: `${CLOUDCODE_BASE_URL}${CODE_ASSIST_PATH_MAP.STREAM_GENERATE_CONTENT}`,
3181
+ },
3182
+ /** Isolated credential dir — resolved per-OS at runtime, never hardcoded. */
3183
+ isolatedDir: "MAENE_ISOLATED_DIR | <opencode-base>/auth",
3184
+ } as const;
3185
+
3186
+ /**
3187
+ * Antigravity CLI bypass aggregate: identification of the official `agy`
3188
+ * binary (extracted from the distributed CLI and live-validated 2026-08-26).
3189
+ * The client the plugin presents for NEW logins since 2.1.8: the client pair,
3190
+ * scopes, official redirect URI and version parts come from oauth.js
3191
+ * (the authentication owner — CLIENT_ID/CLIENT_SECRET are env-overridable
3192
+ * there, so the aggregate tracks the same override); the UA and
3193
+ * X-Goog-Api-Client keep their static linux/amd64 snapshot shape, composed
3194
+ * from the oauth.js version constants.
3195
+ */
3196
+ export const ANTIGRAVITY_CLI_BYPASS = {
3197
+ clientId: ANTIGRAVITY_CLI_CLIENT_ID,
3198
+ clientSecret: ANTIGRAVITY_CLI_CLIENT_SECRET,
3199
+ userAgent: `antigravity-cli/${ANTIGRAVITY_CLI_VERSION_LATEST} (linux; amd64)`,
3200
+ xGoogApiClient: `antigravity-cli/${ANTIGRAVITY_CLI_VERSION_LATEST} grpc-go/${ANTIGRAVITY_CLI_GRPC_VERSION}`,
3201
+ redirectUriOfficial: ANTIGRAVITY_CLI_REDIRECT_OFFICIAL,
3202
+ scopes: ANTIGRAVITY_CLI_OAUTH_SCOPES,
3203
+ usesPKCE: true,
3204
+ appLabel: "Google Antigravity",
3205
+ } as const;
3206
+
3207
+ /* ── Merged: the grand merge section ── the correlated opencode server plugin entry logics of the merged repository interned here, one surface without duplicate variations ── */
3208
+ /**
3209
+ * @file providercli.ts — the opencode `./server` package entry (v2.1.20).
3210
+ *
3211
+ * opencode >= 1.18 resolves package.json exports["./server"] FIRST when
3212
+ * loading a server plugin. This entry uses the multi-export plugin shape so
3213
+ * that a SINGLE plugin reference registers THREE auth entries in
3214
+ * `opencode auth login` (and in the TUI "Connect a provider" dialog):
3215
+ *
3216
+ * - provider "google" → the standard Google entry, OAuth method
3217
+ * - provider "antigravity" → the standard Antigravity entry, OAuth method
3218
+ * - the CUSTOM provider detected by MODEL IDS in opencode.json
3219
+ * ("casasbahia", "my-google", ... — whatever the user named it)
3220
+ *
3221
+ * opencode's loader treats every exported FUNCTION as a plugin instance and
3222
+ * merges the returned hooks, which is exactly what we need: one instance
3223
+ * returns the main hooks (identification headers, config injection, the
3224
+ * custom-provider auth) and two more instances return the standard google /
3225
+ * antigravity auth hooks.
3226
+ *
3227
+ * IMPORTANT CONTRACT: every export in this file MUST be a function —
3228
+ * opencode's legacy loader throws "Plugin export is not a function" for any
3229
+ * other export shape. Never re-export constants, objects or types from here;
3230
+ * library consumers keep importing from the package root (plugin.ts).
3231
+ *
3232
+ * (the ownership move of the gateway ownership wave, step 5: the three
3233
+ * entries merged into the antigravity owner — providerServer/googleAuth/
3234
+ * antigravityAuth live in antigravity.ts beside opencodeServer and
3235
+ * buildOpencodeAuthHook — and re-exported here for compat.)
3236
+ */
3237
+
3238
+ export { providerServer, googleAuth, antigravityAuth } from "./antigravity.js";