rl-rockcli 0.0.9 → 0.0.11

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 (90) hide show
  1. package/commands/attach/basic-repl.js +212 -0
  2. package/commands/attach/cleanup-history.js +189 -0
  3. package/commands/attach/cleanup-manager.js +163 -0
  4. package/commands/attach/copy-ui/copyRepl.js +195 -0
  5. package/commands/attach/copy-ui/index.js +7 -0
  6. package/commands/attach/copy-ui/render/outputBlock.js +25 -0
  7. package/commands/attach/copy-ui/viewport/viewport.js +23 -0
  8. package/commands/attach/copy-ui/viewport/wheel.js +14 -0
  9. package/commands/attach/history-manager.js +507 -0
  10. package/commands/attach/history-session.js +48 -0
  11. package/commands/attach/ink-repl/InkREPL.js +1507 -0
  12. package/commands/attach/ink-repl/builtinCommands.js +1253 -0
  13. package/commands/attach/ink-repl/components/ConnectingScreen.js +76 -0
  14. package/commands/attach/ink-repl/components/Console.js +191 -0
  15. package/commands/attach/ink-repl/components/DetailView.js +148 -0
  16. package/commands/attach/ink-repl/components/DropdownMenu.js +86 -0
  17. package/commands/attach/ink-repl/components/InputArea.js +125 -0
  18. package/commands/attach/ink-repl/components/InputLine.js +18 -0
  19. package/commands/attach/ink-repl/components/OutputArea.js +22 -0
  20. package/commands/attach/ink-repl/components/OutputItem.js +96 -0
  21. package/commands/attach/ink-repl/components/ShellLayout.js +61 -0
  22. package/commands/attach/ink-repl/components/Spinner.js +79 -0
  23. package/commands/attach/ink-repl/components/StatusBar.js +106 -0
  24. package/commands/attach/ink-repl/components/WelcomeBanner.js +48 -0
  25. package/commands/attach/ink-repl/contexts/LayoutContext.js +12 -0
  26. package/commands/attach/ink-repl/contexts/ThemeContext.js +43 -0
  27. package/commands/attach/ink-repl/hooks/useFunctionKeys.js +70 -0
  28. package/commands/attach/ink-repl/hooks/useMouse.js +162 -0
  29. package/commands/attach/ink-repl/hooks/useResources.js +132 -0
  30. package/commands/attach/ink-repl/hooks/useSpinner.js +49 -0
  31. package/commands/attach/ink-repl/index.js +112 -0
  32. package/commands/attach/ink-repl/package.json +3 -0
  33. package/commands/attach/ink-repl/replState.js +947 -0
  34. package/commands/attach/ink-repl/shortcuts/defaultKeybindings.js +138 -0
  35. package/commands/attach/ink-repl/shortcuts/index.js +332 -0
  36. package/commands/attach/ink-repl/themes/defaultDark.js +18 -0
  37. package/commands/attach/ink-repl/themes/defaultLight.js +18 -0
  38. package/commands/attach/ink-repl/themes/index.js +4 -0
  39. package/commands/attach/ink-repl/themes/themeManager.js +45 -0
  40. package/commands/attach/ink-repl/themes/themeTokens.js +15 -0
  41. package/commands/attach/ink-repl/utils/atCompletion.js +346 -0
  42. package/commands/attach/ink-repl/utils/clipboard.js +50 -0
  43. package/commands/attach/ink-repl/utils/consoleLogger.js +81 -0
  44. package/commands/attach/ink-repl/utils/exitCodeHandler.js +49 -0
  45. package/commands/attach/ink-repl/utils/exitCodeTips.js +56 -0
  46. package/commands/attach/ink-repl/utils/formatTime.js +12 -0
  47. package/commands/attach/ink-repl/utils/outputSelection.js +120 -0
  48. package/commands/attach/ink-repl/utils/outputViewport.js +77 -0
  49. package/commands/attach/ink-repl/utils/paginatedFileLoading.js +76 -0
  50. package/commands/attach/ink-repl/utils/paramHint.js +60 -0
  51. package/commands/attach/ink-repl/utils/parseError.js +174 -0
  52. package/commands/attach/ink-repl/utils/pathCompletion.js +167 -0
  53. package/commands/attach/ink-repl/utils/remotePathSafety.js +56 -0
  54. package/commands/attach/ink-repl/utils/replSelection.js +205 -0
  55. package/commands/attach/ink-repl/utils/responseFormatter.js +127 -0
  56. package/commands/attach/ink-repl/utils/textWrap.js +117 -0
  57. package/commands/attach/ink-repl/utils/truncate.js +115 -0
  58. package/commands/attach/opentui-repl/App.tsx +891 -0
  59. package/commands/attach/opentui-repl/builtinCommands.ts +80 -0
  60. package/commands/attach/opentui-repl/components/ConfirmDialog.tsx +116 -0
  61. package/commands/attach/opentui-repl/components/ConnectingScreen.tsx +131 -0
  62. package/commands/attach/opentui-repl/components/Console.tsx +73 -0
  63. package/commands/attach/opentui-repl/components/DetailView.tsx +45 -0
  64. package/commands/attach/opentui-repl/components/DropdownMenu.tsx +130 -0
  65. package/commands/attach/opentui-repl/components/ExecutionStatus.tsx +66 -0
  66. package/commands/attach/opentui-repl/components/Header.tsx +24 -0
  67. package/commands/attach/opentui-repl/components/OutputArea.tsx +25 -0
  68. package/commands/attach/opentui-repl/components/OutputBlock.tsx +108 -0
  69. package/commands/attach/opentui-repl/components/PromptInput.tsx +109 -0
  70. package/commands/attach/opentui-repl/components/StatusBar.tsx +63 -0
  71. package/commands/attach/opentui-repl/components/Toast.tsx +65 -0
  72. package/commands/attach/opentui-repl/components/WelcomeBanner.tsx +41 -0
  73. package/commands/attach/opentui-repl/contexts/ReplContext.tsx +137 -0
  74. package/commands/attach/opentui-repl/contexts/SessionContext.tsx +32 -0
  75. package/commands/attach/opentui-repl/contexts/ThemeContext.tsx +70 -0
  76. package/commands/attach/opentui-repl/contexts/ToastContext.tsx +69 -0
  77. package/commands/attach/opentui-repl/contexts/toast-logic.js +71 -0
  78. package/commands/attach/opentui-repl/hooks/useResources.ts +102 -0
  79. package/commands/attach/opentui-repl/hooks/useSpinner.ts +46 -0
  80. package/commands/attach/opentui-repl/index.js +99 -0
  81. package/commands/attach/opentui-repl/keybindings.ts +39 -0
  82. package/commands/attach/opentui-repl/package.json +3 -0
  83. package/commands/attach/opentui-repl/render.tsx +72 -0
  84. package/commands/attach/opentui-repl/tsconfig.json +12 -0
  85. package/commands/attach/repl.js +791 -0
  86. package/commands/attach/sandbox-id-resolver.js +56 -0
  87. package/commands/attach/session-manager.js +307 -0
  88. package/commands/attach/ui-mode.js +146 -0
  89. package/commands/attach.js +186 -0
  90. package/package.json +1 -1
@@ -0,0 +1,1253 @@
1
+ import { createRequire } from 'module';
2
+ const require = createRequire(import.meta.url);
3
+
4
+ const logger = require('../../../utils/logger');
5
+ import { hasDangerousShellChars, shellEscapePosix } from './utils/remotePathSafety.js';
6
+ import { formatUploadResult } from './utils/responseFormatter.js';
7
+ import { friendlyErrorMessage } from './utils/parseError.js';
8
+
9
+ /**
10
+ * Command help descriptions
11
+ */
12
+ export const COMMAND_HELP = {
13
+ // Primary commands
14
+ status: 'Show sandbox status',
15
+ sessions: 'List attach sessions',
16
+ log: 'View sandbox logs. Usage: /log [-f file] [-k keyword] [-n lines]',
17
+ tail: 'View last lines of a file. Usage: /tail [-n lines] [-k keyword] [-f] <file>',
18
+ upload: 'Upload file/directory to sandbox. Usage: /upload @<local> @<remote>',
19
+ download: 'Download file from sandbox. Usage: /download @<remote> @<local>',
20
+ // Utility commands
21
+ stop: 'Stop the sandbox',
22
+ // resume: 'Resume a previous session. Usage: /resume [session-id]', // DISABLED
23
+ clear: 'Clear terminal screen',
24
+ stats: 'Show session statistics',
25
+ copy: 'Copy output of last command to clipboard',
26
+ about: 'Show about information',
27
+ retry: 'Retry last failed command',
28
+ theme: 'Switch UI theme. Usage: /theme <name> | /theme list',
29
+ 'cleanup-history': 'Clean up history sessions. Usage: /cleanup-history [--sandbox <id>] [--all]',
30
+ // Meta commands
31
+ help: 'Show help message',
32
+ docs: 'Open documentation',
33
+ bug: 'Report a bug',
34
+ close: 'Close the session permanently and exit',
35
+ exit: 'Exit the REPL (session kept alive for resume)',
36
+ };
37
+
38
+ /**
39
+ * Command parameter templates for inline hints
40
+ * Used by paramHint.js to show placeholder text when user is typing commands
41
+ */
42
+ export const COMMAND_PARAM_TEMPLATES = {
43
+ upload: {
44
+ template: '@<local> @<remote>',
45
+ params: [
46
+ { placeholder: '<local>', prefix: '@' },
47
+ { placeholder: '<remote>', prefix: '@' }
48
+ ]
49
+ },
50
+ download: {
51
+ template: '@<remote> @<local>',
52
+ params: [
53
+ { placeholder: '<remote>', prefix: '@' },
54
+ { placeholder: '<local>', prefix: '@' }
55
+ ]
56
+ }
57
+ };
58
+
59
+ function clampNumber(value, { min, max, fallback }) {
60
+ const num = Number(value);
61
+ if (!Number.isFinite(num)) return fallback;
62
+ return Math.max(min, Math.min(max, Math.floor(num)));
63
+ }
64
+
65
+ function isSafeLogFileName(name) {
66
+ if (!name) return false;
67
+ // Only allow basenames for Loghouse log file selection.
68
+ // For remote tail fallback, absolute paths are handled separately.
69
+ if (name.includes('/')) return false;
70
+ return /^[A-Za-z0-9._-]{1,128}$/.test(name);
71
+ }
72
+
73
+ function resolveRemoteLogPath(file) {
74
+ if (!file) return '/var/log/sandbox.log';
75
+ if (file.startsWith('/')) return file;
76
+ if (file === 'sandbox.log') return '/var/log/sandbox.log';
77
+ if (file === 'command.log') return '/data/logs/command.log';
78
+ return `/data/logs/${file}`;
79
+ }
80
+
81
+ /**
82
+ * Interactive commands that should be blocked
83
+ */
84
+ export const INTERACTIVE_COMMANDS = {
85
+ vim: 'Use: cat <file> to view, or echo "content" > file to write',
86
+ vi: 'Use: cat <file> to view, or echo "content" > file to write',
87
+ nano: 'Use: cat <file> to view, or echo "content" > file to write',
88
+ emacs: 'Use: cat <file> to view, or echo "content" > file to write',
89
+ less: 'Use: cat <file> or head/tail <file>',
90
+ more: 'Use: cat <file> or head/tail <file>',
91
+ top: 'Use: ps aux | head -20, or top -b -n 1',
92
+ htop: 'Use: ps aux | head -20, or top -b -n 1',
93
+ tail: 'Use: tail -n 50 <file> (without -f)',
94
+ python: 'Try: python -c "print(1+1)" or python script.py',
95
+ python3: 'Try: python3 -c "print(1+1)" or python3 script.py',
96
+ node: 'Use: node -e "code" or node script.js',
97
+ mysql: 'Use: mysql -e "query" dbname',
98
+ psql: 'Use: psql -c "query" dbname',
99
+ ssh: 'SSH not supported in attach mode',
100
+ man: 'Use: command --help',
101
+ watch: 'Use command directly or in a loop',
102
+ irb: 'Use: ruby -e "code"',
103
+ bash: 'Already in bash shell',
104
+ sh: 'Already in shell',
105
+ };
106
+
107
+ /**
108
+ * Upload a directory recursively to sandbox
109
+ * @param {Object} ctx - Execution context
110
+ * @param {string} localDir - Local directory path (resolved)
111
+ * @param {string} remoteDir - Remote directory path
112
+ * @param {string} displayPath - Original path for display
113
+ * @returns {Promise<{ output: string, exitCode: number }>}
114
+ */
115
+ async function uploadDirectory(ctx, localDir, remoteDir, displayPath) {
116
+ const fs = require('fs');
117
+ const path = require('path');
118
+
119
+ const uploadedFiles = [];
120
+ const errors = [];
121
+
122
+ // Ensure remote directory ends with /
123
+ const normalizedRemoteDir = remoteDir.endsWith('/') ? remoteDir : `${remoteDir}/`;
124
+
125
+ // Recursive function to collect all files
126
+ function collectFiles(dir, relativePath = '') {
127
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
128
+ const files = [];
129
+
130
+ for (const entry of entries) {
131
+ const localPath = path.join(dir, entry.name);
132
+ const relPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
133
+
134
+ if (entry.isDirectory()) {
135
+ files.push(...collectFiles(localPath, relPath));
136
+ } else if (entry.isFile()) {
137
+ files.push({
138
+ localPath,
139
+ relativePath: relPath,
140
+ remotePath: `${normalizedRemoteDir}${relPath}`,
141
+ });
142
+ }
143
+ }
144
+
145
+ return files;
146
+ }
147
+
148
+ try {
149
+ const files = collectFiles(localDir);
150
+
151
+ if (files.length === 0) {
152
+ return { output: `Directory is empty: ${displayPath}`, exitCode: 0 };
153
+ }
154
+
155
+ // Upload each file
156
+ for (const file of files) {
157
+ try {
158
+ const result = await ctx.client.uploadFile(file.localPath, file.remotePath);
159
+ if (result.success) {
160
+ uploadedFiles.push(file.relativePath);
161
+ } else {
162
+ errors.push(`${file.relativePath}: ${result.message}`);
163
+ }
164
+ } catch (err) {
165
+ const errMessage = err?.message || (typeof err === 'string' ? err : 'Unknown error');
166
+ errors.push(`${file.relativePath}: ${errMessage}`);
167
+ }
168
+ }
169
+
170
+ // Build result message
171
+ const lines = [];
172
+ if (uploadedFiles.length > 0) {
173
+ lines.push(`✓ Uploaded ${uploadedFiles.length} file(s): ${displayPath} → ${remoteDir}`);
174
+ if (uploadedFiles.length <= 10) {
175
+ for (const f of uploadedFiles) {
176
+ lines.push(` ✓ ${f}`);
177
+ }
178
+ } else {
179
+ for (const f of uploadedFiles.slice(0, 5)) {
180
+ lines.push(` ✓ ${f}`);
181
+ }
182
+ lines.push(` ... and ${uploadedFiles.length - 5} more`);
183
+ }
184
+ }
185
+
186
+ if (errors.length > 0) {
187
+ lines.push('');
188
+ lines.push(`✗ Failed to upload ${errors.length} file(s):`);
189
+ for (const e of errors.slice(0, 5)) {
190
+ lines.push(` ✗ ${e}`);
191
+ }
192
+ if (errors.length > 5) {
193
+ lines.push(` ... and ${errors.length - 5} more errors`);
194
+ }
195
+ }
196
+
197
+ return {
198
+ output: lines.join('\n'),
199
+ exitCode: errors.length > 0 ? 1 : 0,
200
+ };
201
+ } catch (error) {
202
+ return { output: `Failed to read directory: ${error.message}`, exitCode: 1 };
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Builtin command handlers
208
+ * Each handler receives (context, args) and returns { output, exitCode }
209
+ */
210
+ export const commandHandlers = {
211
+ /**
212
+ * /exit - Exit REPL
213
+ */
214
+ exit: async (ctx, args) => {
215
+ return { action: 'exit' };
216
+ },
217
+
218
+ /**
219
+ * /quit - Alias for exit
220
+ */
221
+ quit: async (ctx, args) => {
222
+ return { action: 'exit' };
223
+ },
224
+
225
+ /**
226
+ * /clear - Clear screen
227
+ */
228
+ clear: async (ctx, args) => {
229
+ return { action: 'clear' };
230
+ },
231
+
232
+ /**
233
+ * /help - Show help
234
+ */
235
+ help: async (ctx, args) => {
236
+ if (args.length > 0) {
237
+ const cmd = args[0].replace(/^\//, '');
238
+ if (COMMAND_HELP[cmd]) {
239
+ return { output: `/${cmd}: ${COMMAND_HELP[cmd]}`, exitCode: 0 };
240
+ }
241
+ return { output: `Unknown command: ${cmd}`, exitCode: 1 };
242
+ }
243
+
244
+ const lines = [
245
+ 'Available commands:',
246
+ '',
247
+ 'Builtin commands (start with /):',
248
+ ...Object.entries(COMMAND_HELP).map(([cmd, desc]) =>
249
+ ` /${cmd.padEnd(12)} ${desc}`
250
+ ),
251
+ '',
252
+ 'Shell commands:',
253
+ ' Any command not starting with / is executed as a shell command.',
254
+ ' Examples: ls -la, cd /app, python script.py',
255
+ '',
256
+ 'Tips:',
257
+ ' - Use Up/Down arrows to navigate history',
258
+ ' - Use Tab for auto-completion',
259
+ ' - Double Ctrl+C to exit',
260
+ ];
261
+
262
+ return { output: lines.join('\n'), exitCode: 0 };
263
+ },
264
+
265
+ /**
266
+ * /status - Show sandbox status
267
+ */
268
+ status: async (ctx, args) => {
269
+ try {
270
+ const status = await ctx.client.getStatus();
271
+
272
+ // Status indicator
273
+ const aliveIcon = status.isAlive ? '●' : '○';
274
+ const aliveText = status.isAlive ? 'Running' : 'Stopped';
275
+
276
+ // Stage status with icons
277
+ let stagesLine = '';
278
+ if (status.status) {
279
+ const stages = Object.entries(status.status);
280
+ const stageDisplay = stages.map(([stage, details]) => {
281
+ const s = details.status || 'unknown';
282
+ let icon = '?';
283
+ if (s === 'success') icon = '✓';
284
+ else if (s === 'failed' || s === 'error') icon = '✗';
285
+ else if (s === 'running' || s === 'pending') icon = '◐';
286
+ return `${icon} ${stage.replace(/_/g, ' ')}`;
287
+ });
288
+ stagesLine = stageDisplay.join(' ');
289
+ }
290
+
291
+ // Build compact status display
292
+ const lines = [
293
+ '',
294
+ ` ${aliveIcon} Sandbox ${aliveText}`,
295
+ '',
296
+ ` ID ${ctx.sandboxId}`,
297
+ ` Cluster ${status.cluster || 'N/A'}`,
298
+ ` Host ${status.hostName || 'N/A'}`,
299
+ ` IP ${status.hostIp || 'N/A'}`,
300
+ ];
301
+
302
+ if (stagesLine) {
303
+ lines.push(` Stages ${stagesLine}`);
304
+ }
305
+
306
+ lines.push('');
307
+
308
+ return { output: lines.join('\n'), exitCode: 0 };
309
+ } catch (error) {
310
+ // Error message is already user-friendly from getStatus()
311
+ return { output: error.message, exitCode: 1 };
312
+ }
313
+ },
314
+
315
+ /**
316
+ * /tail - View last lines of a file
317
+ */
318
+ tail: async (ctx, args) => {
319
+ let lines = 10;
320
+ let keyword = null;
321
+ let follow = false;
322
+ let filePath = null;
323
+
324
+ for (let i = 0; i < args.length; i++) {
325
+ if (args[i] === '-n' && args[i + 1]) {
326
+ lines = clampNumber(args[++i], { min: 1, max: 10000, fallback: 10 });
327
+ } else if (args[i] === '-k' && args[i + 1]) {
328
+ keyword = args[++i];
329
+ } else if (args[i] === '-f' || args[i] === '--follow') {
330
+ follow = true;
331
+ } else if (!args[i].startsWith('-')) {
332
+ filePath = args[i];
333
+ }
334
+ }
335
+
336
+ if (!filePath) {
337
+ return {
338
+ output: 'Usage: /tail [-n lines] [-k keyword] [-f] <file>\nExample: /tail /var/log/sandbox.log\n /tail -n 50 -k error /var/log/sandbox.log',
339
+ exitCode: 1,
340
+ };
341
+ }
342
+
343
+ if (hasDangerousShellChars(filePath)) {
344
+ return { output: 'Invalid file path: dangerous characters detected', exitCode: 1 };
345
+ }
346
+
347
+ if (follow) {
348
+ // Follow mode requires interactive terminal, not supported in tests
349
+ return {
350
+ output: 'Follow mode (-f) is not supported in this environment. Use tail without -f.',
351
+ exitCode: 1,
352
+ };
353
+ }
354
+
355
+ try {
356
+ let command = `tail -n ${lines} -- ${shellEscapePosix(filePath)}`;
357
+ if (keyword) {
358
+ command = `${command} | grep -i -- ${shellEscapePosix(keyword)}`;
359
+ }
360
+
361
+ const result = await ctx.sessionManager.execute(command);
362
+
363
+ if (result.exit_code !== 0) {
364
+ const errorMsg = result.stderr || result.output || '';
365
+ if (errorMsg.includes('No such file') || errorMsg.includes('cannot access')) {
366
+ return { output: 'Failed to read file: file not found', exitCode: 1 };
367
+ }
368
+ return { output: `Failed to read file: ${errorMsg}`, exitCode: 1 };
369
+ }
370
+
371
+ const output = result.output || '';
372
+ if (!output.trim()) {
373
+ return { output: '(empty file)', exitCode: 0 };
374
+ }
375
+
376
+ return { output, exitCode: 0 };
377
+ } catch (error) {
378
+ return { output: `Failed to read file: ${error.message}`, exitCode: 1 };
379
+ }
380
+ },
381
+
382
+ /**
383
+ * /log - View sandbox logs
384
+ */
385
+ log: async (ctx, args) => {
386
+ let keyword = null;
387
+ let lines = 100;
388
+ let logFile = 'command.log';
389
+
390
+ for (let i = 0; i < args.length; i++) {
391
+ if (args[i] === '-k' && args[i + 1]) {
392
+ keyword = args[++i];
393
+ } else if (args[i] === '-n' && args[i + 1]) {
394
+ lines = clampNumber(args[++i], { min: 1, max: 5000, fallback: 100 });
395
+ } else if ((args[i] === '-f' || args[i] === '--file') && args[i + 1]) {
396
+ logFile = args[++i];
397
+ }
398
+ }
399
+
400
+ // Reject obvious shell injection for user-provided values. This is not an auto-triggered path,
401
+ // but we still keep it safe since it runs in the sandbox shell.
402
+ if (hasDangerousShellChars(logFile) || (keyword && hasDangerousShellChars(keyword))) {
403
+ return { output: 'Invalid log parameters.', exitCode: 1 };
404
+ }
405
+
406
+ try {
407
+ // Try Loghouse first (only supports safe basenames for logFile)
408
+ const safeLoghouseFile = isSafeLogFileName(logFile) ? logFile : null;
409
+ const handleLogSearch =
410
+ typeof ctx.handleLogSearch === 'function'
411
+ ? ctx.handleLogSearch
412
+ : require('../../log/search').handleLogSearch;
413
+
414
+ const searchArgv = {
415
+ sandboxId: ctx.sandboxId,
416
+ logFile: safeLoghouseFile || 'command.log',
417
+ minutes: 60,
418
+ limit: lines,
419
+ keyword: keyword || undefined,
420
+ raw: false,
421
+ highlight: true,
422
+ highlightSandboxId: false,
423
+ truncate: 200,
424
+ };
425
+
426
+ // Capture console output
427
+ const output = [];
428
+ const originalLog = console.log;
429
+ const originalError = console.error;
430
+
431
+ console.log = (...args) => output.push(args.join(' '));
432
+ console.error = (...args) => output.push(args.join(' '));
433
+
434
+ try {
435
+ await handleLogSearch(searchArgv);
436
+ } finally {
437
+ console.log = originalLog;
438
+ console.error = originalError;
439
+ }
440
+
441
+ if (output.length > 0) {
442
+ return { output: output.join('\n'), exitCode: 0 };
443
+ }
444
+ return { output: 'No logs found.', exitCode: 0 };
445
+ } catch (error) {
446
+ // Fallback to local log
447
+ try {
448
+ const remotePath = resolveRemoteLogPath(logFile);
449
+ if (hasDangerousShellChars(remotePath)) {
450
+ return { output: 'Invalid log file path.', exitCode: 1 };
451
+ }
452
+
453
+ let command = `tail -n ${lines} -- ${shellEscapePosix(remotePath)} 2>/dev/null`;
454
+ if (keyword) {
455
+ command = `${command} | grep -i -- ${shellEscapePosix(keyword)}`;
456
+ }
457
+ command = `${command} || echo ${shellEscapePosix(keyword ? 'No matching logs' : 'No logs available')}`;
458
+
459
+ const result = await ctx.sessionManager.execute(command);
460
+ return { output: result.output || 'No logs available', exitCode: 0 };
461
+ } catch (e) {
462
+ return { output: `Failed to get logs: ${e.message}`, exitCode: 1 };
463
+ }
464
+ }
465
+ },
466
+
467
+ /**
468
+ * /upload - Upload file or directory to sandbox
469
+ * Supports @ prefix for paths (e.g., /upload @local.txt @/remote.txt)
470
+ */
471
+ upload: async (ctx, args) => {
472
+ if (args.length < 2) {
473
+ return {
474
+ output: 'Usage: /upload @<local-path> @<remote-path>\nExample: /upload @./script.py @/app/script.py\n /upload @./src/ @/app/src/',
475
+ exitCode: 1,
476
+ };
477
+ }
478
+
479
+ const fs = require('fs');
480
+ const path = require('path');
481
+
482
+ // Strip @ prefix from paths
483
+ const localPath = args[0].startsWith('@') ? args[0].slice(1) : args[0];
484
+ let remotePath = args[1].startsWith('@') ? args[1].slice(1) : args[1];
485
+
486
+ try {
487
+ const resolvedPath = path.resolve(localPath);
488
+ if (!fs.existsSync(resolvedPath)) {
489
+ return { output: `File not found: ${localPath}`, exitCode: 1 };
490
+ }
491
+
492
+ const stats = fs.statSync(resolvedPath);
493
+
494
+ if (stats.isDirectory()) {
495
+ // Upload directory recursively
496
+ const result = await uploadDirectory(ctx, resolvedPath, remotePath, localPath);
497
+ return result;
498
+ } else {
499
+ // If remote path ends with /, append the local filename
500
+ if (remotePath.endsWith('/')) {
501
+ remotePath = remotePath + path.basename(resolvedPath);
502
+ }
503
+ // Upload single file
504
+ const result = await ctx.client.uploadFile(resolvedPath, remotePath);
505
+ return formatUploadResult(result, { localPath, remotePath });
506
+ }
507
+ } catch (error) {
508
+ const errorMessage = error?.message || (typeof error === 'string' ? error : 'Unknown error');
509
+ return { output: `Upload failed: ${errorMessage}`, exitCode: 1 };
510
+ }
511
+ },
512
+
513
+ /**
514
+ * /download - Download file from sandbox
515
+ */
516
+ download: async (ctx, args) => {
517
+ if (args.length < 2) {
518
+ return {
519
+ output: 'Usage: /download @<remote-path> @<local-path>\nExample: /download @/app/output.txt @./output.txt',
520
+ exitCode: 1,
521
+ };
522
+ }
523
+
524
+ // Remove @ prefix from paths if present
525
+ let remotePath = args[0];
526
+ let localPath = args[1];
527
+
528
+ if (remotePath.startsWith('@')) {
529
+ remotePath = remotePath.substring(1);
530
+ }
531
+ if (localPath.startsWith('@')) {
532
+ localPath = localPath.substring(1);
533
+ }
534
+
535
+ // Check for dangerous characters in remote path
536
+ if (hasDangerousShellChars(remotePath)) {
537
+ return { output: '✗ Download failed: Invalid characters in remote path', exitCode: 1 };
538
+ }
539
+
540
+ const fs = require('fs');
541
+ const path = require('path');
542
+
543
+ try {
544
+ // If localPath is a directory, append the remote filename
545
+ if (localPath.endsWith('/') || localPath === '.') {
546
+ const remoteFileName = path.basename(remotePath);
547
+ localPath = path.join(localPath, remoteFileName);
548
+ }
549
+
550
+ // Use cat command to read the entire file content (with escaped path)
551
+ const result = await ctx.sessionManager.execute(`cat -- ${shellEscapePosix(remotePath)}`);
552
+
553
+ // Check exit_code (note: underscore format)
554
+ if (result.exit_code !== 0) {
555
+ const errorMsg = result.stderr || result.output || 'Failed to read file';
556
+ return { output: `✗ Download failed\n\nerror: ${errorMsg}`, exitCode: 1 };
557
+ }
558
+
559
+ const resolvedPath = path.resolve(localPath);
560
+ fs.writeFileSync(resolvedPath, result.output);
561
+
562
+ return { output: `✓ ${remotePath} → ${resolvedPath}`, exitCode: 0 };
563
+ } catch (error) {
564
+ return { output: `✗ Download failed\n\nerror: ${error.message}`, exitCode: 1 };
565
+ }
566
+ },
567
+
568
+ /**
569
+ * /stop - Stop the sandbox
570
+ */
571
+ stop: async (ctx, args) => {
572
+ try {
573
+ await ctx.client.stop();
574
+ return { output: 'Sandbox stopped.', exitCode: 0, action: 'exit' };
575
+ } catch (error) {
576
+ return { output: `Failed to stop sandbox: ${error.message}`, exitCode: 1 };
577
+ }
578
+ },
579
+
580
+ /**
581
+ * /sessions - List attach sessions
582
+ */
583
+ sessions: async (ctx, args) => {
584
+ if (!ctx.historyManager || typeof ctx.historyManager.listSessions !== 'function') {
585
+ return { output: 'Session history not available.', exitCode: 0 };
586
+ }
587
+
588
+ try {
589
+ const sessions = await ctx.historyManager.listSessions();
590
+
591
+ if (!sessions || !Array.isArray(sessions) || sessions.length === 0) {
592
+ return { output: 'No previous sessions found.', exitCode: 0 };
593
+ }
594
+
595
+ // Get terminal width from context (REPL environment) or fallback to process.stdout
596
+ const terminalWidth = ctx.terminalWidth || process.stdout.columns || 120;
597
+
598
+ // opentui OutputBlock: marginLeft/Right=2 each (4 chars) + border=2 + paddingLeft/Right=1 each (2 chars) = 8 chars total
599
+ // ink OutputItem: border=2 + paddingLeft/Right=1 each (2 chars) = 4 chars total
600
+ // Use 8 chars to be safe for both modes (extra padding won't hurt)
601
+ const outputBoxPadding = 8;
602
+ const availableWidth = terminalWidth - outputBoxPadding;
603
+
604
+ // Separator and content use the same available width
605
+ // All lines have ' ' prefix (2 chars)
606
+ const separatorWidth = availableWidth - 2;
607
+
608
+ // Data rows format: ' ' + mark(' ★' or ' ') + ' ' + columns
609
+ // Total prefix width: 2 + 2 + 1 = 5 chars
610
+ const dataRowPrefixWidth = 5;
611
+
612
+ // Available width for content columns
613
+ const contentWidth = availableWidth - dataRowPrefixWidth;
614
+
615
+ // Format date/time
616
+ const formatTime = (ts) => {
617
+ if (!ts) return ' -- ';
618
+ const d = new Date(ts);
619
+ return d.toLocaleString('zh-CN', {
620
+ month: '2-digit',
621
+ day: '2-digit',
622
+ hour: '2-digit',
623
+ minute: '2-digit',
624
+ });
625
+ };
626
+
627
+ // Calculate dynamic column widths
628
+ const timeWidth = 13; // "02/06 15:37" format
629
+ const spacing = 4; // 4 spaces between each column
630
+
631
+ // Minimum widths for column headers
632
+ const minSessionIdWidth = 'SESSION'.length; // 7
633
+ const minPathWidth = 'PATH'.length; // 4
634
+
635
+ // Fixed width: time*2 + spacing*3 (between sessionId, created, updated, path)
636
+ const fixedWidth = timeWidth + timeWidth + spacing * 3;
637
+ const flexibleWidth = contentWidth - fixedWidth;
638
+
639
+ // Split flexible width between session ID and path (70% session, 30% path)
640
+ // But ensure minimum widths for headers
641
+ const sessionIdWidth = Math.max(minSessionIdWidth, Math.floor(flexibleWidth * 0.7));
642
+ const pathWidth = Math.max(minPathWidth, flexibleWidth - sessionIdWidth);
643
+
644
+ const formatCwd = (cwd) => {
645
+ const safe = String(cwd || '/');
646
+ if (safe.length <= pathWidth) return safe.padEnd(pathWidth);
647
+ return `…${safe.slice(-(pathWidth - 1))}`;
648
+ };
649
+
650
+ // Get current session ID
651
+ const currentHistorySessionId = ctx.historyManager.sessionId || '';
652
+ const currentShellSessionName = ctx.sessionManager?.sessionId || '';
653
+
654
+ const separator = '─'.repeat(separatorWidth);
655
+ const lines = [
656
+ '',
657
+ ' Recent Sessions',
658
+ ' ' + separator,
659
+ // Header: 5 chars prefix + columns (PATH is padded to pathWidth)
660
+ ' ' + `${'SESSION'.padEnd(sessionIdWidth)} ${'CREATED'.padEnd(timeWidth)} ${'UPDATED'.padEnd(timeWidth)} ${'PATH'.padEnd(pathWidth)}`,
661
+ ' ' + separator,
662
+ ];
663
+
664
+ for (const session of sessions.slice(0, 10)) {
665
+ const historySessionId = session.session_id || session.id || '-';
666
+ const shellSessionName = session.shell_session_name || '-';
667
+ const isCurrent = historySessionId === currentHistorySessionId ||
668
+ (currentShellSessionName && shellSessionName === currentShellSessionName);
669
+ const created = formatTime(session.created_at || session.start_time);
670
+ const updated = formatTime(session.updated_at || session.ended_at || session.created_at || session.start_time);
671
+ const cwd = formatCwd(session.work_dir || session.cwd || '/');
672
+
673
+ const sessionIdForAttach = shellSessionName && shellSessionName !== '-' ? shellSessionName : '-';
674
+ const currentMark = isCurrent ? ' ★' : ' ';
675
+
676
+ // Format: ' ' + mark + ' ' + content
677
+ const sessionIdPadded = sessionIdForAttach.padEnd(sessionIdWidth);
678
+ lines.push(` ${currentMark} ${sessionIdPadded} ${created} ${updated} ${cwd}`);
679
+ }
680
+
681
+ lines.push(' ' + separator);
682
+ lines.push('');
683
+
684
+ // Debug: Add width info (remove this after debugging)
685
+ if (process.env.DEBUG_SESSIONS_WIDTH) {
686
+ lines.push(` [Debug] Terminal width: ${terminalWidth}`);
687
+ lines.push(` [Debug] Separator line length: ${(' ' + separator).length}`);
688
+ lines.push(` [Debug] Expected: ${terminalWidth}, Actual: ${(' ' + separator).length}`);
689
+ }
690
+
691
+ return { output: lines.join('\n'), exitCode: 0 };
692
+ } catch (error) {
693
+ return { output: `Failed to list sessions: ${error.message}`, exitCode: 1 };
694
+ }
695
+ },
696
+
697
+ /**
698
+ * /cleanup-history - Clean up history sessions (keep current)
699
+ * Usage: /cleanup-history [--sandbox <id>] [--all]
700
+ */
701
+ 'cleanup-history': async (ctx, args) => {
702
+ if (!ctx.historyManager) {
703
+ return { output: 'Session history not available.', exitCode: 1 };
704
+ }
705
+
706
+ const fs = require('fs');
707
+ const path = require('path');
708
+ const os = require('os');
709
+ const readline = require('readline');
710
+
711
+ let targetSandboxId = null;
712
+ let cleanupAll = false;
713
+ let skipConfirm = false;
714
+
715
+ for (let i = 0; i < args.length; i++) {
716
+ if (args[i] === '--sandbox' && args[i + 1]) {
717
+ targetSandboxId = args[++i];
718
+ } else if (args[i] === '--all') {
719
+ cleanupAll = true;
720
+ } else if (args[i] === '--confirm') {
721
+ skipConfirm = true;
722
+ }
723
+ }
724
+
725
+ try {
726
+ // Get sessions list
727
+ if (typeof ctx.historyManager.listSessions !== 'function') {
728
+ return { output: 'Session listing not available.', exitCode: 1 };
729
+ }
730
+
731
+ const allSessions = await ctx.historyManager.listSessions();
732
+ if (!allSessions || !Array.isArray(allSessions)) {
733
+ return { output: 'No sessions found.', exitCode: 0 };
734
+ }
735
+
736
+ // Get current session IDs
737
+ const currentHistorySessionId = ctx.historyManager.sessionId || '';
738
+ const currentShellSessionName = ctx.sessionManager?.sessionId || '';
739
+
740
+ // Filter sessions to clean (exclude current unless --all)
741
+ let sessionsToClean;
742
+ if (cleanupAll) {
743
+ sessionsToClean = allSessions;
744
+ } else {
745
+ sessionsToClean = allSessions.filter(session => {
746
+ const historyId = session.session_id || session.id || '';
747
+ const shellName = session.shell_session_name || '';
748
+ return historyId !== currentHistorySessionId && shellName !== currentShellSessionName;
749
+ });
750
+ }
751
+
752
+ if (sessionsToClean.length === 0) {
753
+ return { output: 'No sessions to clean up.', exitCode: 0 };
754
+ }
755
+
756
+ // Format session items for display (max 10)
757
+ const maxDisplay = 10;
758
+ const displaySessions = sessionsToClean.slice(0, maxDisplay);
759
+ const moreCount = sessionsToClean.length > maxDisplay ? sessionsToClean.length - maxDisplay : 0;
760
+
761
+ const formatTime = (ts) => {
762
+ if (!ts) return ' -- ';
763
+ const d = new Date(ts);
764
+ return d.toLocaleString('zh-CN', {
765
+ month: '2-digit',
766
+ day: '2-digit',
767
+ hour: '2-digit',
768
+ minute: '2-digit',
769
+ });
770
+ };
771
+
772
+ // Build output with session list
773
+ const lines = [
774
+ '',
775
+ `Found ${sessionsToClean.length} session${sessionsToClean.length !== 1 ? 's' : ''} to clean up:`,
776
+ '',
777
+ ' SESSION CREATED UPDATED PATH',
778
+ ' ' + '─'.repeat(50),
779
+ ];
780
+
781
+ displaySessions.forEach(session => {
782
+ const sessionId = (session.session_id || session.id || '').slice(0, 8);
783
+ const created = formatTime(session.created_at || session.start_time);
784
+ const updated = formatTime(session.updated_at || session.ended_at);
785
+ const cwd = session.work_dir || session.cwd || '/';
786
+ lines.push(` ${sessionId.padEnd(10)} ${created} ${updated} ${cwd}`);
787
+ });
788
+
789
+ if (moreCount > 0) {
790
+ lines.push(` ... and ${moreCount} more session${moreCount !== 1 ? 's' : ''}`);
791
+ }
792
+
793
+ lines.push('');
794
+
795
+ // Get user confirmation if not skipped
796
+ if (!skipConfirm) {
797
+ const rl = readline.createInterface({
798
+ input: process.stdin,
799
+ output: process.stdout
800
+ });
801
+
802
+ const answer = await new Promise((resolve) => {
803
+ rl.question(`Clean up ${sessionsToClean.length} session${sessionsToClean.length !== 1 ? 's' : ''}? (y/N): `, (input) => {
804
+ rl.close();
805
+ resolve(input.trim().toLowerCase());
806
+ });
807
+ });
808
+
809
+ if (answer !== 'y' && answer !== 'yes') {
810
+ return { output: 'Cleanup cancelled.', exitCode: 0 };
811
+ }
812
+ }
813
+
814
+ // Get sandbox directory
815
+ const sandboxId = targetSandboxId || ctx.historyManager.sandboxId;
816
+ const sandboxDir = targetSandboxId
817
+ ? path.join(os.homedir(), '.rock', 'history', targetSandboxId)
818
+ : ctx.historyManager._getSandboxDir();
819
+ const indexFile = path.join(sandboxDir, 'index.json');
820
+
821
+ // Read index
822
+ let index = { current_session: null, sessions: [] };
823
+ if (fs.existsSync(indexFile)) {
824
+ try {
825
+ index = JSON.parse(fs.readFileSync(indexFile, 'utf8'));
826
+ } catch (e) {
827
+ // Ignore parse errors
828
+ }
829
+ }
830
+
831
+ const currentSessionId = index.current_session || ctx.historyManager.sessionId;
832
+ const sessions = index.sessions || [];
833
+
834
+ // Determine which sessions to clean
835
+ let sessionsToCleanIds;
836
+ if (cleanupAll) {
837
+ sessionsToCleanIds = sessions.map(s => s.id);
838
+ } else {
839
+ sessionsToCleanIds = sessions.filter(s => s.id !== currentSessionId).map(s => s.id);
840
+ }
841
+
842
+ // Delete sessions
843
+ let success = 0;
844
+ let failed = 0;
845
+ for (const sessionId of sessionsToCleanIds) {
846
+ const sessionDir = path.join(sandboxDir, sessionId);
847
+ try {
848
+ if (fs.existsSync(sessionDir)) {
849
+ fs.rmSync(sessionDir, { recursive: true, force: true });
850
+ success++;
851
+ }
852
+ } catch (e) {
853
+ failed++;
854
+ }
855
+ }
856
+
857
+ // Update index
858
+ const newSessions = cleanupAll
859
+ ? []
860
+ : sessions.filter(s => s.id === currentSessionId);
861
+ fs.writeFileSync(indexFile, JSON.stringify({
862
+ sandbox_id: sandboxId,
863
+ current_session: cleanupAll ? null : currentSessionId,
864
+ sessions: newSessions
865
+ }, null, 2));
866
+
867
+ // Build output message
868
+ lines.push(`Cleaned up ${success} session${success !== 1 ? 's' : ''}${failed > 0 ? ` (${failed} failed)` : ''}`);
869
+ lines.push('');
870
+
871
+ return { output: lines.join('\n'), exitCode: 0 };
872
+ } catch (error) {
873
+ return { output: `Failed to cleanup history: ${error.message}`, exitCode: 1 };
874
+ }
875
+ },
876
+
877
+ /**
878
+ * /resume - Resume a previous session
879
+ * DISABLED: Use /sessions to view session history
880
+ */
881
+ /*
882
+ resume: async (ctx, args) => {
883
+ if (!ctx.historyManager) {
884
+ return { output: 'Session history not available.', exitCode: 0 };
885
+ }
886
+
887
+ try {
888
+ let sessionId = args[0];
889
+
890
+ if (!sessionId) {
891
+ // Get last session
892
+ if (typeof ctx.historyManager.listSessions !== 'function') {
893
+ return { output: 'Session listing not available.', exitCode: 0 };
894
+ }
895
+ const sessions = await ctx.historyManager.listSessions();
896
+ if (!sessions || !Array.isArray(sessions) || sessions.length < 2) {
897
+ return { output: 'No previous session to resume.', exitCode: 0 };
898
+ }
899
+ sessionId = sessions[1].session_id || sessions[1].id;
900
+ }
901
+
902
+ if (typeof ctx.historyManager.getSessionHistory !== 'function') {
903
+ return { output: 'Session history retrieval not available.', exitCode: 0 };
904
+ }
905
+ const history = await ctx.historyManager.getSessionHistory(sessionId);
906
+ if (!history || history.length === 0) {
907
+ return { output: `No commands found in session ${sessionId}`, exitCode: 0 };
908
+ }
909
+
910
+ const lines = [`Session ${sessionId.slice(0, 8)} history:`, ''];
911
+ for (const entry of history) {
912
+ lines.push(` ${entry.command}`);
913
+ }
914
+
915
+ return { output: lines.join('\n'), exitCode: 0 };
916
+ } catch (error) {
917
+ return { output: `Failed to resume session: ${error.message}`, exitCode: 1 };
918
+ }
919
+ },
920
+ */
921
+
922
+ /**
923
+ * /stats - Show session statistics
924
+ */
925
+ stats: async (ctx, args) => {
926
+ const stats = ctx.stats || {};
927
+ const uptime = stats.startTime
928
+ ? Math.round((Date.now() - stats.startTime) / 1000)
929
+ : 0;
930
+
931
+ const lines = [
932
+ 'Session Statistics:',
933
+ ` Uptime: ${uptime}s`,
934
+ ` Shell commands: ${stats.shellCommands || 0}`,
935
+ ` Builtin commands: ${stats.builtinCommands || 0}`,
936
+ ];
937
+
938
+ return { output: lines.join('\n'), exitCode: 0 };
939
+ },
940
+
941
+ theme: async (ctx, args) => {
942
+ const themeManager = ctx.themeManager;
943
+ const setTheme = ctx.setTheme;
944
+ if (!themeManager || !setTheme) {
945
+ return { output: 'Theme switching not available in this mode.', exitCode: 1 };
946
+ }
947
+
948
+ if (!args || args.length === 0 || args[0] === 'list') {
949
+ const current = ctx.themeName;
950
+ const available = themeManager.listThemes().map(t => ` - ${t.name}${t.name === current ? ' (current)' : ''}`);
951
+ return {
952
+ output: ['Available themes:', ...available, '', 'Usage: /theme <name>'].join('\n'),
953
+ exitCode: 0,
954
+ };
955
+ }
956
+
957
+ const target = args[0];
958
+ if (setTheme(target)) {
959
+ if (ctx.setUIConfig) {
960
+ await ctx.setUIConfig('theme', target);
961
+ }
962
+ return { output: `Theme switched to ${target}`, exitCode: 0 };
963
+ }
964
+
965
+ return { output: `Unknown theme: ${target}`, exitCode: 1 };
966
+ },
967
+
968
+ /**
969
+ * /copy - Copy last output to clipboard
970
+ */
971
+ copy: async (ctx, args) => {
972
+ if (!ctx.lastOutput) {
973
+ return { output: 'No output to copy.', exitCode: 0 };
974
+ }
975
+
976
+ try {
977
+ const { exec } = require('child_process');
978
+ const { promisify } = require('util');
979
+ const execAsync = promisify(exec);
980
+
981
+ // Detect OS and use appropriate clipboard command
982
+ const platform = process.platform;
983
+ let cmd;
984
+
985
+ if (platform === 'darwin') {
986
+ cmd = 'pbcopy';
987
+ } else if (platform === 'linux') {
988
+ cmd = 'xclip -selection clipboard';
989
+ } else {
990
+ return { output: 'Clipboard not supported on this platform.', exitCode: 1 };
991
+ }
992
+
993
+ await execAsync(cmd, { input: ctx.lastOutput });
994
+ return { output: 'Output copied to clipboard.', exitCode: 0 };
995
+ } catch (error) {
996
+ return { output: `Failed to copy: ${error.message}`, exitCode: 1 };
997
+ }
998
+ },
999
+
1000
+ /**
1001
+ * /about - Show about information
1002
+ */
1003
+ about: async (ctx, args) => {
1004
+ const version = ctx.version || 'unknown';
1005
+
1006
+ // OSC 8 终端超链接格式: \e]8;;URL\a文本\e]8;;\a
1007
+ // \e = ESC (\u001B), \a = BEL (\u0007)
1008
+ const createHyperlink = (text, url) => {
1009
+ return `\u001B]8;;${url}\u0007${text}\u001B]8;;\u0007`;
1010
+ };
1011
+
1012
+ const docLink = createHyperlink('查看文档', 'https://rock-cli.io.example.com/docs/rock_cli_introduction');
1013
+
1014
+ // 格式化信息
1015
+ const formatRow = (label, value) => {
1016
+ return ` ${label.padEnd(6)} ${value}`;
1017
+ };
1018
+
1019
+ const lines = [
1020
+ '',
1021
+ ' 关于 ROCK CLI',
1022
+ '',
1023
+ formatRow('版本', version === 'unknown' ? '未知' : version),
1024
+ formatRow('沙箱', ctx.sandboxId || '未知'),
1025
+ formatRow('目录', process.cwd()),
1026
+ '',
1027
+ ` ${docLink}`,
1028
+ '',
1029
+ ];
1030
+
1031
+ return { output: lines.join('\n'), exitCode: 0 };
1032
+ },
1033
+
1034
+ /**
1035
+ * /docs - Open documentation
1036
+ */
1037
+ docs: async (ctx, args) => {
1038
+ const url = 'https://rock-cli.io.example.com/docs/rock_cli_introduction';
1039
+ const { exec } = require('child_process');
1040
+
1041
+ try {
1042
+ const platform = process.platform;
1043
+ if (platform === 'darwin') {
1044
+ exec(`open ${url}`);
1045
+ } else if (platform === 'linux') {
1046
+ exec(`xdg-open ${url}`);
1047
+ }
1048
+ return { output: `Opening ${url}...`, exitCode: 0 };
1049
+ } catch (error) {
1050
+ return { output: `Visit: ${url}`, exitCode: 0 };
1051
+ }
1052
+ },
1053
+
1054
+ /**
1055
+ * /bug - Report a bug
1056
+ */
1057
+ bug: async (ctx, args) => {
1058
+ const url = 'https://project.aone.example.com/v2/project/2125004/req#viewIdentifier=d7f112f9d023e2108fa1b0d8';
1059
+ const { exec } = require('child_process');
1060
+
1061
+ try {
1062
+ const platform = process.platform;
1063
+ if (platform === 'darwin') {
1064
+ exec(`open ${url}`);
1065
+ } else if (platform === 'linux') {
1066
+ exec(`xdg-open ${url}`);
1067
+ }
1068
+ return { output: `Opening ${url}...`, exitCode: 0 };
1069
+ } catch (error) {
1070
+ return { output: `Visit: ${url}`, exitCode: 0 };
1071
+ }
1072
+ },
1073
+
1074
+ /**
1075
+ * /retry - Retry last failed command
1076
+ */
1077
+ retry: async (ctx, args) => {
1078
+ if (!ctx.lastCommand) {
1079
+ return { output: 'No command to retry.', exitCode: 0 };
1080
+ }
1081
+
1082
+ return { action: 'retry', command: ctx.lastCommand };
1083
+ },
1084
+
1085
+ /**
1086
+ * /close - Close session permanently and exit
1087
+ */
1088
+ close: async (ctx, args) => {
1089
+ try {
1090
+ const sessionId = ctx.sessionManager?.sessionId;
1091
+ if (sessionId && ctx.client?.closeSession) {
1092
+ await ctx.client.closeSession(sessionId);
1093
+ return { output: 'Session closed.', exitCode: 0, action: 'exit' };
1094
+ }
1095
+ return { output: 'No active session to close.', exitCode: 0, action: 'exit' };
1096
+ } catch (error) {
1097
+ logger.debug(`Failed to close session: ${error.message}`);
1098
+ return { output: `Warning: ${error.message}`, exitCode: 0, action: 'exit' };
1099
+ }
1100
+ },
1101
+ };
1102
+
1103
+ /**
1104
+ * Shell-like commands that work without / prefix
1105
+ */
1106
+ const SHELL_BUILTINS = ['quit', 'exit', 'clear'];
1107
+
1108
+ /**
1109
+ * Check if input is a valid builtin command
1110
+ * @param {string} input - User input
1111
+ * @returns {boolean}
1112
+ */
1113
+ export function isBuiltinCommand(input) {
1114
+ if (!input) return false;
1115
+
1116
+ // Check /command format
1117
+ if (input.startsWith('/')) {
1118
+ const cmd = input.slice(1).split(/\s+/)[0].toLowerCase();
1119
+ return cmd in commandHandlers;
1120
+ }
1121
+
1122
+ // Check shell-like builtins (quit, exit, clear without /)
1123
+ const cmd = input.trim().split(/\s+/)[0].toLowerCase();
1124
+ return SHELL_BUILTINS.includes(cmd) && cmd in commandHandlers;
1125
+ }
1126
+
1127
+ /**
1128
+ * Parse builtin command
1129
+ * @param {string} input - User input like "/log -n 50" or "quit"
1130
+ * @returns {{ command: string, args: string[] }}
1131
+ */
1132
+ export function parseBuiltinCommand(input) {
1133
+ // Handle /command format
1134
+ if (input.startsWith('/')) {
1135
+ const parts = input.slice(1).split(/\s+/);
1136
+ const command = parts[0].toLowerCase();
1137
+ const args = parts.slice(1);
1138
+ return { command, args };
1139
+ }
1140
+
1141
+ // Handle shell-like builtins (quit, exit, clear)
1142
+ const parts = input.trim().split(/\s+/);
1143
+ const command = parts[0].toLowerCase();
1144
+ const args = parts.slice(1);
1145
+ return { command, args };
1146
+ }
1147
+
1148
+ /**
1149
+ * Execute a builtin command
1150
+ * @param {string} input - User input like "/log -n 50"
1151
+ * @param {Object} context - Execution context { client, sessionManager, historyManager, sandboxId, stats, lastCommand, lastOutput }
1152
+ * @returns {Promise<{ output: string, exitCode: number, action?: string }>}
1153
+ */
1154
+ export async function executeBuiltinCommand(input, context) {
1155
+ const { command, args } = parseBuiltinCommand(input);
1156
+
1157
+ const handler = commandHandlers[command];
1158
+ if (!handler) {
1159
+ return {
1160
+ output: `Unknown command: /${command}. Type / for available commands.`,
1161
+ exitCode: 1,
1162
+ };
1163
+ }
1164
+
1165
+ try {
1166
+ return await handler(context, args);
1167
+ } catch (error) {
1168
+ logger.debug(`Builtin command error: ${error.stack}`);
1169
+ return {
1170
+ output: `Error executing /${command}: ${friendlyErrorMessage(error.message)}`,
1171
+ exitCode: 1,
1172
+ };
1173
+ }
1174
+ }
1175
+
1176
+ /**
1177
+ * Check if command is interactive (requires PTY)
1178
+ * @param {string} command - Shell command
1179
+ * @returns {{ isInteractive: boolean, cmdName: string, alternative: string|null }}
1180
+ */
1181
+ export function checkInteractiveCommand(command) {
1182
+ const parts = command.trim().split(/\s+/);
1183
+ const fullCmd = parts[0] || '';
1184
+ const cmdName = fullCmd.split('/').pop();
1185
+
1186
+ if (!(cmdName in INTERACTIVE_COMMANDS)) {
1187
+ return { isInteractive: false, cmdName, alternative: null };
1188
+ }
1189
+
1190
+ // Check for non-interactive flags
1191
+ const nonInteractiveFlags = {
1192
+ python: ['-c', '-m', '-V', '--version', '-h', '--help'],
1193
+ python3: ['-c', '-m', '-V', '--version', '-h', '--help'],
1194
+ node: ['-e', '-p', '-c', '-v', '--version', '-h', '--help', '--check'],
1195
+ top: ['-b'],
1196
+ mysql: ['-e', '--execute', '-V', '--version', '-h', '--help'],
1197
+ psql: ['-c', '--command', '-V', '--version', '-h', '--help'],
1198
+ };
1199
+
1200
+ if (nonInteractiveFlags[cmdName]) {
1201
+ for (const flag of nonInteractiveFlags[cmdName]) {
1202
+ if (parts.includes(flag)) {
1203
+ return { isInteractive: false, cmdName, alternative: null };
1204
+ }
1205
+ }
1206
+ // Check for --execute= or --command= format
1207
+ if (cmdName === 'mysql' || cmdName === 'psql') {
1208
+ for (const part of parts) {
1209
+ if (part.startsWith('--execute=') || part.startsWith('--command=')) {
1210
+ return { isInteractive: false, cmdName, alternative: null };
1211
+ }
1212
+ }
1213
+ }
1214
+ }
1215
+
1216
+ // tail -f is interactive
1217
+ if (cmdName === 'tail') {
1218
+ if (command.includes('-f') || command.includes('--follow')) {
1219
+ return { isInteractive: true, cmdName, alternative: 'tail -n 50 <file>' };
1220
+ }
1221
+ return { isInteractive: false, cmdName, alternative: null };
1222
+ }
1223
+
1224
+ // python/node/irb: only interactive when called with no arguments
1225
+ if (['python', 'python3', 'node', 'irb'].includes(cmdName)) {
1226
+ if (parts.length === 1) {
1227
+ return { isInteractive: true, cmdName, alternative: INTERACTIVE_COMMANDS[cmdName] };
1228
+ }
1229
+ // Has arguments (script file or -c/-e), not interactive
1230
+ return { isInteractive: false, cmdName, alternative: null };
1231
+ }
1232
+
1233
+ // mysql/psql: interactive when called with no arguments or only database name
1234
+ // Non-interactive when using -e/--execute (mysql) or -c/--command (psql)
1235
+ if (['mysql', 'psql'].includes(cmdName)) {
1236
+ if (parts.length === 1) {
1237
+ return { isInteractive: true, cmdName, alternative: INTERACTIVE_COMMANDS[cmdName] };
1238
+ }
1239
+ // Already checked non-interactive flags above, so if we get here with args, check again
1240
+ // If no non-interactive flag found, it's still interactive (e.g., mysql dbname)
1241
+ return { isInteractive: true, cmdName, alternative: INTERACTIVE_COMMANDS[cmdName] };
1242
+ }
1243
+
1244
+ // Always interactive commands (no non-interactive mode)
1245
+ // Note: top is here because -b check happens before this
1246
+ // Note: mysql and psql removed - they have -e/--execute and -c/--command non-interactive modes
1247
+ const alwaysInteractive = ['vim', 'vi', 'nano', 'emacs', 'less', 'more', 'top', 'htop', 'ssh', 'man', 'watch', 'bash', 'sh'];
1248
+ if (alwaysInteractive.includes(cmdName)) {
1249
+ return { isInteractive: true, cmdName, alternative: INTERACTIVE_COMMANDS[cmdName] };
1250
+ }
1251
+
1252
+ return { isInteractive: false, cmdName, alternative: null };
1253
+ }