@portel/photon-core 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.
package/src/elicit.ts ADDED
@@ -0,0 +1,438 @@
1
+ /**
2
+ * Elicit - Cross-platform user input for Photon
3
+ *
4
+ * Provides a unified API for requesting user input that works across:
5
+ * - Native OS dialogs (default - works standalone without photon runtime)
6
+ * - CLI readline (photon CLI overrides via globalThis.__photon_prompt__)
7
+ * - MCP elicitation (photon MCP overrides when client supports it)
8
+ *
9
+ * Design:
10
+ * - Photon files call the simple `prompt()` function (no imports needed)
11
+ * - Standalone: Uses native OS dialogs
12
+ * - Photon runtime: Overrides globalThis.__photon_prompt__ with appropriate handler
13
+ */
14
+
15
+ import { exec } from 'child_process';
16
+ import * as os from 'os';
17
+ import * as readline from 'readline';
18
+
19
+ export interface ElicitOptions {
20
+ /** The prompt/message to display to the user */
21
+ prompt: string;
22
+ /** Title for the dialog (used in GUI dialogs) */
23
+ title?: string;
24
+ /** Default value to pre-fill */
25
+ defaultValue?: string;
26
+ /** Type of input */
27
+ type?: 'text' | 'password' | 'confirm';
28
+ /** JSON schema for validation (used in MCP elicitation) */
29
+ schema?: object;
30
+ /** Timeout in milliseconds (0 = no timeout) */
31
+ timeout?: number;
32
+ }
33
+
34
+ export interface ElicitResult {
35
+ /** Whether the user provided input (vs cancelled) */
36
+ success: boolean;
37
+ /** The user's input value */
38
+ value?: string;
39
+ /** True if user confirmed (for confirm type) */
40
+ confirmed?: boolean;
41
+ /** Error message if failed */
42
+ error?: string;
43
+ }
44
+
45
+ /** Prompt handler type - simple string in, string out */
46
+ export type PromptHandler = (message: string, defaultValue?: string) => Promise<string | null>;
47
+
48
+ /** Full elicit handler type */
49
+ export type ElicitHandler = (options: ElicitOptions) => Promise<ElicitResult>;
50
+
51
+ // Declare global override point
52
+ declare global {
53
+ var __photon_prompt__: PromptHandler | undefined;
54
+ var __photon_elicit__: ElicitHandler | undefined;
55
+ }
56
+
57
+ /**
58
+ * Set the prompt handler (simple string-based)
59
+ * Runtimes use this to override with readline, MCP elicitation, etc.
60
+ */
61
+ export function setPromptHandler(handler: PromptHandler | null): void {
62
+ globalThis.__photon_prompt__ = handler || undefined;
63
+ }
64
+
65
+ /**
66
+ * Set the full elicit handler (with options)
67
+ */
68
+ export function setElicitHandler(handler: ElicitHandler | null): void {
69
+ globalThis.__photon_elicit__ = handler || undefined;
70
+ }
71
+
72
+ /**
73
+ * Simple prompt function - can be called directly from photon files
74
+ * No imports needed - uses global override or falls back to native dialog
75
+ *
76
+ * @example
77
+ * ```typescript
78
+ * // In a photon file - works standalone or with photon runtime
79
+ * const code = await prompt('Enter the 6-digit code:');
80
+ * if (code) {
81
+ * console.log('User entered:', code);
82
+ * }
83
+ * ```
84
+ */
85
+ export async function prompt(message: string, defaultValue?: string): Promise<string | null> {
86
+ // Check for runtime override
87
+ if (globalThis.__photon_prompt__) {
88
+ return globalThis.__photon_prompt__(message, defaultValue);
89
+ }
90
+
91
+ // Fall back to native dialog
92
+ const result = await elicitNativeDialog({
93
+ prompt: message,
94
+ defaultValue,
95
+ type: 'text',
96
+ });
97
+
98
+ return result.success ? (result.value ?? null) : null;
99
+ }
100
+
101
+ /**
102
+ * Confirm dialog - returns true/false
103
+ */
104
+ export async function confirm(message: string): Promise<boolean> {
105
+ if (globalThis.__photon_elicit__) {
106
+ const result = await globalThis.__photon_elicit__({
107
+ prompt: message,
108
+ type: 'confirm',
109
+ });
110
+ return result.confirmed ?? false;
111
+ }
112
+
113
+ const result = await elicitNativeDialog({
114
+ prompt: message,
115
+ type: 'confirm',
116
+ });
117
+
118
+ return result.confirmed ?? false;
119
+ }
120
+
121
+ /**
122
+ * Full elicit with options
123
+ */
124
+ export async function elicit(options: ElicitOptions): Promise<ElicitResult> {
125
+ // Use custom handler if set
126
+ if (globalThis.__photon_elicit__) {
127
+ return globalThis.__photon_elicit__(options);
128
+ }
129
+
130
+ // Check if we're in a TTY (interactive terminal)
131
+ if (process.stdin.isTTY && process.stdout.isTTY) {
132
+ return elicitReadline(options);
133
+ }
134
+
135
+ // Default to native OS dialog
136
+ return elicitNativeDialog(options);
137
+ }
138
+
139
+ /**
140
+ * Get the current prompt handler
141
+ */
142
+ export function getPromptHandler(): PromptHandler | undefined {
143
+ return globalThis.__photon_prompt__;
144
+ }
145
+
146
+ /**
147
+ * Get the current elicit handler
148
+ */
149
+ export function getElicitHandler(): ElicitHandler | undefined {
150
+ return globalThis.__photon_elicit__;
151
+ }
152
+
153
+ /**
154
+ * Elicit using readline (for CLI/terminal)
155
+ */
156
+ export async function elicitReadline(options: ElicitOptions): Promise<ElicitResult> {
157
+ return new Promise((resolve) => {
158
+ const rl = readline.createInterface({
159
+ input: process.stdin,
160
+ output: process.stdout,
161
+ });
162
+
163
+ const prompt = options.defaultValue
164
+ ? `${options.prompt} [${options.defaultValue}]: `
165
+ : `${options.prompt}: `;
166
+
167
+ if (options.type === 'confirm') {
168
+ rl.question(`${options.prompt} (y/n): `, (answer) => {
169
+ rl.close();
170
+ const confirmed = answer.toLowerCase().startsWith('y');
171
+ resolve({
172
+ success: true,
173
+ confirmed,
174
+ value: confirmed ? 'yes' : 'no',
175
+ });
176
+ });
177
+ } else {
178
+ rl.question(prompt, (answer) => {
179
+ rl.close();
180
+ const value = answer || options.defaultValue || '';
181
+ resolve({
182
+ success: true,
183
+ value,
184
+ });
185
+ });
186
+ }
187
+
188
+ // Handle timeout
189
+ if (options.timeout && options.timeout > 0) {
190
+ setTimeout(() => {
191
+ rl.close();
192
+ resolve({
193
+ success: false,
194
+ error: 'Input timeout',
195
+ });
196
+ }, options.timeout);
197
+ }
198
+ });
199
+ }
200
+
201
+ /**
202
+ * Elicit using native OS dialog
203
+ */
204
+ export async function elicitNativeDialog(options: ElicitOptions): Promise<ElicitResult> {
205
+ const platform = os.platform();
206
+
207
+ try {
208
+ switch (platform) {
209
+ case 'darwin':
210
+ return elicitMacOS(options);
211
+ case 'win32':
212
+ return elicitWindows(options);
213
+ case 'linux':
214
+ return elicitLinux(options);
215
+ default:
216
+ // Fallback to readline for unsupported platforms
217
+ return elicitReadline(options);
218
+ }
219
+ } catch (error: any) {
220
+ return {
221
+ success: false,
222
+ error: error.message,
223
+ };
224
+ }
225
+ }
226
+
227
+ /**
228
+ * macOS: Use osascript (AppleScript) for dialogs
229
+ */
230
+ function elicitMacOS(options: ElicitOptions): Promise<ElicitResult> {
231
+ return new Promise((resolve) => {
232
+ const title = options.title || 'Input Required';
233
+ const prompt = options.prompt;
234
+ const defaultValue = options.defaultValue || '';
235
+ const isPassword = options.type === 'password';
236
+ const isConfirm = options.type === 'confirm';
237
+
238
+ let script: string;
239
+
240
+ if (isConfirm) {
241
+ script = `
242
+ display dialog "${escapeAppleScript(prompt)}" ¬
243
+ with title "${escapeAppleScript(title)}" ¬
244
+ buttons {"Cancel", "No", "Yes"} ¬
245
+ default button "Yes"
246
+ set buttonPressed to button returned of result
247
+ return buttonPressed
248
+ `;
249
+ } else if (isPassword) {
250
+ script = `
251
+ display dialog "${escapeAppleScript(prompt)}" ¬
252
+ with title "${escapeAppleScript(title)}" ¬
253
+ default answer "${escapeAppleScript(defaultValue)}" ¬
254
+ with hidden answer ¬
255
+ buttons {"Cancel", "OK"} ¬
256
+ default button "OK"
257
+ return text returned of result
258
+ `;
259
+ } else {
260
+ script = `
261
+ display dialog "${escapeAppleScript(prompt)}" ¬
262
+ with title "${escapeAppleScript(title)}" ¬
263
+ default answer "${escapeAppleScript(defaultValue)}" ¬
264
+ buttons {"Cancel", "OK"} ¬
265
+ default button "OK"
266
+ return text returned of result
267
+ `;
268
+ }
269
+
270
+ exec(`osascript -e '${script.replace(/'/g, "'\"'\"'")}'`, (error, stdout, stderr) => {
271
+ if (error) {
272
+ // User cancelled
273
+ if (error.code === 1) {
274
+ resolve({
275
+ success: false,
276
+ error: 'User cancelled',
277
+ });
278
+ } else {
279
+ resolve({
280
+ success: false,
281
+ error: stderr || error.message,
282
+ });
283
+ }
284
+ return;
285
+ }
286
+
287
+ const value = stdout.trim();
288
+
289
+ if (isConfirm) {
290
+ resolve({
291
+ success: true,
292
+ confirmed: value === 'Yes',
293
+ value,
294
+ });
295
+ } else {
296
+ resolve({
297
+ success: true,
298
+ value,
299
+ });
300
+ }
301
+ });
302
+ });
303
+ }
304
+
305
+ /**
306
+ * Windows: Use PowerShell for dialogs
307
+ */
308
+ function elicitWindows(options: ElicitOptions): Promise<ElicitResult> {
309
+ return new Promise((resolve) => {
310
+ const title = options.title || 'Input Required';
311
+ const prompt = options.prompt;
312
+ const defaultValue = options.defaultValue || '';
313
+ const isConfirm = options.type === 'confirm';
314
+
315
+ let script: string;
316
+
317
+ if (isConfirm) {
318
+ script = `
319
+ Add-Type -AssemblyName System.Windows.Forms
320
+ $result = [System.Windows.Forms.MessageBox]::Show('${escapePowerShell(prompt)}', '${escapePowerShell(title)}', 'YesNoCancel', 'Question')
321
+ Write-Output $result
322
+ `;
323
+ } else {
324
+ script = `
325
+ Add-Type -AssemblyName Microsoft.VisualBasic
326
+ $result = [Microsoft.VisualBasic.Interaction]::InputBox('${escapePowerShell(prompt)}', '${escapePowerShell(title)}', '${escapePowerShell(defaultValue)}')
327
+ Write-Output $result
328
+ `;
329
+ }
330
+
331
+ exec(`powershell -Command "${script.replace(/"/g, '\\"')}"`, (error, stdout, stderr) => {
332
+ if (error) {
333
+ resolve({
334
+ success: false,
335
+ error: stderr || error.message,
336
+ });
337
+ return;
338
+ }
339
+
340
+ const value = stdout.trim();
341
+
342
+ if (isConfirm) {
343
+ if (value === 'Cancel') {
344
+ resolve({
345
+ success: false,
346
+ error: 'User cancelled',
347
+ });
348
+ } else {
349
+ resolve({
350
+ success: true,
351
+ confirmed: value === 'Yes',
352
+ value,
353
+ });
354
+ }
355
+ } else {
356
+ if (value === '') {
357
+ resolve({
358
+ success: false,
359
+ error: 'User cancelled',
360
+ });
361
+ } else {
362
+ resolve({
363
+ success: true,
364
+ value,
365
+ });
366
+ }
367
+ }
368
+ });
369
+ });
370
+ }
371
+
372
+ /**
373
+ * Linux: Use zenity or kdialog
374
+ */
375
+ function elicitLinux(options: ElicitOptions): Promise<ElicitResult> {
376
+ return new Promise((resolve) => {
377
+ const title = options.title || 'Input Required';
378
+ const prompt = options.prompt;
379
+ const defaultValue = options.defaultValue || '';
380
+ const isPassword = options.type === 'password';
381
+ const isConfirm = options.type === 'confirm';
382
+
383
+ // Try zenity first, then kdialog
384
+ let command: string;
385
+
386
+ if (isConfirm) {
387
+ command = `zenity --question --title="${escapeShell(title)}" --text="${escapeShell(prompt)}" 2>/dev/null || kdialog --yesno "${escapeShell(prompt)}" --title "${escapeShell(title)}" 2>/dev/null`;
388
+ } else if (isPassword) {
389
+ command = `zenity --password --title="${escapeShell(title)}" 2>/dev/null || kdialog --password "${escapeShell(prompt)}" --title "${escapeShell(title)}" 2>/dev/null`;
390
+ } else {
391
+ command = `zenity --entry --title="${escapeShell(title)}" --text="${escapeShell(prompt)}" --entry-text="${escapeShell(defaultValue)}" 2>/dev/null || kdialog --inputbox "${escapeShell(prompt)}" "${escapeShell(defaultValue)}" --title "${escapeShell(title)}" 2>/dev/null`;
392
+ }
393
+
394
+ exec(command, (error, stdout, stderr) => {
395
+ if (error) {
396
+ if (error.code === 1) {
397
+ resolve({
398
+ success: false,
399
+ error: 'User cancelled',
400
+ });
401
+ } else {
402
+ // Neither zenity nor kdialog available, fall back to readline
403
+ elicitReadline(options).then(resolve);
404
+ }
405
+ return;
406
+ }
407
+
408
+ const value = stdout.trim();
409
+
410
+ if (isConfirm) {
411
+ resolve({
412
+ success: true,
413
+ confirmed: true,
414
+ value: 'yes',
415
+ });
416
+ } else {
417
+ resolve({
418
+ success: true,
419
+ value,
420
+ });
421
+ }
422
+ });
423
+ });
424
+ }
425
+
426
+ // Helper functions for escaping strings
427
+
428
+ function escapeAppleScript(str: string): string {
429
+ return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
430
+ }
431
+
432
+ function escapePowerShell(str: string): string {
433
+ return str.replace(/'/g, "''");
434
+ }
435
+
436
+ function escapeShell(str: string): string {
437
+ return str.replace(/"/g, '\\"').replace(/\$/g, '\\$').replace(/`/g, '\\`');
438
+ }