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.
- package/LICENSE +21 -0
- package/README.md +266 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +183 -0
- package/dist/cli.js.map +1 -0
- package/dist/core/accounts.d.ts +19 -0
- package/dist/core/accounts.d.ts.map +1 -0
- package/dist/core/accounts.js +181 -0
- package/dist/core/accounts.js.map +1 -0
- package/dist/core/config-store.d.ts +48 -0
- package/dist/core/config-store.d.ts.map +1 -0
- package/dist/core/config-store.js +206 -0
- package/dist/core/config-store.js.map +1 -0
- package/dist/core/crypto.d.ts +40 -0
- package/dist/core/crypto.d.ts.map +1 -0
- package/dist/core/crypto.js +172 -0
- package/dist/core/crypto.js.map +1 -0
- package/dist/core/importers/amJson.d.ts +17 -0
- package/dist/core/importers/amJson.d.ts.map +1 -0
- package/dist/core/importers/amJson.js +131 -0
- package/dist/core/importers/amJson.js.map +1 -0
- package/dist/core/opencode-config.d.ts +92 -0
- package/dist/core/opencode-config.d.ts.map +1 -0
- package/dist/core/opencode-config.js +148 -0
- package/dist/core/opencode-config.js.map +1 -0
- package/dist/core/paths.d.ts +5 -0
- package/dist/core/paths.d.ts.map +1 -0
- package/dist/core/paths.js +38 -0
- package/dist/core/paths.js.map +1 -0
- package/dist/core/types.d.ts +74 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +30 -0
- package/dist/core/types.js.map +1 -0
- package/dist/core/utils.d.ts +5 -0
- package/dist/core/utils.d.ts.map +1 -0
- package/dist/core/utils.js +35 -0
- package/dist/core/utils.js.map +1 -0
- package/dist/tui/Dashboard.d.ts +7 -0
- package/dist/tui/Dashboard.d.ts.map +1 -0
- package/dist/tui/Dashboard.js +331 -0
- package/dist/tui/Dashboard.js.map +1 -0
- package/dist/tui/components/AccountList.d.ts +18 -0
- package/dist/tui/components/AccountList.d.ts.map +1 -0
- package/dist/tui/components/AccountList.js +92 -0
- package/dist/tui/components/AccountList.js.map +1 -0
- package/dist/tui/components/Box.d.ts +11 -0
- package/dist/tui/components/Box.d.ts.map +1 -0
- package/dist/tui/components/Box.js +15 -0
- package/dist/tui/components/Box.js.map +1 -0
- package/dist/tui/components/ExportModal.d.ts +10 -0
- package/dist/tui/components/ExportModal.d.ts.map +1 -0
- package/dist/tui/components/ExportModal.js +192 -0
- package/dist/tui/components/ExportModal.js.map +1 -0
- package/dist/tui/components/FileBrowser.d.ts +12 -0
- package/dist/tui/components/FileBrowser.d.ts.map +1 -0
- package/dist/tui/components/FileBrowser.js +349 -0
- package/dist/tui/components/FileBrowser.js.map +1 -0
- package/dist/tui/components/Header.d.ts +8 -0
- package/dist/tui/components/Header.d.ts.map +1 -0
- package/dist/tui/components/Header.js +20 -0
- package/dist/tui/components/Header.js.map +1 -0
- package/dist/tui/components/ImportModal.d.ts +10 -0
- package/dist/tui/components/ImportModal.d.ts.map +1 -0
- package/dist/tui/components/ImportModal.js +215 -0
- package/dist/tui/components/ImportModal.js.map +1 -0
- package/dist/tui/components/McpServerList.d.ts +8 -0
- package/dist/tui/components/McpServerList.d.ts.map +1 -0
- package/dist/tui/components/McpServerList.js +35 -0
- package/dist/tui/components/McpServerList.js.map +1 -0
- package/dist/tui/components/Menu.d.ts +10 -0
- package/dist/tui/components/Menu.d.ts.map +1 -0
- package/dist/tui/components/Menu.js +83 -0
- package/dist/tui/components/Menu.js.map +1 -0
- package/dist/tui/components/PasswordInput.d.ts +12 -0
- package/dist/tui/components/PasswordInput.d.ts.map +1 -0
- package/dist/tui/components/PasswordInput.js +130 -0
- package/dist/tui/components/PasswordInput.js.map +1 -0
- package/dist/tui/components/ProviderList.d.ts +8 -0
- package/dist/tui/components/ProviderList.d.ts.map +1 -0
- package/dist/tui/components/ProviderList.js +37 -0
- package/dist/tui/components/ProviderList.js.map +1 -0
- package/dist/tui/components/SectionBox.d.ts +10 -0
- package/dist/tui/components/SectionBox.d.ts.map +1 -0
- package/dist/tui/components/SectionBox.js +16 -0
- package/dist/tui/components/SectionBox.js.map +1 -0
- package/dist/tui/components/StatsRow.d.ts +13 -0
- package/dist/tui/components/StatsRow.d.ts.map +1 -0
- package/dist/tui/components/StatsRow.js +18 -0
- package/dist/tui/components/StatsRow.js.map +1 -0
- package/dist/tui/components/StatusBadge.d.ts +8 -0
- package/dist/tui/components/StatusBadge.d.ts.map +1 -0
- package/dist/tui/components/StatusBadge.js +30 -0
- package/dist/tui/components/StatusBadge.js.map +1 -0
- package/dist/tui/components/index.d.ts +14 -0
- package/dist/tui/components/index.d.ts.map +1 -0
- package/dist/tui/components/index.js +32 -0
- package/dist/tui/components/index.js.map +1 -0
- package/dist/tui/index.d.ts +5 -0
- package/dist/tui/index.d.ts.map +1 -0
- package/dist/tui/index.js +13 -0
- package/dist/tui/index.js.map +1 -0
- package/docs/BLUEPRINT.md +476 -0
- package/docs/ROADMAP.md +74 -0
- package/package.json +38 -0
- package/src/cli.ts +207 -0
- package/src/core/accounts.ts +215 -0
- package/src/core/config-store.ts +212 -0
- package/src/core/crypto.ts +162 -0
- package/src/core/importers/amJson.ts +185 -0
- package/src/core/opencode-config.ts +217 -0
- package/src/core/paths.ts +32 -0
- package/src/core/types.ts +118 -0
- package/src/core/utils.ts +28 -0
- package/src/tui/Dashboard.tsx +431 -0
- package/src/tui/components/AccountList.tsx +155 -0
- package/src/tui/components/Box.tsx +37 -0
- package/src/tui/components/ExportModal.tsx +255 -0
- package/src/tui/components/FileBrowser.tsx +393 -0
- package/src/tui/components/Header.tsx +26 -0
- package/src/tui/components/ImportModal.tsx +288 -0
- package/src/tui/components/McpServerList.tsx +67 -0
- package/src/tui/components/Menu.tsx +103 -0
- package/src/tui/components/PasswordInput.tsx +159 -0
- package/src/tui/components/ProviderList.tsx +61 -0
- package/src/tui/components/SectionBox.tsx +35 -0
- package/src/tui/components/StatsRow.tsx +33 -0
- package/src/tui/components/StatusBadge.tsx +33 -0
- package/src/tui/components/index.ts +13 -0
- package/src/tui/index.tsx +11 -0
- 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
|
+
}
|