opencode-copilot-account-switcher 0.12.4 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,763 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { stdin as input, stdout as output } from "node:process";
3
+ import { fetchQuota } from "../active-account-quota.js";
4
+ import { getGitHubToken, normalizeDomain } from "../copilot-api-helpers.js";
5
+ import { listAssignableAccountsForModel, listKnownCopilotModels, rewriteModelAccountAssignments, } from "../model-account-map.js";
6
+ import { applyMenuAction, persistAccountSwitch } from "../plugin-actions.js";
7
+ import { select, selectMany } from "../ui/select.js";
8
+ import { authPath, readAuth, readStore } from "../store.js";
9
+ const CLIENT_ID = "Ov23li8tweQw6odWQebz";
10
+ const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000;
11
+ function getUrls(domain) {
12
+ return {
13
+ DEVICE_CODE_URL: `https://${domain}/login/device/code`,
14
+ ACCESS_TOKEN_URL: `https://${domain}/login/oauth/access_token`,
15
+ };
16
+ }
17
+ async function sleep(ms) {
18
+ await new Promise((resolve) => setTimeout(resolve, ms));
19
+ }
20
+ function buildName(entry, login) {
21
+ const user = login ?? entry.user;
22
+ if (!user)
23
+ return entry.name;
24
+ if (!entry.enterpriseUrl)
25
+ return user;
26
+ const host = normalizeDomain(entry.enterpriseUrl);
27
+ return `${host}:${user}`;
28
+ }
29
+ function score(entry) {
30
+ return (entry.user ? 2 : 0) + (entry.email ? 2 : 0) + (entry.orgs?.length ? 1 : 0);
31
+ }
32
+ function key(entry) {
33
+ if (entry.refresh)
34
+ return `refresh:${entry.refresh}`;
35
+ return undefined;
36
+ }
37
+ function dedupe(store) {
38
+ const seen = new Map();
39
+ for (const [name, entry] of Object.entries(store.accounts)) {
40
+ const k = key(entry);
41
+ if (!k)
42
+ continue;
43
+ const current = seen.get(k);
44
+ if (!current) {
45
+ seen.set(k, name);
46
+ continue;
47
+ }
48
+ const currentEntry = store.accounts[current];
49
+ if (score(entry) > score(currentEntry)) {
50
+ rewriteModelAccountAssignments(store, { [current]: name });
51
+ delete store.accounts[current];
52
+ seen.set(k, name);
53
+ if (store.active === current)
54
+ store.active = name;
55
+ continue;
56
+ }
57
+ rewriteModelAccountAssignments(store, { [name]: current });
58
+ delete store.accounts[name];
59
+ if (store.active === name)
60
+ store.active = current;
61
+ }
62
+ }
63
+ function mergeAuth(store, imported) {
64
+ dedupe(store);
65
+ const byRefresh = new Map();
66
+ for (const [name, entry] of Object.entries(store.accounts)) {
67
+ if (entry.refresh)
68
+ byRefresh.set(entry.refresh, name);
69
+ }
70
+ for (const [providerKey, entry] of imported) {
71
+ const match = byRefresh.get(entry.refresh);
72
+ if (match) {
73
+ store.accounts[match] = {
74
+ ...store.accounts[match],
75
+ ...entry,
76
+ name: store.accounts[match].name,
77
+ source: "auth",
78
+ providerId: providerKey,
79
+ };
80
+ if (!store.active)
81
+ store.active = match;
82
+ continue;
83
+ }
84
+ const name = entry.name || `auth:${providerKey}`;
85
+ store.accounts[name] = {
86
+ ...entry,
87
+ name,
88
+ source: "auth",
89
+ providerId: providerKey,
90
+ };
91
+ if (!store.active)
92
+ store.active = name;
93
+ }
94
+ }
95
+ function renameAccounts(store, items) {
96
+ const counts = new Map();
97
+ const renamed = items.map((item) => {
98
+ const count = (counts.get(item.base) ?? 0) + 1;
99
+ counts.set(item.base, count);
100
+ const name = count === 1 ? item.base : `${item.base}#${count}`;
101
+ return { ...item, name, entry: { ...item.entry, name } };
102
+ });
103
+ store.accounts = renamed.reduce((acc, item) => {
104
+ acc[item.name] = item.entry;
105
+ return acc;
106
+ }, {});
107
+ rewriteModelAccountAssignments(store, Object.fromEntries(renamed.map((item) => [item.oldName, item.name])));
108
+ const active = renamed.find((item) => item.oldName === store.active);
109
+ if (active)
110
+ store.active = active.name;
111
+ }
112
+ function toInfo(name, entry, index, active, now = Date.now) {
113
+ const status = entry.expires && entry.expires > 0 && entry.expires < now() ? "expired" : "active";
114
+ const labelName = name.startsWith("github.com:") ? name.slice("github.com:".length) : name;
115
+ const hasUser = entry.user ? labelName.includes(entry.user) : false;
116
+ const hasEmail = entry.email ? labelName.includes(entry.email) : false;
117
+ const suffix = entry.user
118
+ ? hasUser
119
+ ? ""
120
+ : ` (${entry.user})`
121
+ : entry.email
122
+ ? ` (${entry.email})`
123
+ : "";
124
+ const label = `${labelName}${suffix}`;
125
+ return {
126
+ name: label,
127
+ index,
128
+ addedAt: entry.addedAt,
129
+ lastUsed: entry.lastUsed,
130
+ status,
131
+ isCurrent: active === name,
132
+ };
133
+ }
134
+ async function promptText(message) {
135
+ const rl = createInterface({ input, output });
136
+ try {
137
+ const answer = await rl.question(message);
138
+ return answer.trim();
139
+ }
140
+ finally {
141
+ rl.close();
142
+ }
143
+ }
144
+ async function promptAccountName(existing) {
145
+ while (true) {
146
+ const name = await promptText("Account name: ");
147
+ if (!name)
148
+ continue;
149
+ if (!existing.includes(name))
150
+ return name;
151
+ console.log(`Name already exists: ${name}`);
152
+ }
153
+ }
154
+ async function promptAccountEntry(existing, nowFn) {
155
+ const name = await promptAccountName(existing);
156
+ const refresh = await promptText("OAuth refresh/access token: ");
157
+ const access = await promptText("Copilot access token (optional, press Enter to skip): ");
158
+ const expiresRaw = await promptText("Access token expires (unix ms, optional): ");
159
+ const enterpriseUrl = await promptText("Enterprise URL (optional): ");
160
+ const expires = Number(expiresRaw);
161
+ const entry = {
162
+ name,
163
+ refresh,
164
+ access: access || refresh,
165
+ expires: Number.isFinite(expires) ? expires : 0,
166
+ enterpriseUrl: enterpriseUrl || undefined,
167
+ addedAt: nowFn(),
168
+ source: "manual",
169
+ };
170
+ return { name, entry };
171
+ }
172
+ async function promptFilePath(message, defaultValue) {
173
+ const rl = createInterface({ input, output });
174
+ try {
175
+ const answer = await rl.question(`${message} (${defaultValue}): `);
176
+ return answer.trim() || defaultValue;
177
+ }
178
+ finally {
179
+ rl.close();
180
+ }
181
+ }
182
+ async function loginOauth(deployment, nowFn, fetchUserFn, enterpriseUrl) {
183
+ const domain = deployment === "enterprise" ? normalizeDomain(enterpriseUrl ?? "") : "github.com";
184
+ const urls = getUrls(domain);
185
+ const deviceResponse = await fetch(urls.DEVICE_CODE_URL, {
186
+ method: "POST",
187
+ headers: {
188
+ Accept: "application/json",
189
+ "Content-Type": "application/json",
190
+ },
191
+ body: JSON.stringify({
192
+ client_id: CLIENT_ID,
193
+ scope: "read:user user:email",
194
+ }),
195
+ });
196
+ if (!deviceResponse.ok)
197
+ throw new Error("Failed to initiate device authorization");
198
+ const deviceData = (await deviceResponse.json());
199
+ console.log(`Go to: ${deviceData.verification_uri}`);
200
+ console.log(`Enter code: ${deviceData.user_code}`);
201
+ while (true) {
202
+ const response = await fetch(urls.ACCESS_TOKEN_URL, {
203
+ method: "POST",
204
+ headers: {
205
+ Accept: "application/json",
206
+ "Content-Type": "application/json",
207
+ },
208
+ body: JSON.stringify({
209
+ client_id: CLIENT_ID,
210
+ device_code: deviceData.device_code,
211
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
212
+ }),
213
+ });
214
+ if (!response.ok)
215
+ throw new Error("Failed to poll token");
216
+ const data = (await response.json());
217
+ if (data.access_token) {
218
+ const entry = {
219
+ name: deployment === "enterprise" ? `enterprise:${domain}` : "github.com",
220
+ refresh: data.access_token,
221
+ access: data.access_token,
222
+ expires: 0,
223
+ enterpriseUrl: deployment === "enterprise" ? domain : undefined,
224
+ addedAt: nowFn(),
225
+ source: "auth",
226
+ };
227
+ const user = await fetchUserFn(entry);
228
+ if (user?.login)
229
+ entry.user = user.login;
230
+ if (user?.email)
231
+ entry.email = user.email;
232
+ if (user?.orgs?.length)
233
+ entry.orgs = user.orgs;
234
+ return entry;
235
+ }
236
+ if (data.error === "authorization_pending") {
237
+ await sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS);
238
+ continue;
239
+ }
240
+ if (data.error === "slow_down") {
241
+ const serverInterval = data.interval;
242
+ const next = (serverInterval && serverInterval > 0 ? serverInterval : deviceData.interval + 5) * 1000;
243
+ await sleep(next + OAUTH_POLLING_SAFETY_MARGIN_MS);
244
+ continue;
245
+ }
246
+ throw new Error("Authorization failed");
247
+ }
248
+ }
249
+ async function switchAccount(client, entry) {
250
+ const payload = {
251
+ type: "oauth",
252
+ refresh: entry.refresh,
253
+ access: entry.access,
254
+ expires: entry.expires,
255
+ ...(entry.enterpriseUrl ? { enterpriseUrl: entry.enterpriseUrl } : {}),
256
+ };
257
+ await client.auth.set({
258
+ path: { id: entry.enterpriseUrl ? "github-copilot-enterprise" : "github-copilot" },
259
+ body: payload,
260
+ });
261
+ }
262
+ function parseModels(modelData) {
263
+ const available = [];
264
+ const disabled = [];
265
+ for (const item of modelData.data ?? []) {
266
+ if (!item.id)
267
+ continue;
268
+ const enabled = item.model_picker_enabled === true && item.policy?.state !== "disabled";
269
+ if (enabled)
270
+ available.push(item.id);
271
+ else
272
+ disabled.push(item.id);
273
+ }
274
+ return { available, disabled, updatedAt: Date.now() };
275
+ }
276
+ async function fetchModelsDefault(entry) {
277
+ try {
278
+ const headers = {
279
+ Accept: "application/json",
280
+ Authorization: `Bearer ${entry.access}`,
281
+ "User-Agent": "GitHubCopilotChat/0.26.7",
282
+ "Editor-Version": "vscode/1.96.2",
283
+ "Editor-Plugin-Version": "copilot/1.159.0",
284
+ "Copilot-Integration-Id": "vscode-chat",
285
+ "X-Github-Api-Version": "2025-04-01",
286
+ };
287
+ const modelsUrl = entry.enterpriseUrl
288
+ ? `https://copilot-api.${normalizeDomain(entry.enterpriseUrl)}/models`
289
+ : "https://api.githubcopilot.com/models";
290
+ const modelRes = await fetch(modelsUrl, { headers });
291
+ if (!modelRes.ok) {
292
+ const base = entry.enterpriseUrl ? `https://api.${normalizeDomain(entry.enterpriseUrl)}` : "https://api.github.com";
293
+ const tokenRes = await fetch(`${base}/copilot_internal/v2/token`, {
294
+ headers: {
295
+ Accept: "application/json",
296
+ Authorization: `token ${getGitHubToken(entry)}`,
297
+ "User-Agent": "GitHubCopilotChat/0.26.7",
298
+ "Editor-Version": "vscode/1.96.2",
299
+ "Editor-Plugin-Version": "copilot/1.159.0",
300
+ "X-Github-Api-Version": "2025-04-01",
301
+ },
302
+ });
303
+ if (!tokenRes.ok)
304
+ return { available: [], disabled: [], error: `token ${tokenRes.status}` };
305
+ const tokenData = (await tokenRes.json());
306
+ if (!tokenData.token)
307
+ return { available: [], disabled: [], error: "token missing" };
308
+ entry.access = tokenData.token;
309
+ if (tokenData.expires_at)
310
+ entry.expires = tokenData.expires_at * 1000;
311
+ const fallbackRes = await fetch(modelsUrl, {
312
+ headers: {
313
+ Accept: "application/json",
314
+ Authorization: `Bearer ${tokenData.token}`,
315
+ "User-Agent": "GitHubCopilotChat/0.26.7",
316
+ "Editor-Version": "vscode/1.96.2",
317
+ "Editor-Plugin-Version": "copilot/1.159.0",
318
+ "Copilot-Integration-Id": "vscode-chat",
319
+ "X-Github-Api-Version": "2025-04-01",
320
+ },
321
+ });
322
+ if (!fallbackRes.ok)
323
+ return { available: [], disabled: [], error: `models ${fallbackRes.status}` };
324
+ return parseModels((await fallbackRes.json()));
325
+ }
326
+ return parseModels((await modelRes.json()));
327
+ }
328
+ catch (error) {
329
+ return { available: [], disabled: [], error: error instanceof Error ? error.message : String(error) };
330
+ }
331
+ }
332
+ async function fetchUserDefault(entry) {
333
+ try {
334
+ const base = entry.enterpriseUrl ? `https://api.${normalizeDomain(entry.enterpriseUrl)}` : "https://api.github.com";
335
+ const headers = {
336
+ Accept: "application/json",
337
+ Authorization: `token ${getGitHubToken(entry)}`,
338
+ "User-Agent": "GitHubCopilotChat/0.26.7",
339
+ };
340
+ const userRes = await fetch(`${base}/user`, { headers });
341
+ if (!userRes.ok)
342
+ return undefined;
343
+ const user = (await userRes.json());
344
+ let email = user.email;
345
+ if (!email) {
346
+ const emailRes = await fetch(`${base}/user/emails`, { headers });
347
+ if (emailRes.ok) {
348
+ const items = (await emailRes.json());
349
+ const primary = items.find((item) => item.primary && item.verified);
350
+ email = primary?.email ?? items[0]?.email;
351
+ }
352
+ }
353
+ const orgRes = await fetch(`${base}/user/orgs`, { headers });
354
+ const orgs = orgRes.ok ? (await orgRes.json()).map((o) => o.login).filter(Boolean) : undefined;
355
+ return { login: user.login, email, orgs };
356
+ }
357
+ catch {
358
+ return undefined;
359
+ }
360
+ }
361
+ async function refreshIdentity(store, fetchUserFn) {
362
+ const items = await Promise.all(Object.entries(store.accounts).map(async ([name, entry]) => {
363
+ const user = await fetchUserFn(entry);
364
+ const base = buildName(entry, user?.login ?? entry.user);
365
+ return {
366
+ oldName: name,
367
+ base,
368
+ entry: {
369
+ ...entry,
370
+ user: user?.login ?? entry.user,
371
+ email: user?.email ?? entry.email,
372
+ orgs: user?.orgs ?? entry.orgs,
373
+ name: base,
374
+ },
375
+ };
376
+ }));
377
+ renameAccounts(store, items);
378
+ }
379
+ export function createCopilotMenuAdapter(inputDeps) {
380
+ const now = inputDeps.now ?? Date.now;
381
+ const loadStore = inputDeps.readStore ?? readStore;
382
+ const persistStore = inputDeps.writeStore ?? (async (store, meta) => {
383
+ const { writeStore } = await import("../store.js");
384
+ await writeStore(store, { debug: meta });
385
+ });
386
+ const loadAuth = inputDeps.readAuth ?? readAuth;
387
+ const fetchUserFn = inputDeps.fetchUser ?? fetchUserDefault;
388
+ const fetchModelsFn = inputDeps.fetchModels ?? fetchModelsDefault;
389
+ const fetchQuotaFn = inputDeps.fetchQuota ?? fetchQuota;
390
+ let nextAutoRefreshAt = 0;
391
+ async function maybeAutoRefresh(store) {
392
+ if (store.autoRefresh !== true || now() < nextAutoRefreshAt)
393
+ return;
394
+ const updated = await Promise.all(Object.entries(store.accounts).map(async ([name, entry]) => ({
395
+ name,
396
+ entry: {
397
+ ...entry,
398
+ quota: await fetchQuotaFn(entry),
399
+ },
400
+ })));
401
+ for (const item of updated) {
402
+ store.accounts[item.name] = item.entry;
403
+ }
404
+ store.lastQuotaRefresh = now();
405
+ await persistStore(store, {
406
+ reason: "auto-refresh",
407
+ source: "plugin.runMenu",
408
+ actionType: "toggle-refresh",
409
+ });
410
+ nextAutoRefreshAt = now() + (store.refreshMinutes ?? 15) * 60_000;
411
+ }
412
+ return {
413
+ key: "copilot",
414
+ loadStore,
415
+ writeStore: persistStore,
416
+ bootstrapAuthImport: async (store) => {
417
+ const auth = await loadAuth().catch(() => ({}));
418
+ const imported = Object.entries(auth).filter(([providerKey]) => providerKey === "github-copilot" || providerKey === "github-copilot-enterprise");
419
+ if (imported.length > 0) {
420
+ mergeAuth(store, imported);
421
+ const preferred = imported.find(([providerKey]) => providerKey === "github-copilot") ?? imported[0];
422
+ if (!store.active)
423
+ store.active = preferred?.[1].name;
424
+ }
425
+ if (Object.keys(store.accounts).length > 0
426
+ && !Object.values(store.accounts).some((entry) => entry.user || entry.email || (entry.orgs && entry.orgs.length > 0))) {
427
+ await refreshIdentity(store, fetchUserFn);
428
+ dedupe(store);
429
+ await persistStore(store, {
430
+ reason: "refresh-identity-bootstrap",
431
+ source: "plugin.runMenu",
432
+ });
433
+ }
434
+ return false;
435
+ },
436
+ authorizeNewAccount: async (store) => {
437
+ if (inputDeps.authorizeNewAccount) {
438
+ return inputDeps.authorizeNewAccount(store);
439
+ }
440
+ const mode = await promptText("Add via device login? (y/n): ");
441
+ const useDevice = mode.toLowerCase() === "y" || mode.toLowerCase() === "yes";
442
+ if (useDevice) {
443
+ const dep = await promptText("Enterprise? (y/n): ");
444
+ const isEnterprise = dep.toLowerCase() === "y" || dep.toLowerCase() === "yes";
445
+ const domain = isEnterprise ? await promptText("Enterprise URL or domain: ") : undefined;
446
+ const entry = await loginOauth(isEnterprise ? "enterprise" : "github.com", now, fetchUserFn, domain);
447
+ const user = await fetchUserFn(entry);
448
+ if (user?.login)
449
+ entry.user = user.login;
450
+ if (user?.email)
451
+ entry.email = user.email;
452
+ if (user?.orgs?.length)
453
+ entry.orgs = user.orgs;
454
+ entry.name = buildName(entry, user?.login);
455
+ return entry;
456
+ }
457
+ const manual = await promptAccountEntry(Object.keys(store.accounts), now);
458
+ const user = await fetchUserFn(manual.entry);
459
+ if (user?.login)
460
+ manual.entry.user = user.login;
461
+ if (user?.email)
462
+ manual.entry.email = user.email;
463
+ if (user?.orgs?.length)
464
+ manual.entry.orgs = user.orgs;
465
+ manual.entry.name = buildName(manual.entry, user?.login);
466
+ return manual.entry;
467
+ },
468
+ refreshSnapshots: async () => { },
469
+ toMenuInfo: async (store) => {
470
+ await maybeAutoRefresh(store);
471
+ const entries = Object.entries(store.accounts);
472
+ const refreshed = await Promise.all(entries.map(async ([name, entry]) => {
473
+ if (entry.user || entry.email || (entry.orgs && entry.orgs.length > 0))
474
+ return { name, entry };
475
+ const user = await fetchUserFn(entry);
476
+ return {
477
+ name,
478
+ entry: {
479
+ ...entry,
480
+ user: user?.login ?? entry.user,
481
+ email: user?.email ?? entry.email,
482
+ orgs: user?.orgs ?? entry.orgs,
483
+ },
484
+ };
485
+ }));
486
+ for (const item of refreshed) {
487
+ store.accounts[item.name] = item.entry;
488
+ }
489
+ return Object.entries(store.accounts).map(([name, entry], index) => ({
490
+ id: name,
491
+ ...toInfo(name, entry, index, store.active, now),
492
+ source: entry.source,
493
+ orgs: entry.orgs,
494
+ plan: entry.quota?.plan,
495
+ sku: entry.quota?.sku,
496
+ reset: entry.quota?.reset,
497
+ models: entry.models
498
+ ? {
499
+ enabled: entry.models.available.length,
500
+ disabled: entry.models.disabled.length,
501
+ }
502
+ : undefined,
503
+ modelsError: entry.models?.error,
504
+ modelList: entry.models
505
+ ? {
506
+ available: entry.models.available,
507
+ disabled: entry.models.disabled,
508
+ }
509
+ : undefined,
510
+ quota: entry.quota?.snapshots
511
+ ? {
512
+ premium: entry.quota.snapshots.premium,
513
+ chat: entry.quota.snapshots.chat,
514
+ completions: entry.quota.snapshots.completions,
515
+ }
516
+ : undefined,
517
+ }));
518
+ },
519
+ getCurrentEntry: (store) => (store.active ? store.accounts[store.active] : undefined),
520
+ getRefreshConfig: () => ({ enabled: false, minutes: 15 }),
521
+ getAccountByName: (store, name) => {
522
+ const entry = store.accounts[name];
523
+ if (!entry)
524
+ return undefined;
525
+ return { name, entry };
526
+ },
527
+ addAccount: async (store, entry) => {
528
+ store.accounts[entry.name] = entry;
529
+ store.active = store.active ?? entry.name;
530
+ if (store.active === entry.name) {
531
+ if (inputDeps.activateAddedAccount) {
532
+ await inputDeps.activateAddedAccount({
533
+ store,
534
+ name: entry.name,
535
+ switchAccount: () => switchAccount(inputDeps.client, entry),
536
+ writeStore: persistStore,
537
+ now,
538
+ });
539
+ }
540
+ else {
541
+ await switchAccount(inputDeps.client, entry);
542
+ await persistAccountSwitch({
543
+ store,
544
+ name: entry.name,
545
+ at: now(),
546
+ writeStore: persistStore,
547
+ });
548
+ }
549
+ return { changed: true, persistHandled: true };
550
+ }
551
+ await persistStore(store, {
552
+ reason: "add-account",
553
+ source: "plugin.runMenu",
554
+ actionType: "add",
555
+ });
556
+ return { changed: true, persistHandled: true };
557
+ },
558
+ removeAccount: async (store, name) => {
559
+ if (!store.accounts[name])
560
+ return false;
561
+ if (inputDeps.removeAccountFromStore) {
562
+ inputDeps.removeAccountFromStore(store, name);
563
+ }
564
+ else {
565
+ delete store.accounts[name];
566
+ }
567
+ await persistStore(store, {
568
+ reason: "remove-account",
569
+ source: "plugin.runMenu",
570
+ actionType: "remove",
571
+ });
572
+ return { changed: true, persistHandled: true };
573
+ },
574
+ removeAllAccounts: async (store) => {
575
+ if (Object.keys(store.accounts).length === 0)
576
+ return false;
577
+ if (inputDeps.clearAllAccounts) {
578
+ inputDeps.clearAllAccounts(store);
579
+ }
580
+ else {
581
+ store.accounts = {};
582
+ store.active = undefined;
583
+ }
584
+ await persistStore(store, {
585
+ reason: "remove-all",
586
+ source: "plugin.runMenu",
587
+ actionType: "remove-all",
588
+ });
589
+ return { changed: true, persistHandled: true };
590
+ },
591
+ switchAccount: async (store, name, entry) => {
592
+ await switchAccount(inputDeps.client, entry);
593
+ await persistAccountSwitch({
594
+ store,
595
+ name,
596
+ at: now(),
597
+ writeStore: persistStore,
598
+ });
599
+ inputDeps.logSwitchHint?.();
600
+ return { persistHandled: true };
601
+ },
602
+ applyAction: async (store, action) => {
603
+ if (action.name === "import-auth") {
604
+ const file = await promptFilePath("auth.json path", authPath());
605
+ const auth = await loadAuth(file).catch(() => ({}));
606
+ const imported = Object.entries(auth).filter(([providerKey]) => providerKey === "github-copilot" || providerKey === "github-copilot-enterprise");
607
+ for (const [_providerKey, entry] of imported) {
608
+ const user = await fetchUserFn(entry);
609
+ if (user?.login)
610
+ entry.user = user.login;
611
+ if (user?.email)
612
+ entry.email = user.email;
613
+ if (user?.orgs?.length)
614
+ entry.orgs = user.orgs;
615
+ entry.name = buildName(entry, user?.login);
616
+ }
617
+ mergeAuth(store, imported);
618
+ await persistStore(store, {
619
+ reason: "import-auth",
620
+ source: "plugin.runMenu",
621
+ actionType: "import",
622
+ });
623
+ return false;
624
+ }
625
+ if (action.name === "refresh-identity") {
626
+ await refreshIdentity(store, fetchUserFn);
627
+ dedupe(store);
628
+ await persistStore(store, {
629
+ reason: "refresh-identity",
630
+ source: "plugin.runMenu",
631
+ actionType: "refresh-identity",
632
+ });
633
+ return false;
634
+ }
635
+ if (action.name === "toggle-refresh") {
636
+ store.autoRefresh = !store.autoRefresh;
637
+ store.refreshMinutes = store.refreshMinutes ?? 15;
638
+ await persistStore(store, {
639
+ reason: "toggle-refresh",
640
+ source: "plugin.runMenu",
641
+ actionType: "toggle-refresh",
642
+ });
643
+ return false;
644
+ }
645
+ if (action.name === "set-interval") {
646
+ const value = await promptText("Refresh interval (minutes): ");
647
+ const minutes = Math.max(1, Math.min(180, Number(value)));
648
+ if (Number.isFinite(minutes))
649
+ store.refreshMinutes = minutes;
650
+ await persistStore(store, {
651
+ reason: "set-interval",
652
+ source: "plugin.runMenu",
653
+ actionType: "set-interval",
654
+ });
655
+ return false;
656
+ }
657
+ if (action.name === "quota-refresh") {
658
+ const entries = Object.entries(store.accounts);
659
+ const updated = await Promise.all(entries.map(async ([name, entry]) => ({
660
+ name,
661
+ entry: {
662
+ ...entry,
663
+ quota: await fetchQuotaFn(entry),
664
+ },
665
+ })));
666
+ for (const item of updated) {
667
+ store.accounts[item.name] = item.entry;
668
+ }
669
+ store.lastQuotaRefresh = now();
670
+ await persistStore(store, {
671
+ reason: "quota-refresh",
672
+ source: "plugin.runMenu",
673
+ actionType: "quota",
674
+ });
675
+ return false;
676
+ }
677
+ if (action.name === "check-models") {
678
+ const entries = Object.entries(store.accounts);
679
+ const updated = await Promise.all(entries.map(async ([name, entry]) => ({
680
+ name,
681
+ entry: {
682
+ ...entry,
683
+ models: await fetchModelsFn(entry),
684
+ },
685
+ })));
686
+ for (const item of updated) {
687
+ store.accounts[item.name] = item.entry;
688
+ }
689
+ await persistStore(store, {
690
+ reason: "check-models",
691
+ source: "plugin.runMenu",
692
+ actionType: "check-models",
693
+ });
694
+ return false;
695
+ }
696
+ if (action.name === "configure-default-group") {
697
+ if (!inputDeps.configureDefaultAccountGroup)
698
+ return false;
699
+ const changed = await inputDeps.configureDefaultAccountGroup(store);
700
+ if (!changed)
701
+ return false;
702
+ await persistStore(store, {
703
+ reason: "configure-default-account-group",
704
+ source: "plugin.runMenu",
705
+ actionType: "configure-default-account-group",
706
+ });
707
+ return false;
708
+ }
709
+ if (action.name === "assign-models") {
710
+ if (!inputDeps.configureModelAccountAssignments)
711
+ return false;
712
+ const changed = await inputDeps.configureModelAccountAssignments(store);
713
+ if (!changed)
714
+ return false;
715
+ await persistStore(store, {
716
+ reason: "assign-model-account",
717
+ source: "plugin.runMenu",
718
+ actionType: "assign-model-account",
719
+ });
720
+ return false;
721
+ }
722
+ if (action.name === "toggle-loop-safety"
723
+ || action.name === "toggle-loop-safety-provider-scope"
724
+ || action.name === "toggle-experimental-slash-commands"
725
+ || action.name === "toggle-network-retry"
726
+ || action.name === "toggle-synthetic-agent-initiator") {
727
+ await applyMenuAction({
728
+ action: { type: action.name },
729
+ store,
730
+ writeStore: persistStore,
731
+ });
732
+ return false;
733
+ }
734
+ if (action.name === "list-models") {
735
+ const modelID = await select([
736
+ { label: "Back", value: "" },
737
+ ...listKnownCopilotModels(store).map((name) => ({ label: name, value: name })),
738
+ ], {
739
+ message: "Choose a Copilot model",
740
+ subtitle: "Inspect current assignment candidates",
741
+ clearScreen: true,
742
+ autoSelectSingle: false,
743
+ });
744
+ if (!modelID)
745
+ return false;
746
+ const options = listAssignableAccountsForModel(store, modelID);
747
+ await selectMany(options.map((item) => ({
748
+ label: item.name,
749
+ value: item.name,
750
+ hint: item.entry.enterpriseUrl ? normalizeDomain(item.entry.enterpriseUrl) : "github.com",
751
+ })), {
752
+ message: modelID,
753
+ subtitle: "Inspect accounts exposing this model",
754
+ clearScreen: true,
755
+ autoSelectSingle: false,
756
+ minSelected: 0,
757
+ });
758
+ return false;
759
+ }
760
+ return false;
761
+ },
762
+ };
763
+ }