@benzsiangco/jarvis 1.0.0 → 1.1.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.
- package/README.md +5 -0
- package/bin/{jarvis.js → jarvis} +1 -1
- package/dist/cli.js +476 -350
- package/dist/electron/main.js +160 -0
- package/dist/electron/preload.js +19 -0
- package/package.json +21 -8
- package/skills.md +147 -0
- package/src/agents/index.ts +248 -0
- package/src/brain/loader.ts +136 -0
- package/src/cli.ts +411 -0
- package/src/config/index.ts +363 -0
- package/src/core/executor.ts +222 -0
- package/src/core/plugins.ts +148 -0
- package/src/core/types.ts +217 -0
- package/src/electron/main.ts +192 -0
- package/src/electron/preload.ts +25 -0
- package/src/electron/types.d.ts +20 -0
- package/src/index.ts +12 -0
- package/src/providers/antigravity-loader.ts +233 -0
- package/src/providers/antigravity.ts +585 -0
- package/src/providers/index.ts +523 -0
- package/src/sessions/index.ts +194 -0
- package/src/tools/index.ts +436 -0
- package/src/tui/index.tsx +784 -0
- package/src/utils/auth-prompt.ts +394 -0
- package/src/utils/index.ts +180 -0
- package/src/utils/native-picker.ts +71 -0
- package/src/utils/skills.ts +99 -0
- package/src/utils/table-integration-examples.ts +617 -0
- package/src/utils/table-utils.ts +401 -0
- package/src/web/build-ui.ts +27 -0
- package/src/web/server.ts +674 -0
- package/src/web/ui/dist/.gitkeep +0 -0
- package/src/web/ui/dist/main.css +1 -0
- package/src/web/ui/dist/main.js +320 -0
- package/src/web/ui/dist/main.js.map +20 -0
- package/src/web/ui/index.html +46 -0
- package/src/web/ui/src/App.tsx +143 -0
- package/src/web/ui/src/Modules/Safety/GuardianModal.tsx +83 -0
- package/src/web/ui/src/components/Layout/ContextPanel.tsx +243 -0
- package/src/web/ui/src/components/Layout/Header.tsx +91 -0
- package/src/web/ui/src/components/Layout/ModelSelector.tsx +235 -0
- package/src/web/ui/src/components/Layout/SessionStats.tsx +369 -0
- package/src/web/ui/src/components/Layout/Sidebar.tsx +895 -0
- package/src/web/ui/src/components/Modules/Chat/ChatStage.tsx +620 -0
- package/src/web/ui/src/components/Modules/Chat/MessageItem.tsx +446 -0
- package/src/web/ui/src/components/Modules/Editor/CommandInspector.tsx +71 -0
- package/src/web/ui/src/components/Modules/Editor/DiffViewer.tsx +83 -0
- package/src/web/ui/src/components/Modules/Terminal/TabbedTerminal.tsx +202 -0
- package/src/web/ui/src/components/Settings/SettingsModal.tsx +935 -0
- package/src/web/ui/src/config/models.ts +70 -0
- package/src/web/ui/src/main.tsx +13 -0
- package/src/web/ui/src/store/agentStore.ts +41 -0
- package/src/web/ui/src/store/uiStore.ts +64 -0
- package/src/web/ui/src/types/index.ts +54 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
// Interactive Auth using @inquirer/prompts (arrow key navigation)
|
|
2
|
+
import { select, confirm, password, input } from '@inquirer/prompts';
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { homedir } from 'os';
|
|
6
|
+
import {
|
|
7
|
+
startAntigravityLogin,
|
|
8
|
+
loadAccounts,
|
|
9
|
+
saveAccounts,
|
|
10
|
+
} from '../providers/antigravity.js';
|
|
11
|
+
|
|
12
|
+
const CONFIG_DIR = join(homedir(), '.config', 'jarvis');
|
|
13
|
+
const API_KEYS_FILE = join(CONFIG_DIR, 'api-keys.json');
|
|
14
|
+
|
|
15
|
+
interface Provider {
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
envVar: string;
|
|
19
|
+
hasOAuth?: boolean;
|
|
20
|
+
apiKeyUrl?: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// All providers with their auth options
|
|
25
|
+
export const providers: Provider[] = [
|
|
26
|
+
{
|
|
27
|
+
id: 'google',
|
|
28
|
+
name: 'Google (Gemini)',
|
|
29
|
+
envVar: 'GOOGLE_API_KEY',
|
|
30
|
+
hasOAuth: true,
|
|
31
|
+
apiKeyUrl: 'https://aistudio.google.com/apikey',
|
|
32
|
+
description: 'Free with OAuth, or use API key'
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: 'anthropic',
|
|
36
|
+
name: 'Anthropic (Claude)',
|
|
37
|
+
envVar: 'ANTHROPIC_API_KEY',
|
|
38
|
+
apiKeyUrl: 'https://console.anthropic.com/settings/keys',
|
|
39
|
+
description: 'Requires API key'
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: 'openai',
|
|
43
|
+
name: 'OpenAI (GPT)',
|
|
44
|
+
envVar: 'OPENAI_API_KEY',
|
|
45
|
+
apiKeyUrl: 'https://platform.openai.com/api-keys',
|
|
46
|
+
description: 'Requires API key'
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: 'groq',
|
|
50
|
+
name: 'Groq',
|
|
51
|
+
envVar: 'GROQ_API_KEY',
|
|
52
|
+
apiKeyUrl: 'https://console.groq.com/keys',
|
|
53
|
+
description: 'Free tier available'
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: 'mistral',
|
|
57
|
+
name: 'Mistral',
|
|
58
|
+
envVar: 'MISTRAL_API_KEY',
|
|
59
|
+
apiKeyUrl: 'https://console.mistral.ai/api-keys',
|
|
60
|
+
description: 'Requires API key'
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: 'deepseek',
|
|
64
|
+
name: 'DeepSeek',
|
|
65
|
+
envVar: 'DEEPSEEK_API_KEY',
|
|
66
|
+
apiKeyUrl: 'https://platform.deepseek.com/api_keys',
|
|
67
|
+
description: 'Very affordable pricing'
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: 'together',
|
|
71
|
+
name: 'Together AI',
|
|
72
|
+
envVar: 'TOGETHER_API_KEY',
|
|
73
|
+
apiKeyUrl: 'https://api.together.xyz/settings/api-keys',
|
|
74
|
+
description: 'Open source models'
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: 'perplexity',
|
|
78
|
+
name: 'Perplexity',
|
|
79
|
+
envVar: 'PERPLEXITY_API_KEY',
|
|
80
|
+
apiKeyUrl: 'https://www.perplexity.ai/settings/api',
|
|
81
|
+
description: 'Web-connected AI'
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: 'fireworks',
|
|
85
|
+
name: 'Fireworks',
|
|
86
|
+
envVar: 'FIREWORKS_API_KEY',
|
|
87
|
+
apiKeyUrl: 'https://fireworks.ai/api-keys',
|
|
88
|
+
description: 'Fast inference'
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
id: 'xai',
|
|
92
|
+
name: 'xAI (Grok)',
|
|
93
|
+
envVar: 'XAI_API_KEY',
|
|
94
|
+
apiKeyUrl: 'https://console.x.ai',
|
|
95
|
+
description: 'Grok models'
|
|
96
|
+
},
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
// Load saved API keys
|
|
100
|
+
function loadApiKeys(): Record<string, string> {
|
|
101
|
+
if (!existsSync(API_KEYS_FILE)) {
|
|
102
|
+
return {};
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
return JSON.parse(readFileSync(API_KEYS_FILE, 'utf-8'));
|
|
106
|
+
} catch {
|
|
107
|
+
return {};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Save API key
|
|
112
|
+
export function saveApiKey(providerId: string, apiKey: string): void {
|
|
113
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
114
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
115
|
+
}
|
|
116
|
+
const keys = loadApiKeys();
|
|
117
|
+
keys[providerId] = apiKey;
|
|
118
|
+
writeFileSync(API_KEYS_FILE, JSON.stringify(keys, null, 2));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Remove API key
|
|
122
|
+
export function removeApiKey(providerId: string): void {
|
|
123
|
+
const keys = loadApiKeys();
|
|
124
|
+
delete keys[providerId];
|
|
125
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
126
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
127
|
+
}
|
|
128
|
+
writeFileSync(API_KEYS_FILE, JSON.stringify(keys, null, 2));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Get configured provider status
|
|
132
|
+
export function getApiKeyStatus(): Array<{ id: string; name: string; configured: boolean; method: string }> {
|
|
133
|
+
const keys = loadApiKeys();
|
|
134
|
+
const accounts = loadAccounts();
|
|
135
|
+
|
|
136
|
+
return providers.map(p => {
|
|
137
|
+
const hasApiKey = !!keys[p.id] || !!process.env[p.envVar];
|
|
138
|
+
const hasOAuth = p.id === 'google' && accounts.accounts.length > 0;
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
id: p.id,
|
|
142
|
+
name: p.name,
|
|
143
|
+
configured: hasApiKey || hasOAuth,
|
|
144
|
+
method: hasOAuth ? 'OAuth' : hasApiKey ? 'API Key' : 'Not configured',
|
|
145
|
+
};
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const CYAN = '\x1b[36m';
|
|
150
|
+
const GREEN = '\x1b[32m';
|
|
151
|
+
const YELLOW = '\x1b[33m';
|
|
152
|
+
const RED = '\x1b[31m';
|
|
153
|
+
const RESET = '\x1b[0m';
|
|
154
|
+
const DIM = '\x1b[2m';
|
|
155
|
+
const BOLD = '\x1b[1m';
|
|
156
|
+
|
|
157
|
+
export async function runInteractiveAuth(): Promise<void> {
|
|
158
|
+
console.log('');
|
|
159
|
+
console.log(`${CYAN}┌ ${RESET}${BOLD}Add credential${RESET}`);
|
|
160
|
+
console.log(`${CYAN}│${RESET}`);
|
|
161
|
+
|
|
162
|
+
// Show current status
|
|
163
|
+
const status = getApiKeyStatus();
|
|
164
|
+
const configured = status.filter(s => s.configured);
|
|
165
|
+
if (configured.length > 0) {
|
|
166
|
+
console.log(`${DIM}Currently configured:${RESET}`);
|
|
167
|
+
configured.forEach(s => {
|
|
168
|
+
console.log(` ${GREEN}✓${RESET} ${s.name} (${s.method})`);
|
|
169
|
+
});
|
|
170
|
+
console.log(`${CYAN}│${RESET}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Provider selection with arrow keys
|
|
174
|
+
const providerId = await select({
|
|
175
|
+
message: 'Select provider to configure',
|
|
176
|
+
choices: providers.map(p => {
|
|
177
|
+
const s = status.find(st => st.id === p.id);
|
|
178
|
+
const statusIcon = s?.configured ? `${GREEN}✓${RESET}` : `${DIM}○${RESET}`;
|
|
179
|
+
return {
|
|
180
|
+
name: `${statusIcon} ${p.name}${p.id === 'google' ? ` ${GREEN}(recommended)${RESET}` : ''}`,
|
|
181
|
+
value: p.id,
|
|
182
|
+
description: p.description,
|
|
183
|
+
};
|
|
184
|
+
}),
|
|
185
|
+
theme: {
|
|
186
|
+
prefix: `${CYAN}◆${RESET}`,
|
|
187
|
+
style: {
|
|
188
|
+
highlight: (text: string) => `${CYAN}${text}${RESET}`,
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const provider = providers.find(p => p.id === providerId)!;
|
|
194
|
+
|
|
195
|
+
console.log(`${CYAN}│${RESET}`);
|
|
196
|
+
console.log(`${GREEN}◇ ${RESET}Selected: ${provider.name}`);
|
|
197
|
+
console.log(`${CYAN}│${RESET}`);
|
|
198
|
+
|
|
199
|
+
// For Google, offer both OAuth and API key
|
|
200
|
+
if (provider.hasOAuth) {
|
|
201
|
+
const method = await select({
|
|
202
|
+
message: 'Choose authentication method',
|
|
203
|
+
choices: [
|
|
204
|
+
{
|
|
205
|
+
name: `OAuth with Google ${GREEN}(recommended - FREE)${RESET}`,
|
|
206
|
+
value: 'oauth',
|
|
207
|
+
description: 'Uses Google account, no API key needed'
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
name: 'Enter API Key',
|
|
211
|
+
value: 'apikey',
|
|
212
|
+
description: 'Use your own Google AI Studio API key'
|
|
213
|
+
},
|
|
214
|
+
],
|
|
215
|
+
theme: {
|
|
216
|
+
prefix: `${CYAN}◆${RESET}`,
|
|
217
|
+
style: {
|
|
218
|
+
highlight: (text: string) => `${CYAN}${text}${RESET}`,
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
if (method === 'oauth') {
|
|
224
|
+
await handleOAuthLogin(provider);
|
|
225
|
+
} else {
|
|
226
|
+
await handleApiKeyEntry(provider);
|
|
227
|
+
}
|
|
228
|
+
} else {
|
|
229
|
+
// API key only provider
|
|
230
|
+
await handleApiKeyEntry(provider);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function handleOAuthLogin(provider: Provider): Promise<void> {
|
|
235
|
+
console.log(`${CYAN}│${RESET}`);
|
|
236
|
+
console.log(`${GREEN}◇ ${RESET}OAuth with Google (Antigravity)`);
|
|
237
|
+
console.log(`${CYAN}│${RESET}`);
|
|
238
|
+
|
|
239
|
+
// Check for existing accounts
|
|
240
|
+
const config = loadAccounts();
|
|
241
|
+
|
|
242
|
+
if (config.accounts.length > 0) {
|
|
243
|
+
console.log(`${YELLOW}${config.accounts.length} account(s) already configured:${RESET}`);
|
|
244
|
+
config.accounts.forEach((acc, i) => {
|
|
245
|
+
console.log(` ${i + 1}. ${acc.email || acc.id}`);
|
|
246
|
+
});
|
|
247
|
+
console.log('');
|
|
248
|
+
|
|
249
|
+
const action = await select({
|
|
250
|
+
message: 'What would you like to do?',
|
|
251
|
+
choices: [
|
|
252
|
+
{
|
|
253
|
+
name: 'Add another account',
|
|
254
|
+
value: 'add',
|
|
255
|
+
description: 'Add more accounts for rate limit bypass'
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
name: 'Replace all accounts',
|
|
259
|
+
value: 'fresh',
|
|
260
|
+
description: 'Remove existing and start fresh'
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
name: 'Cancel',
|
|
264
|
+
value: 'cancel',
|
|
265
|
+
description: 'Keep current configuration'
|
|
266
|
+
},
|
|
267
|
+
],
|
|
268
|
+
theme: {
|
|
269
|
+
prefix: `${CYAN}◆${RESET}`,
|
|
270
|
+
style: {
|
|
271
|
+
highlight: (text: string) => `${CYAN}${text}${RESET}`,
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
if (action === 'cancel') {
|
|
277
|
+
console.log(`${CYAN}└${RESET} No changes made.`);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (action === 'fresh') {
|
|
282
|
+
config.accounts = [];
|
|
283
|
+
saveAccounts(config);
|
|
284
|
+
console.log(`${YELLOW}All accounts cleared.${RESET}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Start OAuth flow
|
|
289
|
+
console.log(`${CYAN}│${RESET}`);
|
|
290
|
+
console.log(`${DIM}Starting OAuth flow...${RESET}`);
|
|
291
|
+
|
|
292
|
+
try {
|
|
293
|
+
await startAntigravityLogin();
|
|
294
|
+
|
|
295
|
+
// Show updated accounts
|
|
296
|
+
const updatedConfig = loadAccounts();
|
|
297
|
+
console.log('');
|
|
298
|
+
console.log(`${GREEN}✓ ${RESET}${BOLD}Authentication successful!${RESET}`);
|
|
299
|
+
console.log('');
|
|
300
|
+
console.log(`${YELLOW}${updatedConfig.accounts.length} account(s) configured:${RESET}`);
|
|
301
|
+
updatedConfig.accounts.forEach((acc, i) => {
|
|
302
|
+
console.log(` ${i + 1}. ${acc.email || acc.id}`);
|
|
303
|
+
});
|
|
304
|
+
console.log('');
|
|
305
|
+
console.log(`${DIM}Tip: Add more accounts with 'jarvis auth login' for rate limit bypass.${RESET}`);
|
|
306
|
+
console.log(`${CYAN}└${RESET}`);
|
|
307
|
+
} catch (error) {
|
|
308
|
+
console.log(`${RED}✗ ${RESET}OAuth failed: ${(error as Error).message}`);
|
|
309
|
+
console.log(`${CYAN}└${RESET}`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function handleApiKeyEntry(provider: Provider): Promise<void> {
|
|
314
|
+
const savedKeys = loadApiKeys();
|
|
315
|
+
const existingKey = savedKeys[provider.id] || process.env[provider.envVar];
|
|
316
|
+
|
|
317
|
+
if (existingKey) {
|
|
318
|
+
const masked = existingKey.substring(0, 8) + '...' + existingKey.substring(existingKey.length - 4);
|
|
319
|
+
console.log(`${GREEN}Current key: ${RESET}${DIM}${masked}${RESET}`);
|
|
320
|
+
console.log(`${CYAN}│${RESET}`);
|
|
321
|
+
|
|
322
|
+
const action = await select({
|
|
323
|
+
message: 'What would you like to do?',
|
|
324
|
+
choices: [
|
|
325
|
+
{ name: 'Keep existing key', value: 'keep', description: 'Use the current API key' },
|
|
326
|
+
{ name: 'Update key', value: 'update', description: 'Enter a new API key' },
|
|
327
|
+
{ name: 'Remove key', value: 'remove', description: 'Delete the saved key' },
|
|
328
|
+
],
|
|
329
|
+
theme: {
|
|
330
|
+
prefix: `${CYAN}◆${RESET}`,
|
|
331
|
+
style: {
|
|
332
|
+
highlight: (text: string) => `${CYAN}${text}${RESET}`,
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
if (action === 'keep') {
|
|
338
|
+
console.log(`${CYAN}└${RESET} No changes made.`);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (action === 'remove') {
|
|
343
|
+
removeApiKey(provider.id);
|
|
344
|
+
console.log(`${YELLOW}API key removed.${RESET}`);
|
|
345
|
+
console.log(`${CYAN}└${RESET}`);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Show API key URL hint
|
|
351
|
+
if (provider.apiKeyUrl) {
|
|
352
|
+
console.log(`${DIM}Get your API key from: ${provider.apiKeyUrl}${RESET}`);
|
|
353
|
+
console.log(`${CYAN}│${RESET}`);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Prompt for API key
|
|
357
|
+
const apiKey = await password({
|
|
358
|
+
message: `Enter your ${provider.name} API key`,
|
|
359
|
+
mask: '*',
|
|
360
|
+
theme: {
|
|
361
|
+
prefix: `${CYAN}◆${RESET}`,
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
if (!apiKey || apiKey.trim().length === 0) {
|
|
366
|
+
console.log(`${YELLOW}No API key entered.${RESET}`);
|
|
367
|
+
console.log(`${CYAN}└${RESET}`);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Validate API key format (basic check)
|
|
372
|
+
const trimmedKey = apiKey.trim();
|
|
373
|
+
if (trimmedKey.length < 10) {
|
|
374
|
+
console.log(`${RED}API key seems too short. Please check and try again.${RESET}`);
|
|
375
|
+
console.log(`${CYAN}└${RESET}`);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Save the API key
|
|
380
|
+
saveApiKey(provider.id, trimmedKey);
|
|
381
|
+
|
|
382
|
+
// Also set in current environment for immediate use
|
|
383
|
+
process.env[provider.envVar] = trimmedKey;
|
|
384
|
+
|
|
385
|
+
console.log(`${CYAN}│${RESET}`);
|
|
386
|
+
console.log(`${GREEN}✓ ${RESET}${BOLD}API key saved!${RESET}`);
|
|
387
|
+
console.log('');
|
|
388
|
+
console.log(`${DIM}Stored in: ${API_KEYS_FILE}${RESET}`);
|
|
389
|
+
console.log(`${DIM}Run 'jarvis' to start using ${provider.name}.${RESET}`);
|
|
390
|
+
console.log(`${CYAN}└${RESET}`);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Export API keys loader for use by providers
|
|
394
|
+
export { loadApiKeys };
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Utility functions for Jarvis
|
|
2
|
+
import { homedir } from 'os';
|
|
3
|
+
import { join, resolve, relative } from 'path';
|
|
4
|
+
import { existsSync, statSync } from 'fs';
|
|
5
|
+
|
|
6
|
+
// Get the home directory
|
|
7
|
+
export function getHomeDir(): string {
|
|
8
|
+
return homedir();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Expand ~ in paths
|
|
12
|
+
export function expandPath(path: string): string {
|
|
13
|
+
if (path.startsWith('~')) {
|
|
14
|
+
return join(homedir(), path.slice(1));
|
|
15
|
+
}
|
|
16
|
+
return resolve(path);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Check if a path is absolute
|
|
20
|
+
export function isAbsolutePath(path: string): boolean {
|
|
21
|
+
if (process.platform === 'win32') {
|
|
22
|
+
return /^[A-Za-z]:\\/.test(path) || path.startsWith('\\\\');
|
|
23
|
+
}
|
|
24
|
+
return path.startsWith('/');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Get relative path from cwd
|
|
28
|
+
export function getRelativePath(absolutePath: string, basePath?: string): string {
|
|
29
|
+
const base = basePath || process.cwd();
|
|
30
|
+
return relative(base, absolutePath);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Check if file exists
|
|
34
|
+
export function fileExists(path: string): boolean {
|
|
35
|
+
try {
|
|
36
|
+
return existsSync(path);
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Check if directory exists
|
|
43
|
+
export function directoryExists(path: string): boolean {
|
|
44
|
+
try {
|
|
45
|
+
return existsSync(path) && statSync(path).isDirectory();
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Truncate text to a maximum length
|
|
52
|
+
export function truncate(text: string, maxLength: number, suffix = '...'): string {
|
|
53
|
+
if (text.length <= maxLength) {
|
|
54
|
+
return text;
|
|
55
|
+
}
|
|
56
|
+
return text.substring(0, maxLength - suffix.length) + suffix;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Format bytes to human readable
|
|
60
|
+
export function formatBytes(bytes: number): string {
|
|
61
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
62
|
+
let unitIndex = 0;
|
|
63
|
+
let size = bytes;
|
|
64
|
+
|
|
65
|
+
while (size >= 1024 && unitIndex < units.length - 1) {
|
|
66
|
+
size /= 1024;
|
|
67
|
+
unitIndex++;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Format duration in milliseconds to human readable
|
|
74
|
+
export function formatDuration(ms: number): string {
|
|
75
|
+
if (ms < 1000) {
|
|
76
|
+
return `${ms}ms`;
|
|
77
|
+
}
|
|
78
|
+
if (ms < 60000) {
|
|
79
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
80
|
+
}
|
|
81
|
+
const minutes = Math.floor(ms / 60000);
|
|
82
|
+
const seconds = Math.floor((ms % 60000) / 1000);
|
|
83
|
+
return `${minutes}m ${seconds}s`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Generate a short ID
|
|
87
|
+
export function shortId(length = 8): string {
|
|
88
|
+
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
|
89
|
+
let result = '';
|
|
90
|
+
for (let i = 0; i < length; i++) {
|
|
91
|
+
result += chars[Math.floor(Math.random() * chars.length)];
|
|
92
|
+
}
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Debounce function
|
|
97
|
+
export function debounce<T extends (...args: unknown[]) => unknown>(
|
|
98
|
+
fn: T,
|
|
99
|
+
delay: number
|
|
100
|
+
): (...args: Parameters<T>) => void {
|
|
101
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
102
|
+
|
|
103
|
+
return (...args: Parameters<T>) => {
|
|
104
|
+
if (timeoutId) {
|
|
105
|
+
clearTimeout(timeoutId);
|
|
106
|
+
}
|
|
107
|
+
timeoutId = setTimeout(() => {
|
|
108
|
+
fn(...args);
|
|
109
|
+
}, delay);
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Throttle function
|
|
114
|
+
export function throttle<T extends (...args: unknown[]) => unknown>(
|
|
115
|
+
fn: T,
|
|
116
|
+
limit: number
|
|
117
|
+
): (...args: Parameters<T>) => void {
|
|
118
|
+
let lastCall = 0;
|
|
119
|
+
|
|
120
|
+
return (...args: Parameters<T>) => {
|
|
121
|
+
const now = Date.now();
|
|
122
|
+
if (now - lastCall >= limit) {
|
|
123
|
+
lastCall = now;
|
|
124
|
+
fn(...args);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Sleep/delay function
|
|
130
|
+
export function sleep(ms: number): Promise<void> {
|
|
131
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Parse command line style arguments from a string
|
|
135
|
+
export function parseArgs(input: string): string[] {
|
|
136
|
+
const args: string[] = [];
|
|
137
|
+
let current = '';
|
|
138
|
+
let inQuote: string | null = null;
|
|
139
|
+
let escape = false;
|
|
140
|
+
|
|
141
|
+
for (const char of input) {
|
|
142
|
+
if (escape) {
|
|
143
|
+
current += char;
|
|
144
|
+
escape = false;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (char === '\\') {
|
|
149
|
+
escape = true;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (char === '"' || char === "'") {
|
|
154
|
+
if (inQuote === char) {
|
|
155
|
+
inQuote = null;
|
|
156
|
+
} else if (!inQuote) {
|
|
157
|
+
inQuote = char;
|
|
158
|
+
} else {
|
|
159
|
+
current += char;
|
|
160
|
+
}
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (char === ' ' && !inQuote) {
|
|
165
|
+
if (current) {
|
|
166
|
+
args.push(current);
|
|
167
|
+
current = '';
|
|
168
|
+
}
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
current += char;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (current) {
|
|
176
|
+
args.push(current);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return args;
|
|
180
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native Folder Picker Utility
|
|
3
|
+
* Provides an interface to trigger system-level folder dialogs
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export async function pickDirectory(): Promise<{path: string | null, error?: string}> {
|
|
7
|
+
if (process.platform === 'win32') {
|
|
8
|
+
const psScript = `
|
|
9
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
10
|
+
$dialog = New-Object System.Windows.Forms.FolderBrowserDialog
|
|
11
|
+
$dialog.Description = "Select a Workspace Directory"
|
|
12
|
+
$dialog.ShowNewFolderButton = $true
|
|
13
|
+
$dialog.RootFolder = [System.Environment+SpecialFolder]::MyComputer
|
|
14
|
+
|
|
15
|
+
if ($dialog.ShowDialog() -eq 'OK') {
|
|
16
|
+
$path = $dialog.SelectedPath
|
|
17
|
+
if (Test-Path $path) {
|
|
18
|
+
Write-Output "SUCCESS:$path"
|
|
19
|
+
} else {
|
|
20
|
+
Write-Output "ERROR:Invalid path selected"
|
|
21
|
+
}
|
|
22
|
+
} else {
|
|
23
|
+
Write-Output "CANCELLED"
|
|
24
|
+
}
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
console.log('[NativePicker] Opening folder browser...');
|
|
29
|
+
|
|
30
|
+
const proc = Bun.spawn([
|
|
31
|
+
"powershell",
|
|
32
|
+
"-ExecutionPolicy", "Bypass", // CRITICAL FIX: Bypass execution policy
|
|
33
|
+
"-NoProfile",
|
|
34
|
+
"-NonInteractive",
|
|
35
|
+
"-Command",
|
|
36
|
+
psScript
|
|
37
|
+
], {
|
|
38
|
+
stdout: "pipe",
|
|
39
|
+
stderr: "pipe"
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// 60-second timeout
|
|
43
|
+
const timeoutPromise = new Promise<string>((_, reject) =>
|
|
44
|
+
setTimeout(() => reject(new Error('Folder picker timed out')), 60000)
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const outputPromise = new Response(proc.stdout).text();
|
|
48
|
+
const output = await Promise.race([outputPromise, timeoutPromise]);
|
|
49
|
+
|
|
50
|
+
const trimmed = output.trim();
|
|
51
|
+
console.log('[NativePicker] Result:', trimmed);
|
|
52
|
+
|
|
53
|
+
if (trimmed.startsWith('SUCCESS:')) {
|
|
54
|
+
return { path: trimmed.substring(8) };
|
|
55
|
+
} else if (trimmed === 'CANCELLED') {
|
|
56
|
+
return { path: null, error: 'User cancelled' };
|
|
57
|
+
} else if (trimmed.startsWith('ERROR:')) {
|
|
58
|
+
return { path: null, error: trimmed.substring(6) };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { path: null, error: 'Unknown error' };
|
|
62
|
+
|
|
63
|
+
} catch (e) {
|
|
64
|
+
console.error('[NativePicker] Failed:', e);
|
|
65
|
+
return { path: null, error: (e as Error).message };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// macOS/Linux support (future enhancement)
|
|
70
|
+
return { path: null, error: 'Folder picker not supported on this platform' };
|
|
71
|
+
}
|