opencode-account-manager 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +266 -0
  3. package/dist/cli.d.ts +3 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +183 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/core/accounts.d.ts +19 -0
  8. package/dist/core/accounts.d.ts.map +1 -0
  9. package/dist/core/accounts.js +181 -0
  10. package/dist/core/accounts.js.map +1 -0
  11. package/dist/core/config-store.d.ts +48 -0
  12. package/dist/core/config-store.d.ts.map +1 -0
  13. package/dist/core/config-store.js +206 -0
  14. package/dist/core/config-store.js.map +1 -0
  15. package/dist/core/crypto.d.ts +40 -0
  16. package/dist/core/crypto.d.ts.map +1 -0
  17. package/dist/core/crypto.js +172 -0
  18. package/dist/core/crypto.js.map +1 -0
  19. package/dist/core/importers/amJson.d.ts +17 -0
  20. package/dist/core/importers/amJson.d.ts.map +1 -0
  21. package/dist/core/importers/amJson.js +131 -0
  22. package/dist/core/importers/amJson.js.map +1 -0
  23. package/dist/core/opencode-config.d.ts +92 -0
  24. package/dist/core/opencode-config.d.ts.map +1 -0
  25. package/dist/core/opencode-config.js +148 -0
  26. package/dist/core/opencode-config.js.map +1 -0
  27. package/dist/core/paths.d.ts +5 -0
  28. package/dist/core/paths.d.ts.map +1 -0
  29. package/dist/core/paths.js +38 -0
  30. package/dist/core/paths.js.map +1 -0
  31. package/dist/core/types.d.ts +74 -0
  32. package/dist/core/types.d.ts.map +1 -0
  33. package/dist/core/types.js +30 -0
  34. package/dist/core/types.js.map +1 -0
  35. package/dist/core/utils.d.ts +5 -0
  36. package/dist/core/utils.d.ts.map +1 -0
  37. package/dist/core/utils.js +35 -0
  38. package/dist/core/utils.js.map +1 -0
  39. package/dist/tui/Dashboard.d.ts +7 -0
  40. package/dist/tui/Dashboard.d.ts.map +1 -0
  41. package/dist/tui/Dashboard.js +331 -0
  42. package/dist/tui/Dashboard.js.map +1 -0
  43. package/dist/tui/components/AccountList.d.ts +18 -0
  44. package/dist/tui/components/AccountList.d.ts.map +1 -0
  45. package/dist/tui/components/AccountList.js +92 -0
  46. package/dist/tui/components/AccountList.js.map +1 -0
  47. package/dist/tui/components/Box.d.ts +11 -0
  48. package/dist/tui/components/Box.d.ts.map +1 -0
  49. package/dist/tui/components/Box.js +15 -0
  50. package/dist/tui/components/Box.js.map +1 -0
  51. package/dist/tui/components/ExportModal.d.ts +10 -0
  52. package/dist/tui/components/ExportModal.d.ts.map +1 -0
  53. package/dist/tui/components/ExportModal.js +192 -0
  54. package/dist/tui/components/ExportModal.js.map +1 -0
  55. package/dist/tui/components/FileBrowser.d.ts +12 -0
  56. package/dist/tui/components/FileBrowser.d.ts.map +1 -0
  57. package/dist/tui/components/FileBrowser.js +349 -0
  58. package/dist/tui/components/FileBrowser.js.map +1 -0
  59. package/dist/tui/components/Header.d.ts +8 -0
  60. package/dist/tui/components/Header.d.ts.map +1 -0
  61. package/dist/tui/components/Header.js +20 -0
  62. package/dist/tui/components/Header.js.map +1 -0
  63. package/dist/tui/components/ImportModal.d.ts +10 -0
  64. package/dist/tui/components/ImportModal.d.ts.map +1 -0
  65. package/dist/tui/components/ImportModal.js +215 -0
  66. package/dist/tui/components/ImportModal.js.map +1 -0
  67. package/dist/tui/components/McpServerList.d.ts +8 -0
  68. package/dist/tui/components/McpServerList.d.ts.map +1 -0
  69. package/dist/tui/components/McpServerList.js +35 -0
  70. package/dist/tui/components/McpServerList.js.map +1 -0
  71. package/dist/tui/components/Menu.d.ts +10 -0
  72. package/dist/tui/components/Menu.d.ts.map +1 -0
  73. package/dist/tui/components/Menu.js +83 -0
  74. package/dist/tui/components/Menu.js.map +1 -0
  75. package/dist/tui/components/PasswordInput.d.ts +12 -0
  76. package/dist/tui/components/PasswordInput.d.ts.map +1 -0
  77. package/dist/tui/components/PasswordInput.js +130 -0
  78. package/dist/tui/components/PasswordInput.js.map +1 -0
  79. package/dist/tui/components/ProviderList.d.ts +8 -0
  80. package/dist/tui/components/ProviderList.d.ts.map +1 -0
  81. package/dist/tui/components/ProviderList.js +37 -0
  82. package/dist/tui/components/ProviderList.js.map +1 -0
  83. package/dist/tui/components/SectionBox.d.ts +10 -0
  84. package/dist/tui/components/SectionBox.d.ts.map +1 -0
  85. package/dist/tui/components/SectionBox.js +16 -0
  86. package/dist/tui/components/SectionBox.js.map +1 -0
  87. package/dist/tui/components/StatsRow.d.ts +13 -0
  88. package/dist/tui/components/StatsRow.d.ts.map +1 -0
  89. package/dist/tui/components/StatsRow.js +18 -0
  90. package/dist/tui/components/StatsRow.js.map +1 -0
  91. package/dist/tui/components/StatusBadge.d.ts +8 -0
  92. package/dist/tui/components/StatusBadge.d.ts.map +1 -0
  93. package/dist/tui/components/StatusBadge.js +30 -0
  94. package/dist/tui/components/StatusBadge.js.map +1 -0
  95. package/dist/tui/components/index.d.ts +14 -0
  96. package/dist/tui/components/index.d.ts.map +1 -0
  97. package/dist/tui/components/index.js +32 -0
  98. package/dist/tui/components/index.js.map +1 -0
  99. package/dist/tui/index.d.ts +5 -0
  100. package/dist/tui/index.d.ts.map +1 -0
  101. package/dist/tui/index.js +13 -0
  102. package/dist/tui/index.js.map +1 -0
  103. package/docs/BLUEPRINT.md +476 -0
  104. package/docs/ROADMAP.md +74 -0
  105. package/package.json +38 -0
  106. package/src/cli.ts +207 -0
  107. package/src/core/accounts.ts +215 -0
  108. package/src/core/config-store.ts +212 -0
  109. package/src/core/crypto.ts +162 -0
  110. package/src/core/importers/amJson.ts +185 -0
  111. package/src/core/opencode-config.ts +217 -0
  112. package/src/core/paths.ts +32 -0
  113. package/src/core/types.ts +118 -0
  114. package/src/core/utils.ts +28 -0
  115. package/src/tui/Dashboard.tsx +431 -0
  116. package/src/tui/components/AccountList.tsx +155 -0
  117. package/src/tui/components/Box.tsx +37 -0
  118. package/src/tui/components/ExportModal.tsx +255 -0
  119. package/src/tui/components/FileBrowser.tsx +393 -0
  120. package/src/tui/components/Header.tsx +26 -0
  121. package/src/tui/components/ImportModal.tsx +288 -0
  122. package/src/tui/components/McpServerList.tsx +67 -0
  123. package/src/tui/components/Menu.tsx +103 -0
  124. package/src/tui/components/PasswordInput.tsx +159 -0
  125. package/src/tui/components/ProviderList.tsx +61 -0
  126. package/src/tui/components/SectionBox.tsx +35 -0
  127. package/src/tui/components/StatsRow.tsx +33 -0
  128. package/src/tui/components/StatusBadge.tsx +33 -0
  129. package/src/tui/components/index.ts +13 -0
  130. package/src/tui/index.tsx +11 -0
  131. package/tsconfig.json +20 -0
@@ -0,0 +1,28 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+
4
+ export function readJsonFile<T>(filePath: string): T {
5
+ const raw = fs.readFileSync(filePath, "utf8");
6
+ return JSON.parse(raw) as T;
7
+ }
8
+
9
+ export function writeJsonFile(filePath: string, data: unknown): void {
10
+ const dir = path.dirname(filePath);
11
+ if (!fs.existsSync(dir)) {
12
+ fs.mkdirSync(dir, { recursive: true });
13
+ }
14
+ const raw = JSON.stringify(data, null, 2);
15
+ fs.writeFileSync(filePath, raw, "utf8");
16
+ }
17
+
18
+ export function safeParseJson(input: string): unknown {
19
+ try {
20
+ return JSON.parse(input);
21
+ } catch {
22
+ return undefined;
23
+ }
24
+ }
25
+
26
+ export function toLowerTrim(input: string): string {
27
+ return input.trim().toLowerCase();
28
+ }
@@ -0,0 +1,431 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import { Box, Text, useApp, useInput } from "ink";
3
+ import {
4
+ Header,
5
+ StatsRow,
6
+ AccountList,
7
+ MenuBar,
8
+ MenuAction,
9
+ ProviderList,
10
+ McpServerList,
11
+ SectionBox,
12
+ ExportModal,
13
+ ImportModal,
14
+ } from "./components";
15
+ import {
16
+ readPluginAccountsFile,
17
+ createEmptyPluginAccountsFile,
18
+ summarizeAccounts,
19
+ mergeAccounts,
20
+ writePluginAccountsFile,
21
+ } from "../core/accounts";
22
+ import { getPluginAccountsPath, getAmFolderPath } from "../core/paths";
23
+ import { Account, PluginAccountsFile } from "../core/types";
24
+ import { importFromAmFolder } from "../core/importers/amJson";
25
+ import {
26
+ parseOpencodeInfo,
27
+ getConfigSummary,
28
+ OpencodeInfo,
29
+ } from "../core/opencode-config";
30
+
31
+ interface DashboardProps {
32
+ pluginPath?: string;
33
+ }
34
+
35
+ type ActiveSection = "providers" | "accounts" | "mcp";
36
+ type ModalType = "none" | "export" | "import" | "export-selected";
37
+
38
+ function safeReadPluginFile(pluginPath: string): PluginAccountsFile {
39
+ try {
40
+ return readPluginAccountsFile(pluginPath);
41
+ } catch {
42
+ return createEmptyPluginAccountsFile();
43
+ }
44
+ }
45
+
46
+ export function Dashboard({ pluginPath }: DashboardProps) {
47
+ const { exit } = useApp();
48
+ const resolvedPath = getPluginAccountsPath(pluginPath);
49
+
50
+ // OpenCode config state
51
+ const [opencodeInfo, setOpencodeInfo] = useState<OpencodeInfo | null>(null);
52
+
53
+ // Plugin accounts state
54
+ const [accounts, setAccounts] = useState<Account[]>([]);
55
+ const [summary, setSummary] = useState({ total: 0, available: 0, limited: 0 });
56
+ const [message, setMessage] = useState<string | null>(null);
57
+
58
+ // UI state
59
+ const [activeSection, setActiveSection] = useState<ActiveSection>("providers");
60
+ const [selectMode, setSelectMode] = useState(false);
61
+ const [selectedIndex, setSelectedIndex] = useState(0);
62
+ const [checkedEmails, setCheckedEmails] = useState<Set<string>>(new Set());
63
+
64
+ // Modal state
65
+ const [activeModal, setActiveModal] = useState<ModalType>("none");
66
+
67
+ const showMessage = (msg: string, duration = 3000) => {
68
+ setMessage(msg);
69
+ setTimeout(() => setMessage(null), duration);
70
+ };
71
+
72
+ const loadOpencodeConfig = () => {
73
+ const info = parseOpencodeInfo();
74
+ setOpencodeInfo(info);
75
+ };
76
+
77
+ const loadAccounts = () => {
78
+ const file = safeReadPluginFile(resolvedPath);
79
+ setAccounts(file.accounts);
80
+ setSummary(summarizeAccounts(file.accounts));
81
+ setCheckedEmails(new Set());
82
+ setSelectedIndex(0);
83
+ };
84
+
85
+ const refresh = () => {
86
+ loadOpencodeConfig();
87
+ loadAccounts();
88
+ showMessage("Refreshed", 2000);
89
+ };
90
+
91
+ useEffect(() => {
92
+ loadOpencodeConfig();
93
+ loadAccounts();
94
+ }, []);
95
+
96
+ // Keyboard navigation (only when no modal is open)
97
+ useInput((input, key) => {
98
+ if (activeModal !== "none") return;
99
+
100
+ // Section switching with Tab
101
+ if (key.tab) {
102
+ setActiveSection(prev => {
103
+ if (prev === "providers") return "accounts";
104
+ if (prev === "accounts") return "mcp";
105
+ return "providers";
106
+ });
107
+ setSelectMode(false);
108
+ return;
109
+ }
110
+
111
+ // Only handle list navigation in accounts section with select mode
112
+ if (activeSection !== "accounts" || !selectMode) return;
113
+
114
+ if (key.upArrow) {
115
+ setSelectedIndex(prev => Math.max(0, prev - 1));
116
+ }
117
+ if (key.downArrow) {
118
+ setSelectedIndex(prev => Math.min(accounts.length - 1, prev + 1));
119
+ }
120
+ if (input === " ") {
121
+ const email = accounts[selectedIndex]?.email;
122
+ if (email) {
123
+ setCheckedEmails(prev => {
124
+ const next = new Set(prev);
125
+ if (next.has(email)) {
126
+ next.delete(email);
127
+ } else {
128
+ next.add(email);
129
+ }
130
+ return next;
131
+ });
132
+ }
133
+ }
134
+ });
135
+
136
+ // Export completion handler
137
+ const handleExportComplete = (filePath: string) => {
138
+ setActiveModal("none");
139
+ showMessage(`Exported to ${filePath}`, 4000);
140
+ };
141
+
142
+ // Import completion handler
143
+ const handleImportComplete = (importedAccounts: Account[], newCount: number, overwrittenCount: number) => {
144
+ // Merge imported accounts with existing (overwrite mode)
145
+ const file = safeReadPluginFile(resolvedPath);
146
+ const merged = mergeAccounts(file, importedAccounts, "merge");
147
+ writePluginAccountsFile(pluginPath, merged);
148
+
149
+ setActiveModal("none");
150
+ loadAccounts();
151
+ showMessage(`Imported: ${newCount} new, ${overwrittenCount} updated`, 4000);
152
+ };
153
+
154
+ const handleImportAM = () => {
155
+ const amPath = getAmFolderPath();
156
+ const result = importFromAmFolder(amPath);
157
+
158
+ if (result.errors.length > 0) {
159
+ showMessage(`Error: ${result.errors[0]}`, 5000);
160
+ return;
161
+ }
162
+
163
+ if (result.accounts.length === 0) {
164
+ showMessage(`No accounts found in AM (${result.skipped.length} skipped)`, 4000);
165
+ return;
166
+ }
167
+
168
+ const existingFile = safeReadPluginFile(resolvedPath);
169
+ const merged = mergeAccounts(existingFile, result.accounts, "merge");
170
+ writePluginAccountsFile(pluginPath, merged);
171
+
172
+ const added = merged.accounts.length - existingFile.accounts.length;
173
+ showMessage(
174
+ `Imported from AM: ${result.accounts.length} found, ${added} new. Total: ${merged.accounts.length}`,
175
+ 5000
176
+ );
177
+
178
+ loadAccounts();
179
+ };
180
+
181
+ const handleEnableSelected = () => {
182
+ if (checkedEmails.size === 0) {
183
+ showMessage("No accounts selected", 2000);
184
+ return;
185
+ }
186
+
187
+ const file = safeReadPluginFile(resolvedPath);
188
+ let count = 0;
189
+
190
+ file.accounts = file.accounts.map(acc => {
191
+ if (checkedEmails.has(acc.email)) {
192
+ count++;
193
+ return { ...acc, enabled: true };
194
+ }
195
+ return acc;
196
+ });
197
+
198
+ writePluginAccountsFile(pluginPath, file);
199
+ showMessage(`Enabled ${count} accounts`, 3000);
200
+ loadAccounts();
201
+ setSelectMode(false);
202
+ };
203
+
204
+ const handleDisableSelected = () => {
205
+ if (checkedEmails.size === 0) {
206
+ showMessage("No accounts selected", 2000);
207
+ return;
208
+ }
209
+
210
+ const file = safeReadPluginFile(resolvedPath);
211
+ let count = 0;
212
+
213
+ file.accounts = file.accounts.map(acc => {
214
+ if (checkedEmails.has(acc.email)) {
215
+ count++;
216
+ return { ...acc, enabled: false };
217
+ }
218
+ return acc;
219
+ });
220
+
221
+ writePluginAccountsFile(pluginPath, file);
222
+ showMessage(`Disabled ${count} accounts`, 3000);
223
+ loadAccounts();
224
+ setSelectMode(false);
225
+ };
226
+
227
+ const handleDeleteSelected = () => {
228
+ if (checkedEmails.size === 0) {
229
+ showMessage("No accounts selected", 2000);
230
+ return;
231
+ }
232
+
233
+ const file = safeReadPluginFile(resolvedPath);
234
+ const beforeCount = file.accounts.length;
235
+
236
+ file.accounts = file.accounts.filter(acc => !checkedEmails.has(acc.email));
237
+ const deletedCount = beforeCount - file.accounts.length;
238
+
239
+ writePluginAccountsFile(pluginPath, file);
240
+ showMessage(`Deleted ${deletedCount} accounts`, 3000);
241
+ loadAccounts();
242
+ setSelectMode(false);
243
+ };
244
+
245
+ const handleSelectAll = () => {
246
+ setCheckedEmails(new Set(accounts.map(a => a.email)));
247
+ };
248
+
249
+ const handleSelectNone = () => {
250
+ setCheckedEmails(new Set());
251
+ };
252
+
253
+ const handleAction = (action: MenuAction) => {
254
+ // Don't handle actions when modal is open
255
+ if (activeModal !== "none") return;
256
+
257
+ switch (action) {
258
+ case "refresh":
259
+ refresh();
260
+ break;
261
+ case "export":
262
+ setActiveModal("export");
263
+ break;
264
+ case "import-file":
265
+ setActiveModal("import");
266
+ break;
267
+ case "import-am":
268
+ handleImportAM();
269
+ break;
270
+ case "toggle-select-mode":
271
+ if (activeSection === "accounts") {
272
+ setSelectMode(prev => !prev);
273
+ setCheckedEmails(new Set());
274
+ setSelectedIndex(0);
275
+ } else {
276
+ showMessage("Switch to Accounts section first (Tab)", 2000);
277
+ }
278
+ break;
279
+ case "select-all":
280
+ handleSelectAll();
281
+ break;
282
+ case "select-none":
283
+ handleSelectNone();
284
+ break;
285
+ case "enable-selected":
286
+ handleEnableSelected();
287
+ break;
288
+ case "disable-selected":
289
+ handleDisableSelected();
290
+ break;
291
+ case "delete-selected":
292
+ handleDeleteSelected();
293
+ break;
294
+ case "export-selected":
295
+ if (checkedEmails.size === 0) {
296
+ showMessage("No accounts selected", 2000);
297
+ } else {
298
+ setActiveModal("export-selected");
299
+ }
300
+ break;
301
+ case "quit":
302
+ exit();
303
+ break;
304
+ }
305
+ };
306
+
307
+ // Calculate stats
308
+ const configSummary = opencodeInfo ? getConfigSummary(opencodeInfo) : null;
309
+
310
+ // Get accounts to export (all or selected)
311
+ const getAccountsForExport = (): Account[] => {
312
+ if (activeModal === "export-selected") {
313
+ return accounts.filter(acc => checkedEmails.has(acc.email));
314
+ }
315
+ return accounts;
316
+ };
317
+
318
+ // If modal is open, render only the modal
319
+ if (activeModal === "export" || activeModal === "export-selected") {
320
+ return (
321
+ <Box flexDirection="column" padding={1}>
322
+ <ExportModal
323
+ accounts={getAccountsForExport()}
324
+ onComplete={handleExportComplete}
325
+ onCancel={() => setActiveModal("none")}
326
+ />
327
+ </Box>
328
+ );
329
+ }
330
+
331
+ if (activeModal === "import") {
332
+ return (
333
+ <Box flexDirection="column" padding={1}>
334
+ <ImportModal
335
+ existingAccounts={accounts}
336
+ onComplete={handleImportComplete}
337
+ onCancel={() => setActiveModal("none")}
338
+ />
339
+ </Box>
340
+ );
341
+ }
342
+
343
+ return (
344
+ <Box flexDirection="column" padding={1}>
345
+ <Header title="OpenCode Account Manager" subtitle="Dashboard" />
346
+
347
+ {/* Global Stats */}
348
+ <StatsRow
349
+ stats={[
350
+ { label: "Providers", value: configSummary?.providers || 0, color: "cyan" },
351
+ { label: "Models", value: configSummary?.models || 0, color: "yellow" },
352
+ { label: "MCP On", value: configSummary?.mcpEnabled || 0, color: "green" },
353
+ { label: "MCP Off", value: configSummary?.mcpDisabled || 0, color: "red" },
354
+ { label: "Accounts", value: summary.total, color: "white" },
355
+ { label: "Available", value: summary.available, color: "green" },
356
+ { label: "Limited", value: summary.limited, color: "yellow" },
357
+ ]}
358
+ />
359
+
360
+ {/* Tab indicator */}
361
+ <Box marginY={1}>
362
+ <Text dimColor>Sections: </Text>
363
+ <Text color={activeSection === "providers" ? "cyan" : "gray"} bold={activeSection === "providers"}>
364
+ [1] Providers
365
+ </Text>
366
+ <Text> </Text>
367
+ <Text color={activeSection === "accounts" ? "cyan" : "gray"} bold={activeSection === "accounts"}>
368
+ [2] Accounts
369
+ </Text>
370
+ <Text> </Text>
371
+ <Text color={activeSection === "mcp" ? "cyan" : "gray"} bold={activeSection === "mcp"}>
372
+ [3] MCP
373
+ </Text>
374
+ <Text dimColor> (Tab to switch)</Text>
375
+ </Box>
376
+
377
+ {/* Providers Section */}
378
+ <SectionBox
379
+ title="PROVIDERS"
380
+ borderColor={activeSection === "providers" ? "cyan" : "gray"}
381
+ collapsed={activeSection !== "providers"}
382
+ >
383
+ {opencodeInfo && <ProviderList providers={opencodeInfo.providers} />}
384
+ </SectionBox>
385
+
386
+ {/* Plugin Accounts Section */}
387
+ <SectionBox
388
+ title={`PLUGIN ACCOUNTS (${opencodeInfo?.plugins[0]?.name || "antigravity-auth"})`}
389
+ borderColor={activeSection === "accounts" ? (selectMode ? "yellow" : "cyan") : "gray"}
390
+ collapsed={activeSection !== "accounts"}
391
+ >
392
+ <AccountList
393
+ accounts={accounts}
394
+ selectedIndex={selectMode ? selectedIndex : -1}
395
+ checkedEmails={checkedEmails}
396
+ showCheckbox={selectMode}
397
+ />
398
+ </SectionBox>
399
+
400
+ {/* MCP Servers Section */}
401
+ <SectionBox
402
+ title="MCP SERVERS"
403
+ borderColor={activeSection === "mcp" ? "cyan" : "gray"}
404
+ collapsed={activeSection !== "mcp"}
405
+ >
406
+ {opencodeInfo && <McpServerList servers={opencodeInfo.mcpServers} />}
407
+ </SectionBox>
408
+
409
+ {/* Config path */}
410
+ <Box marginTop={1}>
411
+ <Text dimColor>Config: {opencodeInfo?.configPath || "N/A"}</Text>
412
+ </Box>
413
+
414
+ {/* Menu */}
415
+ <Box marginTop={1}>
416
+ <MenuBar
417
+ onSelect={handleAction}
418
+ selectMode={selectMode}
419
+ selectedCount={checkedEmails.size}
420
+ />
421
+ </Box>
422
+
423
+ {/* Message */}
424
+ {message && (
425
+ <Box marginTop={1}>
426
+ <Text color="green">→ {message}</Text>
427
+ </Box>
428
+ )}
429
+ </Box>
430
+ );
431
+ }
@@ -0,0 +1,155 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ import { StatusBadge } from "./StatusBadge";
4
+ import { Account } from "../../core/types";
5
+
6
+ interface AccountRowProps {
7
+ account: Account;
8
+ isSelected?: boolean;
9
+ isChecked?: boolean;
10
+ showCheckbox?: boolean;
11
+ }
12
+
13
+ function formatDuration(ms: number): string {
14
+ if (ms <= 0) return "-";
15
+ const minutes = Math.floor(ms / 60000);
16
+ const hours = Math.floor(minutes / 60);
17
+ const days = Math.floor(hours / 24);
18
+ if (days > 0) return `${days}d ${hours % 24}h`;
19
+ if (hours > 0) return `${hours}h ${minutes % 60}m`;
20
+ return `${minutes}m`;
21
+ }
22
+
23
+ function getAccountStatus(account: Account): {
24
+ status: "available" | "limited" | "disabled";
25
+ resetIn: string;
26
+ limitDetails: string[];
27
+ } {
28
+ if (account.enabled === false) {
29
+ return { status: "disabled", resetIn: "-", limitDetails: [] };
30
+ }
31
+ const resets = account.rateLimitResetTimes || {};
32
+ const now = Date.now();
33
+ const limitDetails: string[] = [];
34
+
35
+ // Build detailed limit info for each model
36
+ for (const [model, resetTime] of Object.entries(resets)) {
37
+ if (resetTime > now) {
38
+ limitDetails.push(`${model}: ${formatDuration(resetTime - now)}`);
39
+ }
40
+ }
41
+
42
+ if (limitDetails.length === 0) {
43
+ return { status: "available", resetIn: "-", limitDetails: [] };
44
+ }
45
+
46
+ const nextReset = Math.min(...Object.values(resets).filter((v) => v > now));
47
+ return {
48
+ status: "limited",
49
+ resetIn: formatDuration(nextReset - now),
50
+ limitDetails,
51
+ };
52
+ }
53
+
54
+ export function AccountRow({ account, isSelected, isChecked, showCheckbox }: AccountRowProps) {
55
+ const { status, resetIn, limitDetails } = getAccountStatus(account);
56
+ const project = account.projectId || account.managedProjectId || "-";
57
+
58
+ const checkbox = showCheckbox
59
+ ? (isChecked ? "[x]" : "[ ]")
60
+ : "";
61
+
62
+ const cursor = isSelected ? ">" : " ";
63
+ const emailColor = status === "disabled" ? "gray" : (isSelected ? "white" : "cyan");
64
+
65
+ return (
66
+ <Box flexDirection="column">
67
+ <Box flexDirection="row" paddingX={1}>
68
+ <Box width={2}>
69
+ <Text color={isSelected ? "yellow" : "white"}>{cursor}</Text>
70
+ </Box>
71
+ {showCheckbox && (
72
+ <Box width={4}>
73
+ <Text color={isChecked ? "green" : "gray"}>{checkbox}</Text>
74
+ </Box>
75
+ )}
76
+ <Box width={28}>
77
+ <Text color={emailColor} strikethrough={status === "disabled"}>
78
+ {account.email}
79
+ </Text>
80
+ </Box>
81
+ <Box width={18}>
82
+ <Text dimColor>{project.slice(0, 16)}</Text>
83
+ </Box>
84
+ <Box width={12}>
85
+ <StatusBadge status={status} />
86
+ </Box>
87
+ <Box width={10}>
88
+ <Text dimColor>{resetIn}</Text>
89
+ </Box>
90
+ </Box>
91
+ {status === "limited" && limitDetails.length > 0 && (
92
+ <Box paddingLeft={showCheckbox ? 8 : 4}>
93
+ <Text color="gray">
94
+ └─ {limitDetails.join(" | ")}
95
+ </Text>
96
+ </Box>
97
+ )}
98
+ </Box>
99
+ );
100
+ }
101
+
102
+ interface AccountListProps {
103
+ accounts: Account[];
104
+ selectedIndex?: number;
105
+ checkedEmails?: Set<string>;
106
+ showCheckbox?: boolean;
107
+ }
108
+
109
+ export function AccountList({
110
+ accounts,
111
+ selectedIndex = -1,
112
+ checkedEmails = new Set(),
113
+ showCheckbox = false
114
+ }: AccountListProps) {
115
+ return (
116
+ <Box flexDirection="column">
117
+ <Box flexDirection="row" paddingX={1} marginBottom={1}>
118
+ <Box width={2}>
119
+ <Text dimColor> </Text>
120
+ </Box>
121
+ {showCheckbox && (
122
+ <Box width={4}>
123
+ <Text bold dimColor>SEL</Text>
124
+ </Box>
125
+ )}
126
+ <Box width={28}>
127
+ <Text bold dimColor>EMAIL</Text>
128
+ </Box>
129
+ <Box width={18}>
130
+ <Text bold dimColor>PROJECT</Text>
131
+ </Box>
132
+ <Box width={12}>
133
+ <Text bold dimColor>STATUS</Text>
134
+ </Box>
135
+ <Box width={10}>
136
+ <Text bold dimColor>RESET IN</Text>
137
+ </Box>
138
+ </Box>
139
+ {accounts.map((account, index) => (
140
+ <AccountRow
141
+ key={account.email}
142
+ account={account}
143
+ isSelected={index === selectedIndex}
144
+ isChecked={checkedEmails.has(account.email)}
145
+ showCheckbox={showCheckbox}
146
+ />
147
+ ))}
148
+ {accounts.length === 0 && (
149
+ <Box paddingX={1}>
150
+ <Text dimColor>No accounts found</Text>
151
+ </Box>
152
+ )}
153
+ </Box>
154
+ );
155
+ }
@@ -0,0 +1,37 @@
1
+ import React from "react";
2
+ import { Box as InkBox, Text } from "ink";
3
+
4
+ interface BoxProps {
5
+ title?: string;
6
+ children: React.ReactNode;
7
+ borderColor?: string;
8
+ width?: number | string;
9
+ padding?: number;
10
+ }
11
+
12
+ export function Box({
13
+ title,
14
+ children,
15
+ borderColor = "cyan",
16
+ width,
17
+ padding = 1,
18
+ }: BoxProps) {
19
+ return (
20
+ <InkBox
21
+ flexDirection="column"
22
+ borderStyle="round"
23
+ borderColor={borderColor}
24
+ width={width}
25
+ paddingX={padding}
26
+ >
27
+ {title && (
28
+ <InkBox marginBottom={1}>
29
+ <Text bold color={borderColor}>
30
+ {title}
31
+ </Text>
32
+ </InkBox>
33
+ )}
34
+ {children}
35
+ </InkBox>
36
+ );
37
+ }