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,288 @@
1
+ import React, { useState } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import * as fs from "fs";
4
+ import * as path from "path";
5
+ import { FileBrowser } from "./FileBrowser";
6
+ import { PasswordInput } from "./PasswordInput";
7
+ import {
8
+ Account,
9
+ EncryptedExportFile,
10
+ PortableExportFile,
11
+ isEncryptedExportFile,
12
+ isPortableExportFile,
13
+ } from "../../core/types";
14
+ import { decrypt } from "../../core/crypto";
15
+ import { readJsonFile } from "../../core/utils";
16
+ import { updateLastImportFolder, getLastImportFolder } from "../../core/config-store";
17
+
18
+ interface ImportModalProps {
19
+ existingAccounts: Account[];
20
+ onComplete: (accounts: Account[], newCount: number, overwrittenCount: number) => void;
21
+ onCancel: () => void;
22
+ }
23
+
24
+ type ImportStep = "file" | "password" | "preview" | "importing" | "success" | "error";
25
+
26
+ interface ImportPreviewItem {
27
+ email: string;
28
+ exists: boolean;
29
+ }
30
+
31
+ export function ImportModal({ existingAccounts, onComplete, onCancel }: ImportModalProps) {
32
+ const [step, setStep] = useState<ImportStep>("file");
33
+ const [selectedFile, setSelectedFile] = useState<string>("");
34
+ const [isEncrypted, setIsEncrypted] = useState<boolean>(false);
35
+ const [importedAccounts, setImportedAccounts] = useState<Account[]>([]);
36
+ const [previewItems, setPreviewItems] = useState<ImportPreviewItem[]>([]);
37
+ const [error, setError] = useState<string>("");
38
+ const [result, setResult] = useState({ newCount: 0, overwrittenCount: 0 });
39
+
40
+ // Check if account exists in current accounts
41
+ const existsInCurrent = (email: string): boolean => {
42
+ return existingAccounts.some(
43
+ acc => acc.email.toLowerCase() === email.toLowerCase()
44
+ );
45
+ };
46
+
47
+ // Handle file selection
48
+ const handleFileSelect = (filePath: string) => {
49
+ setSelectedFile(filePath);
50
+ updateLastImportFolder(path.dirname(filePath));
51
+
52
+ try {
53
+ const data = readJsonFile(filePath);
54
+
55
+ if (isEncryptedExportFile(data)) {
56
+ setIsEncrypted(true);
57
+ setStep("password");
58
+ } else if (isPortableExportFile(data)) {
59
+ setIsEncrypted(false);
60
+ processAccounts(data.accounts);
61
+ } else if (Array.isArray(data)) {
62
+ // Raw array of accounts
63
+ setIsEncrypted(false);
64
+ processAccounts(data);
65
+ } else {
66
+ throw new Error("Unknown file format");
67
+ }
68
+ } catch (err) {
69
+ setError(err instanceof Error ? err.message : "Failed to read file");
70
+ setStep("error");
71
+ }
72
+ };
73
+
74
+ // Handle password for encrypted files
75
+ const handlePasswordSubmit = (password: string) => {
76
+ try {
77
+ const data = readJsonFile<EncryptedExportFile>(selectedFile);
78
+
79
+ const decrypted = decrypt<{ accounts: Account[] }>(
80
+ {
81
+ salt: data.salt,
82
+ iv: data.iv,
83
+ authTag: data.authTag,
84
+ data: data.data,
85
+ },
86
+ password
87
+ );
88
+
89
+ processAccounts(decrypted.accounts);
90
+ } catch (err) {
91
+ setError("Invalid password or corrupted file");
92
+ setStep("error");
93
+ }
94
+ };
95
+
96
+ // Process accounts and show preview
97
+ const processAccounts = (accounts: Account[]) => {
98
+ // Filter valid accounts
99
+ const validAccounts = accounts.filter(
100
+ acc => acc.email && acc.email.includes("@")
101
+ );
102
+
103
+ if (validAccounts.length === 0) {
104
+ setError("No valid accounts found in file");
105
+ setStep("error");
106
+ return;
107
+ }
108
+
109
+ setImportedAccounts(validAccounts);
110
+
111
+ // Build preview
112
+ const preview: ImportPreviewItem[] = validAccounts.map(acc => ({
113
+ email: acc.email,
114
+ exists: existsInCurrent(acc.email),
115
+ }));
116
+
117
+ setPreviewItems(preview);
118
+ setStep("preview");
119
+ };
120
+
121
+ // Handle import confirmation
122
+ useInput((input, key) => {
123
+ if (step === "preview") {
124
+ if (key.return) {
125
+ doImport();
126
+ } else if (key.escape) {
127
+ onCancel();
128
+ }
129
+ } else if (step === "success" || step === "error") {
130
+ if (key.return || key.escape) {
131
+ if (step === "success") {
132
+ onComplete(importedAccounts, result.newCount, result.overwrittenCount);
133
+ } else {
134
+ onCancel();
135
+ }
136
+ }
137
+ }
138
+ });
139
+
140
+ // Do the actual import
141
+ const doImport = () => {
142
+ setStep("importing");
143
+
144
+ const newCount = previewItems.filter(p => !p.exists).length;
145
+ const overwrittenCount = previewItems.filter(p => p.exists).length;
146
+
147
+ setResult({ newCount, overwrittenCount });
148
+ setStep("success");
149
+
150
+ // Auto-complete after 2 seconds
151
+ setTimeout(() => {
152
+ onComplete(importedAccounts, newCount, overwrittenCount);
153
+ }, 2000);
154
+ };
155
+
156
+ return (
157
+ <Box flexDirection="column">
158
+ {/* File Selection */}
159
+ {step === "file" && (
160
+ <FileBrowser
161
+ mode="file"
162
+ initialPath={getLastImportFolder()}
163
+ extensions={[".ocam", ".json"]}
164
+ title="Select file to import"
165
+ onSelect={handleFileSelect}
166
+ onCancel={onCancel}
167
+ />
168
+ )}
169
+
170
+ {/* Password Input */}
171
+ {step === "password" && (
172
+ <PasswordInput
173
+ mode="single"
174
+ title="Enter Password"
175
+ subtitle={path.basename(selectedFile)}
176
+ onSubmit={handlePasswordSubmit}
177
+ onCancel={onCancel}
178
+ />
179
+ )}
180
+
181
+ {/* Preview */}
182
+ {step === "preview" && (
183
+ <Box
184
+ flexDirection="column"
185
+ borderStyle="round"
186
+ borderColor="cyan"
187
+ paddingX={2}
188
+ paddingY={1}
189
+ >
190
+ <Text bold color="cyan">IMPORT PREVIEW</Text>
191
+ <Box marginY={1}>
192
+ <Text>
193
+ Found {importedAccounts.length} accounts in{" "}
194
+ <Text color="cyan">{path.basename(selectedFile)}</Text>
195
+ {isEncrypted && <Text color="green"> (encrypted)</Text>}
196
+ </Text>
197
+ </Box>
198
+
199
+ {/* Account list */}
200
+ <Box flexDirection="column">
201
+ {previewItems.slice(0, 8).map((item, index) => (
202
+ <Box key={item.email}>
203
+ <Text>• {item.email}</Text>
204
+ {item.exists && (
205
+ <Text color="yellow"> ⚠️ exists (will overwrite)</Text>
206
+ )}
207
+ </Box>
208
+ ))}
209
+ {previewItems.length > 8 && (
210
+ <Text dimColor>... and {previewItems.length - 8} more</Text>
211
+ )}
212
+ </Box>
213
+
214
+ {/* Summary */}
215
+ <Box marginTop={1}>
216
+ <Text>
217
+ <Text color="green">{previewItems.filter(p => !p.exists).length} new</Text>
218
+ {" | "}
219
+ <Text color="yellow">{previewItems.filter(p => p.exists).length} will overwrite</Text>
220
+ </Text>
221
+ </Box>
222
+
223
+ <Box marginTop={1}>
224
+ <Text dimColor>[Enter] Import [Esc] Cancel</Text>
225
+ </Box>
226
+ </Box>
227
+ )}
228
+
229
+ {/* Importing */}
230
+ {step === "importing" && (
231
+ <Box
232
+ flexDirection="column"
233
+ borderStyle="round"
234
+ borderColor="cyan"
235
+ paddingX={2}
236
+ paddingY={1}
237
+ >
238
+ <Text color="cyan">Importing...</Text>
239
+ </Box>
240
+ )}
241
+
242
+ {/* Success */}
243
+ {step === "success" && (
244
+ <Box
245
+ flexDirection="column"
246
+ borderStyle="round"
247
+ borderColor="green"
248
+ paddingX={2}
249
+ paddingY={1}
250
+ >
251
+ <Text bold color="green">✓ Import Successful!</Text>
252
+ <Box marginTop={1}>
253
+ <Text>
254
+ Imported {importedAccounts.length} accounts
255
+ {" ("}
256
+ <Text color="green">{result.newCount} new</Text>
257
+ {", "}
258
+ <Text color="yellow">{result.overwrittenCount} overwritten</Text>
259
+ {")"}
260
+ </Text>
261
+ </Box>
262
+ <Box marginTop={1}>
263
+ <Text dimColor>[Enter] Close</Text>
264
+ </Box>
265
+ </Box>
266
+ )}
267
+
268
+ {/* Error */}
269
+ {step === "error" && (
270
+ <Box
271
+ flexDirection="column"
272
+ borderStyle="round"
273
+ borderColor="red"
274
+ paddingX={2}
275
+ paddingY={1}
276
+ >
277
+ <Text bold color="red">✗ Import Failed</Text>
278
+ <Box marginTop={1}>
279
+ <Text color="red">{error}</Text>
280
+ </Box>
281
+ <Box marginTop={1}>
282
+ <Text dimColor>[Enter] Try again [Esc] Cancel</Text>
283
+ </Box>
284
+ </Box>
285
+ )}
286
+ </Box>
287
+ );
288
+ }
@@ -0,0 +1,67 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ import { McpServerInfo } from "../../core/opencode-config";
4
+
5
+ interface McpServerListProps {
6
+ servers: McpServerInfo[];
7
+ }
8
+
9
+ export function McpServerList({ servers }: McpServerListProps) {
10
+ if (servers.length === 0) {
11
+ return (
12
+ <Box paddingX={1}>
13
+ <Text dimColor>No MCP servers configured</Text>
14
+ </Box>
15
+ );
16
+ }
17
+
18
+ return (
19
+ <Box flexDirection="column" paddingX={1}>
20
+ {/* Header */}
21
+ <Box>
22
+ <Box width={20}>
23
+ <Text bold dimColor>SERVER</Text>
24
+ </Box>
25
+ <Box width={10}>
26
+ <Text bold dimColor>STATUS</Text>
27
+ </Box>
28
+ <Box width={8}>
29
+ <Text bold dimColor>ENV</Text>
30
+ </Box>
31
+ <Box>
32
+ <Text bold dimColor>COMMAND</Text>
33
+ </Box>
34
+ </Box>
35
+
36
+ {/* Rows */}
37
+ {servers.map((server) => (
38
+ <Box key={server.id}>
39
+ <Box width={20}>
40
+ <Text color="cyan">{truncate(server.id, 18)}</Text>
41
+ </Box>
42
+ <Box width={10}>
43
+ {server.enabled ? (
44
+ <Text color="green">enabled</Text>
45
+ ) : (
46
+ <Text color="red">disabled</Text>
47
+ )}
48
+ </Box>
49
+ <Box width={8}>
50
+ {server.hasEnvVars ? (
51
+ <Text color="yellow">{server.envVarCount}</Text>
52
+ ) : (
53
+ <Text dimColor>-</Text>
54
+ )}
55
+ </Box>
56
+ <Box>
57
+ <Text dimColor>{truncate(server.command, 40)}</Text>
58
+ </Box>
59
+ </Box>
60
+ ))}
61
+ </Box>
62
+ );
63
+ }
64
+
65
+ function truncate(str: string, len: number): string {
66
+ return str.length > len ? str.slice(0, len - 1) + "…" : str;
67
+ }
@@ -0,0 +1,103 @@
1
+ import React from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+
4
+ export type MenuAction =
5
+ | "refresh"
6
+ | "export"
7
+ | "export-selected"
8
+ | "import-file"
9
+ | "import-am"
10
+ | "toggle-select-mode"
11
+ | "select-all"
12
+ | "select-none"
13
+ | "enable-selected"
14
+ | "disable-selected"
15
+ | "delete-selected"
16
+ | "quit";
17
+
18
+ interface MenuItem {
19
+ label: string;
20
+ key: string;
21
+ action: MenuAction;
22
+ selectModeOnly?: boolean;
23
+ normalModeOnly?: boolean;
24
+ }
25
+
26
+ const MENU_ITEMS: MenuItem[] = [
27
+ { label: "Refresh", key: "R", action: "refresh" },
28
+ { label: "Export", key: "E", action: "export", normalModeOnly: true },
29
+ { label: "Import", key: "I", action: "import-file", normalModeOnly: true },
30
+ { label: "AM Import", key: "A", action: "import-am", normalModeOnly: true },
31
+ { label: "Select Mode", key: "S", action: "toggle-select-mode", normalModeOnly: true },
32
+ { label: "Exit Select", key: "S", action: "toggle-select-mode", selectModeOnly: true },
33
+ { label: "All", key: "A", action: "select-all", selectModeOnly: true },
34
+ { label: "None", key: "N", action: "select-none", selectModeOnly: true },
35
+ { label: "Enable", key: "E", action: "enable-selected", selectModeOnly: true },
36
+ { label: "Disable", key: "D", action: "disable-selected", selectModeOnly: true },
37
+ { label: "Export", key: "X", action: "export-selected", selectModeOnly: true },
38
+ { label: "Delete", key: "DEL", action: "delete-selected", selectModeOnly: true },
39
+ { label: "Quit", key: "Q", action: "quit" },
40
+ ];
41
+
42
+ interface MenuBarProps {
43
+ onSelect: (action: MenuAction) => void;
44
+ selectMode?: boolean;
45
+ selectedCount?: number;
46
+ }
47
+
48
+ export function MenuBar({ onSelect, selectMode = false, selectedCount = 0 }: MenuBarProps) {
49
+ useInput((input, key) => {
50
+ const lower = input.toLowerCase();
51
+
52
+ if (selectMode) {
53
+ // Select mode keys
54
+ if (lower === "s" || key.escape) onSelect("toggle-select-mode");
55
+ if (lower === "a") onSelect("select-all");
56
+ if (lower === "n") onSelect("select-none");
57
+ if (lower === "e") onSelect("enable-selected");
58
+ if (lower === "d") onSelect("disable-selected");
59
+ if (lower === "x") onSelect("export-selected");
60
+ if (key.delete || lower === "backspace") onSelect("delete-selected");
61
+ if (lower === "r") onSelect("refresh");
62
+ if (lower === "q") onSelect("quit");
63
+ } else {
64
+ // Normal mode keys
65
+ if (lower === "r") onSelect("refresh");
66
+ if (lower === "e") onSelect("export");
67
+ if (lower === "i") onSelect("import-file");
68
+ if (lower === "a") onSelect("import-am");
69
+ if (lower === "s") onSelect("toggle-select-mode");
70
+ if (lower === "q" || key.escape) onSelect("quit");
71
+ }
72
+ });
73
+
74
+ const visibleItems = MENU_ITEMS.filter(item => {
75
+ if (selectMode && item.normalModeOnly) return false;
76
+ if (!selectMode && item.selectModeOnly) return false;
77
+ return true;
78
+ });
79
+
80
+ return (
81
+ <Box flexDirection="column">
82
+ {selectMode && (
83
+ <Box marginBottom={1} paddingX={1}>
84
+ <Text color="yellow" bold>
85
+ SELECT MODE - {selectedCount} selected | ↑↓ navigate | SPACE toggle | ENTER confirm
86
+ </Text>
87
+ </Box>
88
+ )}
89
+ <Box
90
+ borderStyle="single"
91
+ borderColor={selectMode ? "yellow" : "gray"}
92
+ paddingX={1}
93
+ justifyContent="space-between"
94
+ >
95
+ {visibleItems.map((item) => (
96
+ <Text key={item.action + item.key} dimColor>
97
+ [{item.key}] {item.label}
98
+ </Text>
99
+ ))}
100
+ </Box>
101
+ </Box>
102
+ );
103
+ }
@@ -0,0 +1,159 @@
1
+ import React, { useState } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+
4
+ interface PasswordInputProps {
5
+ mode: "single" | "confirm";
6
+ title?: string;
7
+ subtitle?: string;
8
+ warning?: string;
9
+ onSubmit: (password: string) => void;
10
+ onCancel: () => void;
11
+ }
12
+
13
+ export function PasswordInput({
14
+ mode,
15
+ title = "Enter Password",
16
+ subtitle,
17
+ warning,
18
+ onSubmit,
19
+ onCancel,
20
+ }: PasswordInputProps) {
21
+ const [password, setPassword] = useState("");
22
+ const [confirmPassword, setConfirmPassword] = useState("");
23
+ const [activeField, setActiveField] = useState<"password" | "confirm">("password");
24
+ const [error, setError] = useState<string | null>(null);
25
+
26
+ useInput((input, key) => {
27
+ // Handle escape
28
+ if (key.escape) {
29
+ onCancel();
30
+ return;
31
+ }
32
+
33
+ // Handle tab to switch fields (in confirm mode)
34
+ if (key.tab && mode === "confirm") {
35
+ setActiveField(prev => prev === "password" ? "confirm" : "password");
36
+ return;
37
+ }
38
+
39
+ // Handle enter
40
+ if (key.return) {
41
+ if (mode === "confirm" && activeField === "password") {
42
+ // Move to confirm field
43
+ setActiveField("confirm");
44
+ return;
45
+ }
46
+
47
+ // Validate
48
+ if (password.length === 0) {
49
+ setError("Password cannot be empty");
50
+ return;
51
+ }
52
+
53
+ if (mode === "confirm" && password !== confirmPassword) {
54
+ setError("Passwords do not match");
55
+ return;
56
+ }
57
+
58
+ onSubmit(password);
59
+ return;
60
+ }
61
+
62
+ // Handle backspace
63
+ if (key.backspace || key.delete) {
64
+ if (activeField === "password") {
65
+ setPassword(prev => prev.slice(0, -1));
66
+ } else {
67
+ setConfirmPassword(prev => prev.slice(0, -1));
68
+ }
69
+ setError(null);
70
+ return;
71
+ }
72
+
73
+ // Handle regular input (printable characters)
74
+ if (input && input.length === 1 && !key.ctrl && !key.meta) {
75
+ if (activeField === "password") {
76
+ setPassword(prev => prev + input);
77
+ } else {
78
+ setConfirmPassword(prev => prev + input);
79
+ }
80
+ setError(null);
81
+ }
82
+ });
83
+
84
+ const maskPassword = (pwd: string): string => {
85
+ return "•".repeat(pwd.length);
86
+ };
87
+
88
+ return (
89
+ <Box
90
+ flexDirection="column"
91
+ borderStyle="round"
92
+ borderColor="cyan"
93
+ paddingX={2}
94
+ paddingY={1}
95
+ >
96
+ {/* Title */}
97
+ <Box marginBottom={1}>
98
+ <Text bold color="cyan">{title}</Text>
99
+ {subtitle && <Text dimColor> - {subtitle}</Text>}
100
+ </Box>
101
+
102
+ {/* Password field */}
103
+ <Box>
104
+ <Text dimColor>Password: </Text>
105
+ <Box
106
+ borderStyle={activeField === "password" ? "single" : undefined}
107
+ borderColor="yellow"
108
+ paddingX={1}
109
+ minWidth={30}
110
+ >
111
+ <Text color={activeField === "password" ? "white" : "gray"}>
112
+ {maskPassword(password)}
113
+ {activeField === "password" && <Text color="yellow">▌</Text>}
114
+ </Text>
115
+ </Box>
116
+ </Box>
117
+
118
+ {/* Confirm field (only in confirm mode) */}
119
+ {mode === "confirm" && (
120
+ <Box marginTop={1}>
121
+ <Text dimColor>Confirm: </Text>
122
+ <Box
123
+ borderStyle={activeField === "confirm" ? "single" : undefined}
124
+ borderColor="yellow"
125
+ paddingX={1}
126
+ minWidth={30}
127
+ >
128
+ <Text color={activeField === "confirm" ? "white" : "gray"}>
129
+ {maskPassword(confirmPassword)}
130
+ {activeField === "confirm" && <Text color="yellow">▌</Text>}
131
+ </Text>
132
+ </Box>
133
+ </Box>
134
+ )}
135
+
136
+ {/* Warning */}
137
+ {warning && (
138
+ <Box marginTop={1}>
139
+ <Text color="yellow">⚠️ {warning}</Text>
140
+ </Box>
141
+ )}
142
+
143
+ {/* Error */}
144
+ {error && (
145
+ <Box marginTop={1}>
146
+ <Text color="red">✗ {error}</Text>
147
+ </Box>
148
+ )}
149
+
150
+ {/* Help */}
151
+ <Box marginTop={1}>
152
+ <Text dimColor>
153
+ {mode === "confirm" ? "[Tab] Switch field " : ""}
154
+ [Enter] Confirm [Esc] Cancel
155
+ </Text>
156
+ </Box>
157
+ </Box>
158
+ );
159
+ }
@@ -0,0 +1,61 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ import { ProviderInfo } from "../../core/opencode-config";
4
+
5
+ interface ProviderListProps {
6
+ providers: ProviderInfo[];
7
+ }
8
+
9
+ export function ProviderList({ providers }: ProviderListProps) {
10
+ if (providers.length === 0) {
11
+ return (
12
+ <Box paddingX={1}>
13
+ <Text dimColor>No providers configured</Text>
14
+ </Box>
15
+ );
16
+ }
17
+
18
+ return (
19
+ <Box flexDirection="column" paddingX={1}>
20
+ {/* Header */}
21
+ <Box>
22
+ <Box width={20}>
23
+ <Text bold dimColor>PROVIDER</Text>
24
+ </Box>
25
+ <Box width={10}>
26
+ <Text bold dimColor>MODELS</Text>
27
+ </Box>
28
+ <Box width={10}>
29
+ <Text bold dimColor>TYPE</Text>
30
+ </Box>
31
+ <Box>
32
+ <Text bold dimColor>BASE URL</Text>
33
+ </Box>
34
+ </Box>
35
+
36
+ {/* Rows */}
37
+ {providers.map((provider) => (
38
+ <Box key={provider.id}>
39
+ <Box width={20}>
40
+ <Text color="cyan">{truncate(provider.name || provider.id, 18)}</Text>
41
+ </Box>
42
+ <Box width={10}>
43
+ <Text color="yellow">{provider.modelCount}</Text>
44
+ </Box>
45
+ <Box width={10}>
46
+ <Text color={provider.type === "builtin" ? "green" : "magenta"}>
47
+ {provider.type}
48
+ </Text>
49
+ </Box>
50
+ <Box>
51
+ <Text dimColor>{provider.baseURL || "-"}</Text>
52
+ </Box>
53
+ </Box>
54
+ ))}
55
+ </Box>
56
+ );
57
+ }
58
+
59
+ function truncate(str: string, len: number): string {
60
+ return str.length > len ? str.slice(0, len - 1) + "…" : str;
61
+ }