clauderipple 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.ko.md +18 -2
  3. package/README.md +27 -5
  4. package/dist/app/package.json +1 -1
  5. package/dist/cli/src/claude-auth.js +35 -1
  6. package/dist/cli/src/codex.js +23 -0
  7. package/dist/cli/src/hooks/agent-title.js +58 -29
  8. package/dist/cli/src/index.js +46 -1
  9. package/dist/cli/src/picker.js +19 -11
  10. package/dist/cli/src/runtime.js +3 -0
  11. package/dist/cli/src/settings.js +49 -0
  12. package/dist/mod/.claude-plugin/plugin.json +1 -0
  13. package/dist/mod/hooks/hooks.json +1 -0
  14. package/dist/mod/hooks/register.tsx +309 -0
  15. package/dist/mod/hooks/worker.ts +49 -0
  16. package/dist/mod/tsconfig.json +3 -0
  17. package/dist/mod/types/index.d.ts +30 -0
  18. package/dist/router/src/admin.js +411 -32
  19. package/dist/router/src/agents.js +24 -8
  20. package/dist/router/src/catalog.js +80 -0
  21. package/dist/router/src/compat.js +27 -1
  22. package/dist/router/src/config.js +31 -0
  23. package/dist/router/src/index.js +12 -1
  24. package/dist/router/src/ingress/server.js +3 -2
  25. package/dist/router/src/picker.js +33 -2
  26. package/dist/router/src/presets.js +2 -1
  27. package/dist/router/src/providers/chatgpt/index.js +77 -18
  28. package/dist/router/src/providers/chatgpt/translate.js +34 -6
  29. package/dist/router/src/providers/google/accounts.js +345 -0
  30. package/dist/router/src/providers/google/antigravity.js +392 -0
  31. package/dist/router/src/providers/google/index.js +617 -0
  32. package/dist/router/src/providers/google/login.js +249 -0
  33. package/dist/router/src/providers/google/thoughtSignatures.js +47 -0
  34. package/dist/router/src/providers/google/translate.js +539 -0
  35. package/dist/router/src/providers/google/transport.js +118 -0
  36. package/dist/router/src/providers/openai/translate.js +3 -3
  37. package/dist/router/src/proxy.js +116 -9
  38. package/dist/router/src/requestlog.js +2 -0
  39. package/dist/router/src/routing.js +22 -0
  40. package/dist/router/src/version.js +1 -1
  41. package/dist/ui/app.js +420 -45
  42. package/dist/ui/i18n.js +76 -4
  43. package/dist/ui/index.html +5 -0
  44. package/dist/ui/style.css +4 -0
  45. package/docs/ARCHITECTURE.md +307 -4
  46. package/package.json +1 -1
@@ -0,0 +1,345 @@
1
+ // Google Antigravity (Cloud Code Assist) accounts: several Google logins can be signed in, and a
2
+ // turn moves to the next one when the account it is on runs out.
3
+ //
4
+ // Stored in <home>/google-accounts.json (0600, cross-process lock, atomic write), the same shape as
5
+ // the ChatGPT pool. We refresh these; a refresh token Google has invalidated marks only that account
6
+ // for a new sign-in. Identity is the account email plus the discovered Cloud Code Assist project id.
7
+ //
8
+ // Nothing here has been measured against a live Google endpoint — there is no subscription in this
9
+ // environment. The wire facts (token endpoint shape, `invalid_grant`, project discovery) are taken
10
+ // from the reference implementation opencodex as a behavioral spec (src/oauth/google-antigravity.ts)
11
+ // and marked (assumption) in docs/ARCHITECTURE.md §4d.
12
+ import crypto from "node:crypto";
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import { redactErrorText } from "../../redact.js";
16
+ import { withFileLock, writeJsonAtomic } from "../../locked-file.js";
17
+ import { GOOGLE_OAUTH } from "./antigravity.js";
18
+ export function googleAccountsPath(home) {
19
+ return path.join(home, "google-accounts.json");
20
+ }
21
+ function validAccount(value) {
22
+ if (!value || typeof value !== "object" || Array.isArray(value))
23
+ return false;
24
+ const a = value;
25
+ return typeof a.id === "string" && a.id.length > 0
26
+ && typeof a.accessToken === "string" && a.accessToken.length > 0
27
+ && (a.refreshToken === undefined || typeof a.refreshToken === "string")
28
+ && typeof a.projectId === "string" && a.projectId.length > 0
29
+ && typeof a.expiresAt === "number" && Number.isFinite(a.expiresAt)
30
+ && typeof a.label === "string" && a.label.length > 0
31
+ && typeof a.createdAt === "string" && typeof a.updatedAt === "string"
32
+ && (a.email === undefined || typeof a.email === "string")
33
+ && (a.needsReauth === undefined || typeof a.needsReauth === "boolean")
34
+ && (a.paused === undefined || typeof a.paused === "boolean");
35
+ }
36
+ /** The file's accounts; [] when absent, null when unreadable (so a mutation refuses to overwrite it). */
37
+ function parseAccountsFile(home) {
38
+ try {
39
+ const parsed = JSON.parse(fs.readFileSync(googleAccountsPath(home), "utf8"));
40
+ if (parsed.version !== 1 || !Array.isArray(parsed.accounts) || !parsed.accounts.every(validAccount))
41
+ return null;
42
+ const seen = new Set();
43
+ return parsed.accounts.filter((account) => !seen.has(account.id) && seen.add(account.id)).map((account) => ({ ...account }));
44
+ }
45
+ catch (error) {
46
+ return error.code === "ENOENT" ? [] : null;
47
+ }
48
+ }
49
+ export function readGoogleAccounts(home) {
50
+ return parseAccountsFile(home) ?? [];
51
+ }
52
+ function readForMutation(home) {
53
+ const accounts = parseAccountsFile(home);
54
+ if (accounts === null)
55
+ throw new Error("Google account store is unreadable; refusing to overwrite it");
56
+ return accounts;
57
+ }
58
+ function write(home, accounts) {
59
+ writeJsonAtomic(googleAccountsPath(home), { version: 1, accounts });
60
+ }
61
+ function mutate(home, change) {
62
+ return withFileLock(googleAccountsPath(home), () => {
63
+ const { accounts, result } = change(readForMutation(home));
64
+ if (accounts)
65
+ write(home, accounts);
66
+ return result;
67
+ });
68
+ }
69
+ /** Same person: the email when both have one, else the same Cloud Code Assist project. */
70
+ function sameIdentity(a, b) {
71
+ if (a.email && b.email)
72
+ return a.email.toLowerCase() === b.email.toLowerCase();
73
+ return a.projectId === b.projectId;
74
+ }
75
+ /** Add a signed-in account, or replace the same one after a re-login. */
76
+ export function saveGoogleAccount(home, grant, now = new Date().toISOString()) {
77
+ return mutate(home, (accounts) => {
78
+ const key = { ...(grant.email ? { email: grant.email } : {}), projectId: grant.projectId };
79
+ const index = accounts.findIndex((account) => sameIdentity(account, key));
80
+ const previous = index >= 0 ? accounts[index] : undefined;
81
+ const account = {
82
+ id: previous?.id ?? crypto.randomUUID(),
83
+ accessToken: grant.accessToken,
84
+ ...(grant.refreshToken ? { refreshToken: grant.refreshToken } : {}),
85
+ projectId: grant.projectId,
86
+ ...(grant.email ?? previous?.email ? { email: grant.email ?? previous.email } : {}),
87
+ expiresAt: grant.expiresAt,
88
+ label: previous?.label ?? grant.email ?? `Google account ${accounts.length + 1}`,
89
+ createdAt: previous?.createdAt ?? now,
90
+ updatedAt: now,
91
+ ...(previous?.paused ? { paused: true } : {}),
92
+ };
93
+ const next = [...accounts];
94
+ if (index >= 0)
95
+ next[index] = account;
96
+ else
97
+ next.push(account);
98
+ return { accounts: next, result: { ...summarize(account), added: index < 0 } };
99
+ });
100
+ }
101
+ /** Compare-and-swap a refreshed grant: an older concurrent refresh must not overwrite a newer one. */
102
+ export function replaceGoogleTokens(home, id, expectedRefreshToken, grant, now = new Date().toISOString()) {
103
+ return mutate(home, (accounts) => {
104
+ const index = accounts.findIndex((account) => account.id === id && account.refreshToken === expectedRefreshToken);
105
+ if (index < 0)
106
+ return { result: false };
107
+ const previous = accounts[index];
108
+ const next = [...accounts];
109
+ next[index] = {
110
+ ...previous,
111
+ accessToken: grant.accessToken,
112
+ ...(grant.refreshToken ? { refreshToken: grant.refreshToken } : {}),
113
+ projectId: grant.projectId || previous.projectId,
114
+ ...(grant.email ? { email: grant.email } : {}),
115
+ expiresAt: grant.expiresAt,
116
+ updatedAt: now,
117
+ needsReauth: false,
118
+ };
119
+ return { accounts: next, result: true };
120
+ });
121
+ }
122
+ /** Mark only the token generation that failed; a sign-in that landed meanwhile wins. */
123
+ export function markGoogleNeedsReauth(home, id, expectedAccessToken) {
124
+ return mutate(home, (accounts) => {
125
+ const index = accounts.findIndex((account) => account.id === id && account.accessToken === expectedAccessToken);
126
+ if (index < 0)
127
+ return { result: false };
128
+ const next = [...accounts];
129
+ next[index] = { ...next[index], needsReauth: true, updatedAt: new Date().toISOString() };
130
+ return { accounts: next, result: true };
131
+ });
132
+ }
133
+ export function updateGoogleAccount(home, id, change) {
134
+ const label = change.label?.replace(/[\x00-\x1f\x7f]/g, "").trim().slice(0, 80);
135
+ if (change.label !== undefined && !label)
136
+ return false;
137
+ return mutate(home, (accounts) => {
138
+ const index = accounts.findIndex((account) => account.id === id);
139
+ if (index < 0)
140
+ return { result: false };
141
+ const next = [...accounts];
142
+ const { paused: _paused, ...rest } = next[index];
143
+ next[index] = { ...rest, ...(label ? { label } : {}), ...((change.paused ?? next[index].paused) ? { paused: true } : {}), updatedAt: new Date().toISOString() };
144
+ return { accounts: next, result: true };
145
+ });
146
+ }
147
+ export function removeGoogleAccount(home, id) {
148
+ return mutate(home, (accounts) => {
149
+ const next = accounts.filter((account) => account.id !== id);
150
+ return next.length === accounts.length ? { result: false } : { accounts: next, result: true };
151
+ });
152
+ }
153
+ export function removeAllGoogleAccounts(home) {
154
+ return withFileLock(googleAccountsPath(home), () => {
155
+ const count = readGoogleAccounts(home).length;
156
+ fs.rmSync(googleAccountsPath(home), { force: true });
157
+ return count;
158
+ });
159
+ }
160
+ export function summarize(account) {
161
+ return {
162
+ id: account.id,
163
+ label: account.label,
164
+ ...(account.email ? { email: account.email } : {}),
165
+ projectId: account.projectId,
166
+ expiresAt: account.expiresAt,
167
+ needsReauth: account.needsReauth === true,
168
+ paused: account.paused === true,
169
+ };
170
+ }
171
+ // ---------------------------------------------------------------------------------------------
172
+ // Runtime
173
+ /** Refresh this long before expiry. A turn never waits for it while the old token still works. */
174
+ const REFRESH_LEAD_MS = GOOGLE_OAUTH.refreshLeadMs;
175
+ export class GoogleRefreshError extends Error {
176
+ terminal;
177
+ constructor(message, terminal) {
178
+ super(message);
179
+ this.terminal = terminal;
180
+ }
181
+ }
182
+ /**
183
+ * Whether a refused refresh means "sign in again". Only Google's `invalid_grant` counts; a 5xx whose
184
+ * body happens to mention "expired" must not retire a working account. Prose is read only when the
185
+ * body carries no `error` code at all and only for a 400/401.
186
+ */
187
+ export function refreshRejectionIsTerminal(status, body) {
188
+ let code;
189
+ try {
190
+ const parsed = JSON.parse(body);
191
+ if (typeof parsed.error === "string")
192
+ code = parsed.error;
193
+ }
194
+ catch {
195
+ /* not JSON */
196
+ }
197
+ if (code)
198
+ return code === "invalid_grant";
199
+ return (status === 400 || status === 401) && /\binvalid_grant\b/i.test(body);
200
+ }
201
+ /**
202
+ * Exchange a refresh token for a new grant. Google's token endpoint takes a form-encoded body with
203
+ * the client id and secret (this is a public installed-app client). Throws GoogleRefreshError.
204
+ * Source: opencodex `postToken` / `refreshAntigravityToken`.
205
+ */
206
+ export async function refreshGoogleGrant(refreshToken, previous, fetchImpl = fetch, now = Date.now) {
207
+ let res;
208
+ try {
209
+ res = await fetchImpl(GOOGLE_OAUTH.tokenUrl, {
210
+ method: "POST",
211
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
212
+ body: new URLSearchParams({ grant_type: "refresh_token", client_id: GOOGLE_OAUTH.clientId, client_secret: GOOGLE_OAUTH.clientSecret, refresh_token: refreshToken }).toString(),
213
+ signal: AbortSignal.timeout(GOOGLE_OAUTH.requestTimeoutMs),
214
+ });
215
+ }
216
+ catch (error) {
217
+ throw new GoogleRefreshError(`token refresh unreachable: ${error.message}`, false);
218
+ }
219
+ const text = await res.text().catch(() => "");
220
+ if (!res.ok)
221
+ throw new GoogleRefreshError(`token refresh failed: HTTP ${res.status} ${redactErrorText(text, [refreshToken], 200)}`, refreshRejectionIsTerminal(res.status, text));
222
+ let j;
223
+ try {
224
+ j = JSON.parse(text);
225
+ }
226
+ catch {
227
+ throw new GoogleRefreshError("token refresh answered with something other than JSON", false);
228
+ }
229
+ if (!j.access_token)
230
+ throw new GoogleRefreshError("token refresh answered without an access token", false);
231
+ const expiresIn = typeof j.expires_in === "number" && Number.isFinite(j.expires_in) && j.expires_in > 0 ? j.expires_in : 3600;
232
+ // The project was settled at sign-in (an account cannot be stored without one), so a refresh does
233
+ // not ask again: discovery can poll `onboardUser` for seconds, once an hour, for an answer it has.
234
+ return {
235
+ accessToken: j.access_token,
236
+ refreshToken: j.refresh_token ?? refreshToken,
237
+ projectId: previous.projectId,
238
+ ...(previous.email ? { email: previous.email } : {}),
239
+ expiresAt: now() + expiresIn * 1000,
240
+ };
241
+ }
242
+ /** One refresh per token generation across every adapter in this process. */
243
+ const refreshing = new Map();
244
+ function credentialOf(ownerId, label, accessToken, projectId) {
245
+ // The runtime id is the account itself, not its token: a usage limit belongs to the account, and a
246
+ // refresh must not bring a spent account back early.
247
+ return { id: ownerId, ownerId, label, accessToken, projectId, headers: { authorization: `Bearer ${accessToken}` } };
248
+ }
249
+ /**
250
+ * The Antigravity accounts one google provider may use. Refreshes what is due without holding a turn
251
+ * up, and can force a refresh when the backend refuses a token that has not expired yet.
252
+ */
253
+ export class GoogleAccountPool {
254
+ home;
255
+ log;
256
+ fetchImpl;
257
+ now;
258
+ constructor(options) {
259
+ this.home = options.home;
260
+ this.log = options.log;
261
+ this.fetchImpl = options.fetch;
262
+ this.now = options.now ?? Date.now;
263
+ }
264
+ ownAccounts() {
265
+ return readGoogleAccounts(this.home);
266
+ }
267
+ /** Accounts that could be sent right now, without network I/O. */
268
+ peekCredentials() {
269
+ const out = [];
270
+ for (const account of this.ownAccounts()) {
271
+ if (account.paused || account.needsReauth || this.now() >= account.expiresAt)
272
+ continue;
273
+ out.push(credentialOf(account.id, account.label, account.accessToken, account.projectId));
274
+ }
275
+ return out;
276
+ }
277
+ /** Accounts for one turn. Waits for a refresh only when nothing could be sent otherwise. */
278
+ async credentials() {
279
+ const ready = this.peekCredentials();
280
+ const due = this.refreshDue();
281
+ if (ready.length > 0) {
282
+ void due.catch((error) => this.log.warn(`google account refresh task failed: ${error.message}`));
283
+ return ready;
284
+ }
285
+ await due;
286
+ return this.peekCredentials();
287
+ }
288
+ /** Refresh every stored account inside its lead window. */
289
+ async refreshDue() {
290
+ const due = this.ownAccounts().filter((account) => !account.needsReauth && account.refreshToken && this.now() >= account.expiresAt - REFRESH_LEAD_MS);
291
+ await Promise.all(due.map((account) => this.refresh(account)));
292
+ }
293
+ /** The project id for an owner, from the store. */
294
+ projectFor(ownerId) {
295
+ return this.ownAccounts().find((account) => account.id === ownerId)?.projectId;
296
+ }
297
+ /**
298
+ * The backend refused this account's token before it expired. One refresh; true when a new token
299
+ * is in place.
300
+ */
301
+ async forceRefresh(ownerId) {
302
+ const account = this.ownAccounts().find((candidate) => candidate.id === ownerId);
303
+ if (!account?.refreshToken || account.needsReauth)
304
+ return false;
305
+ const before = account.accessToken;
306
+ await this.refresh(account);
307
+ const after = this.ownAccounts().find((candidate) => candidate.id === ownerId);
308
+ return !!after && !after.needsReauth && after.accessToken !== before;
309
+ }
310
+ /** A token the backend refused after a refresh: that account needs a new sign-in. */
311
+ reject(credential) {
312
+ if (markGoogleNeedsReauth(this.home, credential.ownerId, credential.accessToken)) {
313
+ this.log.warn(`google account ${credential.ownerId.slice(0, 8)}: token refused; sign-in required`);
314
+ }
315
+ }
316
+ refresh(account) {
317
+ const refreshToken = account.refreshToken;
318
+ const key = `${this.home}\0${account.id}\0${crypto.createHash("sha256").update(refreshToken).digest("hex")}`;
319
+ const running = refreshing.get(key);
320
+ if (running)
321
+ return running;
322
+ const task = refreshGoogleGrant(refreshToken, account, this.fetchImpl, this.now).then((grant) => {
323
+ if (replaceGoogleTokens(this.home, account.id, refreshToken, grant)) {
324
+ this.log.info(`google account ${account.id.slice(0, 8)}: token refreshed, valid until ${new Date(grant.expiresAt).toISOString()}`);
325
+ }
326
+ }, (error) => {
327
+ if (error instanceof GoogleRefreshError && error.terminal) {
328
+ markGoogleNeedsReauth(this.home, account.id, account.accessToken);
329
+ this.log.warn(`google account ${account.id.slice(0, 8)}: refresh rejected; sign-in required`);
330
+ }
331
+ else {
332
+ this.log.warn(`google account ${account.id.slice(0, 8)}: refresh failed: ${redactErrorText(error.message, [account.accessToken, refreshToken], 300)}`);
333
+ }
334
+ }).finally(() => refreshing.delete(key));
335
+ refreshing.set(key, task);
336
+ return task;
337
+ }
338
+ summaries() {
339
+ return this.ownAccounts().map(summarize);
340
+ }
341
+ /** Whether anything is signed in at all, expired or not — the "log in first" check. */
342
+ signedIn() {
343
+ return this.ownAccounts().length > 0;
344
+ }
345
+ }