@benzsiangco/jarvis 1.0.2 → 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.
Files changed (53) hide show
  1. package/dist/cli.js +478 -347
  2. package/dist/electron/main.js +160 -0
  3. package/dist/electron/preload.js +19 -0
  4. package/package.json +19 -6
  5. package/skills.md +147 -0
  6. package/src/agents/index.ts +248 -0
  7. package/src/brain/loader.ts +136 -0
  8. package/src/cli.ts +411 -0
  9. package/src/config/index.ts +363 -0
  10. package/src/core/executor.ts +222 -0
  11. package/src/core/plugins.ts +148 -0
  12. package/src/core/types.ts +217 -0
  13. package/src/electron/main.ts +192 -0
  14. package/src/electron/preload.ts +25 -0
  15. package/src/electron/types.d.ts +20 -0
  16. package/src/index.ts +12 -0
  17. package/src/providers/antigravity-loader.ts +233 -0
  18. package/src/providers/antigravity.ts +585 -0
  19. package/src/providers/index.ts +523 -0
  20. package/src/sessions/index.ts +194 -0
  21. package/src/tools/index.ts +436 -0
  22. package/src/tui/index.tsx +784 -0
  23. package/src/utils/auth-prompt.ts +394 -0
  24. package/src/utils/index.ts +180 -0
  25. package/src/utils/native-picker.ts +71 -0
  26. package/src/utils/skills.ts +99 -0
  27. package/src/utils/table-integration-examples.ts +617 -0
  28. package/src/utils/table-utils.ts +401 -0
  29. package/src/web/build-ui.ts +27 -0
  30. package/src/web/server.ts +674 -0
  31. package/src/web/ui/dist/.gitkeep +0 -0
  32. package/src/web/ui/dist/main.css +1 -0
  33. package/src/web/ui/dist/main.js +320 -0
  34. package/src/web/ui/dist/main.js.map +20 -0
  35. package/src/web/ui/index.html +46 -0
  36. package/src/web/ui/src/App.tsx +143 -0
  37. package/src/web/ui/src/Modules/Safety/GuardianModal.tsx +83 -0
  38. package/src/web/ui/src/components/Layout/ContextPanel.tsx +243 -0
  39. package/src/web/ui/src/components/Layout/Header.tsx +91 -0
  40. package/src/web/ui/src/components/Layout/ModelSelector.tsx +235 -0
  41. package/src/web/ui/src/components/Layout/SessionStats.tsx +369 -0
  42. package/src/web/ui/src/components/Layout/Sidebar.tsx +895 -0
  43. package/src/web/ui/src/components/Modules/Chat/ChatStage.tsx +620 -0
  44. package/src/web/ui/src/components/Modules/Chat/MessageItem.tsx +446 -0
  45. package/src/web/ui/src/components/Modules/Editor/CommandInspector.tsx +71 -0
  46. package/src/web/ui/src/components/Modules/Editor/DiffViewer.tsx +83 -0
  47. package/src/web/ui/src/components/Modules/Terminal/TabbedTerminal.tsx +202 -0
  48. package/src/web/ui/src/components/Settings/SettingsModal.tsx +935 -0
  49. package/src/web/ui/src/config/models.ts +70 -0
  50. package/src/web/ui/src/main.tsx +13 -0
  51. package/src/web/ui/src/store/agentStore.ts +41 -0
  52. package/src/web/ui/src/store/uiStore.ts +64 -0
  53. 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
+ }