@kernel.chat/kbot 2.23.1 → 2.24.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/dist/record.js ADDED
@@ -0,0 +1,1182 @@
1
+ // K:BOT Terminal Recording — Capture sessions as SVG, GIF, or Asciicast
2
+ //
3
+ // Records terminal sessions with full timing data for animated playback.
4
+ // Output formats:
5
+ // - SVG: Animated terminal replay for READMEs (no dependencies)
6
+ // - GIF: Via ImageMagick/ffmpeg if available
7
+ // - Asciicast v2: Compatible with asciinema player
8
+ // - JSON: Raw frame data for custom renderers
9
+ //
10
+ // Usage:
11
+ // kbot record start # Start recording
12
+ // kbot record stop # Stop + choose format
13
+ // kbot record start --output demo.svg # Auto-save as SVG
14
+ // kbot record list # Show saved recordings
15
+ // kbot record replay <file> # Replay in terminal
16
+ import { homedir, platform } from 'node:os';
17
+ import { join, extname, basename } from 'node:path';
18
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, unlinkSync, statSync, } from 'node:fs';
19
+ import { spawn, execSync } from 'node:child_process';
20
+ // ── Paths ──
21
+ const RECORDINGS_DIR = join(homedir(), '.kbot', 'recordings');
22
+ const STATE_FILE = join(RECORDINGS_DIR, '.recording-state.json');
23
+ function ensureDir() {
24
+ if (!existsSync(RECORDINGS_DIR))
25
+ mkdirSync(RECORDINGS_DIR, { recursive: true });
26
+ }
27
+ const THEMES = {
28
+ dark: {
29
+ bg: '#1a1a2e',
30
+ fg: '#e0e0e0',
31
+ titleBg: '#16162a',
32
+ titleFg: '#8888aa',
33
+ dotRed: '#ff5f57',
34
+ dotYellow: '#febc2e',
35
+ dotGreen: '#28c840',
36
+ cursor: '#a78bfa',
37
+ bold: '#ffffff',
38
+ dim: '#666680',
39
+ },
40
+ light: {
41
+ bg: '#fafaf8',
42
+ fg: '#2e2e2e',
43
+ titleBg: '#e8e8e4',
44
+ titleFg: '#888888',
45
+ dotRed: '#ff5f57',
46
+ dotYellow: '#febc2e',
47
+ dotGreen: '#28c840',
48
+ cursor: '#6B5B95',
49
+ bold: '#000000',
50
+ dim: '#999999',
51
+ },
52
+ monokai: {
53
+ bg: '#272822',
54
+ fg: '#f8f8f2',
55
+ titleBg: '#1e1f1c',
56
+ titleFg: '#75715e',
57
+ dotRed: '#f92672',
58
+ dotYellow: '#e6db74',
59
+ dotGreen: '#a6e22e',
60
+ cursor: '#fd971f',
61
+ bold: '#ffffff',
62
+ dim: '#75715e',
63
+ },
64
+ dracula: {
65
+ bg: '#282a36',
66
+ fg: '#f8f8f2',
67
+ titleBg: '#21222c',
68
+ titleFg: '#6272a4',
69
+ dotRed: '#ff5555',
70
+ dotYellow: '#f1fa8c',
71
+ dotGreen: '#50fa7b',
72
+ cursor: '#bd93f9',
73
+ bold: '#ffffff',
74
+ dim: '#6272a4',
75
+ },
76
+ };
77
+ // ── ANSI stripping ──
78
+ /** Strip ANSI escape codes from a string for measuring / rendering */
79
+ function stripAnsi(str) {
80
+ // eslint-disable-next-line no-control-regex
81
+ return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
82
+ .replace(/\x1b\][^\x07]*\x07/g, '') // OSC sequences
83
+ .replace(/\x1b\(B/g, '') // charset selection
84
+ .replace(/\r/g, ''); // carriage returns
85
+ }
86
+ /** Escape XML special characters */
87
+ function escapeXML(str) {
88
+ return str
89
+ .replace(/&/g, '&amp;')
90
+ .replace(/</g, '&lt;')
91
+ .replace(/>/g, '&gt;')
92
+ .replace(/"/g, '&quot;')
93
+ .replace(/'/g, '&#39;');
94
+ }
95
+ // ── Recording ID generation ──
96
+ function generateId() {
97
+ const now = new Date();
98
+ const date = now.toISOString().split('T')[0].replace(/-/g, '');
99
+ const time = now.toTimeString().split(' ')[0].replace(/:/g, '');
100
+ const rand = Math.random().toString(36).slice(2, 6);
101
+ return `rec-${date}-${time}-${rand}`;
102
+ }
103
+ // ── Recording state management ──
104
+ function saveState(state) {
105
+ ensureDir();
106
+ writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), 'utf-8');
107
+ }
108
+ function loadState() {
109
+ if (!existsSync(STATE_FILE))
110
+ return null;
111
+ try {
112
+ return JSON.parse(readFileSync(STATE_FILE, 'utf-8'));
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ }
118
+ function clearState() {
119
+ if (existsSync(STATE_FILE))
120
+ unlinkSync(STATE_FILE);
121
+ }
122
+ function isRecording() {
123
+ const state = loadState();
124
+ if (!state)
125
+ return false;
126
+ // Check if the process is still alive
127
+ try {
128
+ process.kill(state.pid, 0);
129
+ return true;
130
+ }
131
+ catch {
132
+ // Process is dead — clean up stale state
133
+ clearState();
134
+ return false;
135
+ }
136
+ }
137
+ // ── Start / Stop recording ──
138
+ /**
139
+ * Start recording the terminal session.
140
+ *
141
+ * Uses the `script` command (POSIX) to capture all terminal output
142
+ * with timing data. Works on macOS and Linux.
143
+ */
144
+ export function startRecording(options = {}) {
145
+ if (isRecording()) {
146
+ return { success: false, message: 'A recording is already in progress. Run `kbot record stop` first.' };
147
+ }
148
+ ensureDir();
149
+ const id = generateId();
150
+ const cols = options.cols || process.stdout.columns || 80;
151
+ const rows = options.rows || process.stdout.rows || 24;
152
+ const shell = options.shell || process.env.SHELL || '/bin/bash';
153
+ const title = options.title || `K:BOT Recording ${id}`;
154
+ const scriptFile = join(RECORDINGS_DIR, `${id}.script`);
155
+ const timingFile = join(RECORDINGS_DIR, `${id}.timing`);
156
+ // Build the script command based on platform
157
+ let child;
158
+ if (platform() === 'darwin') {
159
+ // macOS: script -q <file> uses a subshell;
160
+ // timing is captured with our own wrapper
161
+ child = spawn('script', ['-q', scriptFile], {
162
+ stdio: 'inherit',
163
+ env: {
164
+ ...process.env,
165
+ KBOT_RECORDING: '1',
166
+ KBOT_RECORDING_ID: id,
167
+ COLUMNS: String(cols),
168
+ LINES: String(rows),
169
+ },
170
+ });
171
+ }
172
+ else {
173
+ // Linux: script -q --timing=<file> -c <shell> <file>
174
+ child = spawn('script', [
175
+ '-q',
176
+ `--timing=${timingFile}`,
177
+ '-c', shell,
178
+ scriptFile,
179
+ ], {
180
+ stdio: 'inherit',
181
+ env: {
182
+ ...process.env,
183
+ KBOT_RECORDING: '1',
184
+ KBOT_RECORDING_ID: id,
185
+ COLUMNS: String(cols),
186
+ LINES: String(rows),
187
+ },
188
+ });
189
+ }
190
+ // Write start time for timing reconstruction
191
+ writeFileSync(timingFile, `${Date.now()}\n`, 'utf-8');
192
+ const state = {
193
+ pid: child.pid,
194
+ id,
195
+ startedAt: new Date().toISOString(),
196
+ scriptFile,
197
+ timingFile,
198
+ output: options.output,
199
+ title: options.title,
200
+ cols,
201
+ rows,
202
+ shell,
203
+ };
204
+ saveState(state);
205
+ // When the child exits, we process the recording
206
+ child.on('exit', () => {
207
+ processRecordingOnExit(state);
208
+ });
209
+ return {
210
+ success: true,
211
+ message: `Recording started (${id}). Type \`exit\` or press Ctrl-D to stop.`,
212
+ id,
213
+ };
214
+ }
215
+ /**
216
+ * Stop an active recording session.
217
+ */
218
+ export function stopRecording() {
219
+ const state = loadState();
220
+ if (!state) {
221
+ return { success: false, message: 'No active recording found.' };
222
+ }
223
+ // Send SIGHUP to the script process to terminate it
224
+ try {
225
+ process.kill(state.pid, 'SIGHUP');
226
+ }
227
+ catch {
228
+ // Process may already be dead
229
+ }
230
+ // Wait briefly for the file to be flushed, then process
231
+ const recording = buildRecordingFromScript(state);
232
+ if (!recording) {
233
+ clearState();
234
+ return { success: false, message: 'Failed to read recording data.' };
235
+ }
236
+ // Save the recording JSON
237
+ const jsonPath = join(RECORDINGS_DIR, `${state.id}.json`);
238
+ writeFileSync(jsonPath, JSON.stringify(recording, null, 2), 'utf-8');
239
+ // Determine output format
240
+ let outputPath;
241
+ if (state.output) {
242
+ const ext = extname(state.output).toLowerCase();
243
+ switch (ext) {
244
+ case '.svg':
245
+ writeFileSync(state.output, toSVG(recording), 'utf-8');
246
+ outputPath = state.output;
247
+ break;
248
+ case '.gif':
249
+ outputPath = toGIF(recording, state.output);
250
+ break;
251
+ case '.cast':
252
+ writeFileSync(state.output, toAsciicast(recording), 'utf-8');
253
+ outputPath = state.output;
254
+ break;
255
+ default:
256
+ writeFileSync(state.output, JSON.stringify(recording, null, 2), 'utf-8');
257
+ outputPath = state.output;
258
+ }
259
+ }
260
+ // Clean up intermediate files
261
+ cleanupTempFiles(state);
262
+ clearState();
263
+ return {
264
+ success: true,
265
+ message: outputPath
266
+ ? `Recording saved: ${outputPath} (${recording.duration.toFixed(1)}s, ${recording.frames.length} frames)`
267
+ : `Recording saved: ${jsonPath} (${recording.duration.toFixed(1)}s, ${recording.frames.length} frames)`,
268
+ recording,
269
+ outputPath: outputPath || jsonPath,
270
+ };
271
+ }
272
+ /** Process a recording after the script session exits naturally */
273
+ function processRecordingOnExit(state) {
274
+ try {
275
+ const recording = buildRecordingFromScript(state);
276
+ if (!recording) {
277
+ clearState();
278
+ return;
279
+ }
280
+ const jsonPath = join(RECORDINGS_DIR, `${state.id}.json`);
281
+ writeFileSync(jsonPath, JSON.stringify(recording, null, 2), 'utf-8');
282
+ if (state.output) {
283
+ const ext = extname(state.output).toLowerCase();
284
+ switch (ext) {
285
+ case '.svg':
286
+ writeFileSync(state.output, toSVG(recording), 'utf-8');
287
+ break;
288
+ case '.gif':
289
+ toGIF(recording, state.output);
290
+ break;
291
+ case '.cast':
292
+ writeFileSync(state.output, toAsciicast(recording), 'utf-8');
293
+ break;
294
+ default:
295
+ writeFileSync(state.output, JSON.stringify(recording, null, 2), 'utf-8');
296
+ }
297
+ }
298
+ cleanupTempFiles(state);
299
+ clearState();
300
+ }
301
+ catch {
302
+ clearState();
303
+ }
304
+ }
305
+ /** Build a Recording from script output + timing data */
306
+ function buildRecordingFromScript(state) {
307
+ if (!existsSync(state.scriptFile))
308
+ return null;
309
+ const rawScript = readFileSync(state.scriptFile, 'utf-8');
310
+ const startTime = parseTimingFile(state.timingFile);
311
+ // Split the raw script output into frames
312
+ // Each line break or substantial chunk of output becomes a frame
313
+ const frames = buildFrames(rawScript, startTime);
314
+ const duration = frames.length > 0 ? frames[frames.length - 1].time : 0;
315
+ return {
316
+ id: state.id,
317
+ title: state.title || `K:BOT Recording ${state.id}`,
318
+ startedAt: state.startedAt,
319
+ duration,
320
+ cols: state.cols,
321
+ rows: state.rows,
322
+ frames,
323
+ env: {
324
+ shell: state.shell,
325
+ term: process.env.TERM || 'xterm-256color',
326
+ platform: platform(),
327
+ },
328
+ };
329
+ }
330
+ /** Parse the timing file to get the start timestamp */
331
+ function parseTimingFile(timingFile) {
332
+ if (!existsSync(timingFile))
333
+ return Date.now();
334
+ try {
335
+ const content = readFileSync(timingFile, 'utf-8').trim();
336
+ const firstLine = content.split('\n')[0];
337
+ const ts = parseInt(firstLine, 10);
338
+ return isNaN(ts) ? Date.now() : ts;
339
+ }
340
+ catch {
341
+ return Date.now();
342
+ }
343
+ }
344
+ /**
345
+ * Build frame array from raw script output.
346
+ *
347
+ * Splits output into logical frames: each line or burst of output
348
+ * becomes a separate frame with simulated timing based on content length.
349
+ */
350
+ function buildFrames(rawOutput, _startTime) {
351
+ const frames = [];
352
+ // Split on newlines, preserving the newline in each chunk
353
+ const lines = rawOutput.split('\n');
354
+ let elapsed = 0;
355
+ for (const line of lines) {
356
+ if (line.length === 0 && frames.length > 0) {
357
+ // Empty line — small pause
358
+ elapsed += 0.05;
359
+ frames.push({ time: elapsed, data: '\n' });
360
+ continue;
361
+ }
362
+ // Simulate realistic typing timing:
363
+ // - Short lines (prompts, commands): 0.03s per char (typing)
364
+ // - Long lines (output): burst at 0.001s per char
365
+ const stripped = stripAnsi(line);
366
+ const isTyping = stripped.length < 80 && (stripped.includes('$') || stripped.includes('>')
367
+ || stripped.includes('#') || stripped.startsWith('kbot'));
368
+ if (isTyping) {
369
+ // Character-by-character typing for prompts
370
+ for (const char of line) {
371
+ elapsed += 0.03 + Math.random() * 0.05;
372
+ frames.push({ time: elapsed, data: char });
373
+ }
374
+ elapsed += 0.02;
375
+ frames.push({ time: elapsed, data: '\n' });
376
+ }
377
+ else {
378
+ // Burst output
379
+ elapsed += 0.01 + (stripped.length * 0.001);
380
+ frames.push({ time: elapsed, data: line + '\n' });
381
+ }
382
+ }
383
+ return frames;
384
+ }
385
+ /** Clean up intermediate .script and .timing files */
386
+ function cleanupTempFiles(state) {
387
+ try {
388
+ if (existsSync(state.scriptFile))
389
+ unlinkSync(state.scriptFile);
390
+ }
391
+ catch { /* ignore */ }
392
+ try {
393
+ if (existsSync(state.timingFile))
394
+ unlinkSync(state.timingFile);
395
+ }
396
+ catch { /* ignore */ }
397
+ }
398
+ // ── SVG Generation ──
399
+ /**
400
+ * Convert a recording to an animated SVG.
401
+ *
402
+ * Produces a self-contained SVG with CSS keyframe animations that
403
+ * replays the terminal session. Compatible with GitHub READMEs,
404
+ * browsers, and any SVG viewer.
405
+ */
406
+ export function toSVG(recording, options = {}) {
407
+ const theme = THEMES[options.theme || 'dark'] || THEMES.dark;
408
+ const cols = options.cols || recording.cols || 80;
409
+ const rows = options.rows || recording.rows || 24;
410
+ const fontSize = options.fontSize || 14;
411
+ const chrome = options.chrome !== false;
412
+ const speed = options.speed || 1;
413
+ const loop = options.loop !== false;
414
+ const title = options.title || recording.title || 'K:BOT Terminal';
415
+ const charWidth = fontSize * 0.6;
416
+ const lineHeight = fontSize * 1.4;
417
+ const padding = 16;
418
+ const chromeHeight = chrome ? 40 : 0;
419
+ const width = Math.ceil(cols * charWidth + padding * 2);
420
+ const height = Math.ceil(rows * lineHeight + padding * 2 + chromeHeight);
421
+ // Build screen states from frames
422
+ const screenStates = buildScreenStates(recording.frames, cols, rows, speed);
423
+ const totalDuration = screenStates.length > 0
424
+ ? screenStates[screenStates.length - 1].time + 2
425
+ : 5;
426
+ // Generate SVG
427
+ const lines = [];
428
+ lines.push(`<?xml version="1.0" encoding="UTF-8"?>`);
429
+ lines.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`);
430
+ lines.push(`<defs>`);
431
+ lines.push(` <style>`);
432
+ lines.push(` @keyframes cursor-blink {`);
433
+ lines.push(` 0%, 50% { opacity: 1; }`);
434
+ lines.push(` 51%, 100% { opacity: 0; }`);
435
+ lines.push(` }`);
436
+ // Generate visibility keyframes for each screen state
437
+ for (let i = 0; i < screenStates.length; i++) {
438
+ const startPct = (screenStates[i].time / totalDuration) * 100;
439
+ const endPct = i < screenStates.length - 1
440
+ ? (screenStates[i + 1].time / totalDuration) * 100
441
+ : 100;
442
+ lines.push(` @keyframes frame-${i} {`);
443
+ lines.push(` 0%, ${Math.max(0, startPct - 0.01).toFixed(2)}% { visibility: hidden; }`);
444
+ lines.push(` ${startPct.toFixed(2)}%, ${endPct.toFixed(2)}% { visibility: visible; }`);
445
+ if (endPct < 100) {
446
+ lines.push(` ${(endPct + 0.01).toFixed(2)}%, 100% { visibility: hidden; }`);
447
+ }
448
+ lines.push(` }`);
449
+ }
450
+ lines.push(` </style>`);
451
+ lines.push(`</defs>`);
452
+ // Background
453
+ lines.push(`<rect width="${width}" height="${height}" rx="8" ry="8" fill="${theme.bg}"/>`);
454
+ // Terminal chrome (title bar with traffic light dots)
455
+ if (chrome) {
456
+ lines.push(`<rect width="${width}" height="${chromeHeight}" rx="8" ry="0" fill="${theme.titleBg}"/>`);
457
+ // Clip the bottom corners of the title bar
458
+ lines.push(`<rect x="0" y="${chromeHeight - 8}" width="${width}" height="8" fill="${theme.titleBg}"/>`);
459
+ // Traffic light dots
460
+ const dotY = chromeHeight / 2;
461
+ const dotR = 6;
462
+ lines.push(`<circle cx="${padding + dotR}" cy="${dotY}" r="${dotR}" fill="${theme.dotRed}"/>`);
463
+ lines.push(`<circle cx="${padding + dotR * 3 + 4}" cy="${dotY}" r="${dotR}" fill="${theme.dotYellow}"/>`);
464
+ lines.push(`<circle cx="${padding + dotR * 5 + 8}" cy="${dotY}" r="${dotR}" fill="${theme.dotGreen}"/>`);
465
+ // Title text
466
+ const titleX = width / 2;
467
+ lines.push(`<text x="${titleX}" y="${dotY + 4}" fill="${theme.titleFg}" font-family="monospace" font-size="${fontSize - 2}" text-anchor="middle">${escapeXML(title)}</text>`);
468
+ }
469
+ // Render each screen state as a group with visibility animation
470
+ const contentY = chromeHeight + padding;
471
+ for (let i = 0; i < screenStates.length; i++) {
472
+ const state = screenStates[i];
473
+ const animDuration = totalDuration.toFixed(2);
474
+ const iterCount = loop ? 'infinite' : '1';
475
+ lines.push(`<g style="visibility:hidden;animation:frame-${i} ${animDuration}s linear ${iterCount}">`);
476
+ // Render each line of the screen state
477
+ for (let row = 0; row < state.lines.length && row < rows; row++) {
478
+ const line = state.lines[row];
479
+ if (!line || line.trim().length === 0)
480
+ continue;
481
+ const x = padding;
482
+ const y = contentY + (row + 1) * lineHeight;
483
+ lines.push(` <text x="${x}" y="${y}" fill="${theme.fg}" font-family="'Courier New',monospace" font-size="${fontSize}" xml:space="preserve">${escapeXML(line)}</text>`);
484
+ }
485
+ // Cursor (blinking block at end of last non-empty line)
486
+ const lastLineIdx = findLastNonEmptyLine(state.lines);
487
+ if (lastLineIdx >= 0) {
488
+ const cursorX = padding + state.lines[lastLineIdx].length * charWidth;
489
+ const cursorY = contentY + lastLineIdx * lineHeight + 2;
490
+ lines.push(` <rect x="${cursorX}" y="${cursorY}" width="${charWidth}" height="${lineHeight}" fill="${theme.cursor}" opacity="0.7" style="animation:cursor-blink 1s step-end infinite"/>`);
491
+ }
492
+ lines.push(`</g>`);
493
+ }
494
+ // Watermark
495
+ lines.push(`<text x="${width - padding}" y="${height - 6}" fill="${theme.dim}" font-family="monospace" font-size="10" text-anchor="end" opacity="0.5">recorded with kbot</text>`);
496
+ lines.push(`</svg>`);
497
+ return lines.join('\n');
498
+ }
499
+ /** Find the last non-empty line index */
500
+ function findLastNonEmptyLine(lines) {
501
+ for (let i = lines.length - 1; i >= 0; i--) {
502
+ if (lines[i] && lines[i].trim().length > 0)
503
+ return i;
504
+ }
505
+ return -1;
506
+ }
507
+ function buildScreenStates(frames, cols, rows, speed) {
508
+ const states = [];
509
+ const screen = new Array(rows).fill('');
510
+ let cursorRow = 0;
511
+ // Accumulate frames and snapshot at intervals
512
+ // For SVG, we snapshot every ~0.1 seconds of real content change
513
+ let lastSnapshotTime = -1;
514
+ const minInterval = 0.1 / speed;
515
+ let pendingData = '';
516
+ for (const frame of frames) {
517
+ pendingData += frame.data;
518
+ const adjustedTime = frame.time / speed;
519
+ if (adjustedTime - lastSnapshotTime < minInterval)
520
+ continue;
521
+ // Apply pending data to the virtual screen
522
+ const result = applyToScreen(screen, cursorRow, pendingData, cols, rows);
523
+ cursorRow = result.cursorRow;
524
+ pendingData = '';
525
+ // Take a snapshot
526
+ states.push({
527
+ time: adjustedTime,
528
+ lines: [...screen],
529
+ });
530
+ lastSnapshotTime = adjustedTime;
531
+ }
532
+ // Final state with remaining data
533
+ if (pendingData.length > 0) {
534
+ const result = applyToScreen(screen, cursorRow, pendingData, cols, rows);
535
+ cursorRow = result.cursorRow;
536
+ const finalTime = frames.length > 0 ? frames[frames.length - 1].time / speed : 0;
537
+ states.push({
538
+ time: finalTime,
539
+ lines: [...screen],
540
+ });
541
+ }
542
+ // Deduplicate identical consecutive states
543
+ const deduped = [];
544
+ for (const state of states) {
545
+ if (deduped.length === 0) {
546
+ deduped.push(state);
547
+ continue;
548
+ }
549
+ const prev = deduped[deduped.length - 1];
550
+ const same = prev.lines.length === state.lines.length
551
+ && prev.lines.every((l, i) => l === state.lines[i]);
552
+ if (!same) {
553
+ deduped.push(state);
554
+ }
555
+ }
556
+ // Cap at a reasonable number of frames for SVG size
557
+ const MAX_SVG_FRAMES = 200;
558
+ if (deduped.length > MAX_SVG_FRAMES) {
559
+ const step = Math.ceil(deduped.length / MAX_SVG_FRAMES);
560
+ const sampled = [];
561
+ for (let i = 0; i < deduped.length; i += step) {
562
+ sampled.push(deduped[i]);
563
+ }
564
+ // Always include the last frame
565
+ if (sampled[sampled.length - 1] !== deduped[deduped.length - 1]) {
566
+ sampled.push(deduped[deduped.length - 1]);
567
+ }
568
+ return sampled;
569
+ }
570
+ return deduped;
571
+ }
572
+ /** Apply raw data to a virtual screen buffer, handling newlines and basic ANSI */
573
+ function applyToScreen(screen, cursorRow, data, cols, rows) {
574
+ const clean = stripAnsi(data);
575
+ for (const char of clean) {
576
+ if (char === '\n') {
577
+ cursorRow++;
578
+ if (cursorRow >= rows) {
579
+ // Scroll up
580
+ screen.shift();
581
+ screen.push('');
582
+ cursorRow = rows - 1;
583
+ }
584
+ continue;
585
+ }
586
+ if (char === '\r')
587
+ continue; // Ignore carriage returns
588
+ if (char === '\t') {
589
+ // Tab: advance to next 8-col boundary
590
+ const currentLen = screen[cursorRow]?.length || 0;
591
+ const spaces = 8 - (currentLen % 8);
592
+ screen[cursorRow] = (screen[cursorRow] || '') + ' '.repeat(spaces);
593
+ continue;
594
+ }
595
+ // Printable character
596
+ if (cursorRow >= 0 && cursorRow < rows) {
597
+ const line = screen[cursorRow] || '';
598
+ if (line.length < cols) {
599
+ screen[cursorRow] = line + char;
600
+ }
601
+ // If line is at max cols, next char would wrap but we just truncate for SVG
602
+ }
603
+ }
604
+ return { cursorRow };
605
+ }
606
+ // ── GIF Generation ──
607
+ /**
608
+ * Convert a recording to an animated GIF.
609
+ *
610
+ * Shells out to ImageMagick (`convert`) or `ffmpeg` if available.
611
+ * Falls back to SVG if no image tools are installed.
612
+ *
613
+ * @returns The output file path, or undefined if generation failed.
614
+ */
615
+ export function toGIF(recording, outputPath, options = {}) {
616
+ const cols = options.cols || recording.cols || 80;
617
+ const rows = options.rows || recording.rows || 24;
618
+ const fontSize = options.fontSize || 14;
619
+ const theme = THEMES[options.theme || 'dark'] || THEMES.dark;
620
+ const speed = options.speed || 1;
621
+ const fps = options.fps || 10;
622
+ // Check which tool is available
623
+ const hasConvert = commandExists('convert');
624
+ const hasFfmpeg = commandExists('ffmpeg');
625
+ if (!hasConvert && !hasFfmpeg) {
626
+ // Fall back to SVG
627
+ const svgPath = outputPath.replace(/\.gif$/i, '.svg');
628
+ writeFileSync(svgPath, toSVG(recording, { theme: options.theme, speed }), 'utf-8');
629
+ return svgPath;
630
+ }
631
+ // Build screen states
632
+ const screenStates = buildScreenStates(recording.frames, cols, rows, speed);
633
+ if (screenStates.length === 0)
634
+ return undefined;
635
+ // Create temporary directory for frame PNGs
636
+ const tmpDir = join(RECORDINGS_DIR, '.tmp-gif-' + recording.id);
637
+ mkdirSync(tmpDir, { recursive: true });
638
+ try {
639
+ // Generate SVG frames and convert to PNG
640
+ const charWidth = fontSize * 0.6;
641
+ const lineHeight = fontSize * 1.4;
642
+ const padding = 16;
643
+ const chromeHeight = 40;
644
+ const width = Math.ceil(cols * charWidth + padding * 2);
645
+ const height = Math.ceil(rows * lineHeight + padding * 2 + chromeHeight);
646
+ const framePaths = [];
647
+ for (let i = 0; i < screenStates.length; i++) {
648
+ const state = screenStates[i];
649
+ const frameSvg = renderFrameSVG(state, {
650
+ width, height, cols, rows, fontSize, charWidth, lineHeight,
651
+ padding, chromeHeight, theme, title: recording.title,
652
+ });
653
+ const svgPath = join(tmpDir, `frame-${String(i).padStart(5, '0')}.svg`);
654
+ writeFileSync(svgPath, frameSvg, 'utf-8');
655
+ framePaths.push(svgPath);
656
+ }
657
+ // Calculate frame delay (in centiseconds for ImageMagick, ms for ffmpeg)
658
+ const frameDelay = Math.round(100 / fps); // centiseconds
659
+ if (hasConvert) {
660
+ // Use ImageMagick
661
+ try {
662
+ const args = [
663
+ '-delay', String(frameDelay),
664
+ '-loop', '0',
665
+ ...framePaths,
666
+ '-layers', 'Optimize',
667
+ outputPath,
668
+ ];
669
+ execSync(`convert ${args.map(a => `"${a}"`).join(' ')}`, {
670
+ timeout: 60000,
671
+ stdio: 'pipe',
672
+ });
673
+ return outputPath;
674
+ }
675
+ catch {
676
+ // ImageMagick failed — try ffmpeg
677
+ }
678
+ }
679
+ if (hasFfmpeg) {
680
+ try {
681
+ // Convert SVGs to PNGs first with ImageMagick or rsvg-convert
682
+ const hasRsvg = commandExists('rsvg-convert');
683
+ const pngPaths = [];
684
+ for (let i = 0; i < framePaths.length; i++) {
685
+ const pngPath = framePaths[i].replace('.svg', '.png');
686
+ if (hasRsvg) {
687
+ execSync(`rsvg-convert "${framePaths[i]}" -o "${pngPath}"`, {
688
+ timeout: 10000,
689
+ stdio: 'pipe',
690
+ });
691
+ }
692
+ else if (hasConvert) {
693
+ execSync(`convert "${framePaths[i]}" "${pngPath}"`, {
694
+ timeout: 10000,
695
+ stdio: 'pipe',
696
+ });
697
+ }
698
+ else {
699
+ // Cannot convert SVG to PNG without rsvg-convert or ImageMagick
700
+ // Fall back to SVG output
701
+ const svgPath = outputPath.replace(/\.gif$/i, '.svg');
702
+ writeFileSync(svgPath, toSVG(recording, { theme: options.theme, speed }), 'utf-8');
703
+ return svgPath;
704
+ }
705
+ pngPaths.push(pngPath);
706
+ }
707
+ // Use ffmpeg to create GIF from PNGs
708
+ const inputPattern = join(tmpDir, 'frame-%05d.png');
709
+ execSync(`ffmpeg -y -framerate ${fps} -i "${inputPattern}" -vf "split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse" "${outputPath}"`, { timeout: 60000, stdio: 'pipe' });
710
+ return outputPath;
711
+ }
712
+ catch {
713
+ // ffmpeg failed — fallback to SVG
714
+ }
715
+ }
716
+ // All methods failed — produce SVG fallback
717
+ const svgPath = outputPath.replace(/\.gif$/i, '.svg');
718
+ writeFileSync(svgPath, toSVG(recording, { theme: options.theme, speed }), 'utf-8');
719
+ return svgPath;
720
+ }
721
+ finally {
722
+ // Clean up temp directory
723
+ try {
724
+ const tmpFiles = readdirSync(tmpDir);
725
+ for (const f of tmpFiles)
726
+ unlinkSync(join(tmpDir, f));
727
+ // Remove the directory itself
728
+ try {
729
+ execSync(`rmdir "${tmpDir}"`, { stdio: 'pipe' });
730
+ }
731
+ catch { /* ignore */ }
732
+ }
733
+ catch { /* ignore */ }
734
+ }
735
+ }
736
+ /** Render a single frame as a complete SVG (for GIF frame generation) */
737
+ function renderFrameSVG(state, opts) {
738
+ const { width, height, padding, chromeHeight, lineHeight, fontSize, theme, title } = opts;
739
+ const contentY = chromeHeight + padding;
740
+ const lines = [];
741
+ lines.push(`<?xml version="1.0" encoding="UTF-8"?>`);
742
+ lines.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`);
743
+ // Background
744
+ lines.push(`<rect width="${width}" height="${height}" rx="8" ry="8" fill="${theme.bg}"/>`);
745
+ // Chrome
746
+ lines.push(`<rect width="${width}" height="${chromeHeight}" rx="8" ry="0" fill="${theme.titleBg}"/>`);
747
+ lines.push(`<rect x="0" y="${chromeHeight - 8}" width="${width}" height="8" fill="${theme.titleBg}"/>`);
748
+ const dotY = chromeHeight / 2;
749
+ const dotR = 6;
750
+ lines.push(`<circle cx="${padding + dotR}" cy="${dotY}" r="${dotR}" fill="${theme.dotRed}"/>`);
751
+ lines.push(`<circle cx="${padding + dotR * 3 + 4}" cy="${dotY}" r="${dotR}" fill="${theme.dotYellow}"/>`);
752
+ lines.push(`<circle cx="${padding + dotR * 5 + 8}" cy="${dotY}" r="${dotR}" fill="${theme.dotGreen}"/>`);
753
+ lines.push(`<text x="${width / 2}" y="${dotY + 4}" fill="${theme.titleFg}" font-family="monospace" font-size="${fontSize - 2}" text-anchor="middle">${escapeXML(title)}</text>`);
754
+ // Content
755
+ for (let row = 0; row < state.lines.length; row++) {
756
+ const line = state.lines[row];
757
+ if (!line || line.trim().length === 0)
758
+ continue;
759
+ const x = padding;
760
+ const y = contentY + (row + 1) * lineHeight;
761
+ lines.push(`<text x="${x}" y="${y}" fill="${theme.fg}" font-family="'Courier New',monospace" font-size="${fontSize}" xml:space="preserve">${escapeXML(line)}</text>`);
762
+ }
763
+ lines.push(`</svg>`);
764
+ return lines.join('\n');
765
+ }
766
+ /** Check if a command exists on the system */
767
+ function commandExists(cmd) {
768
+ try {
769
+ execSync(`which "${cmd}"`, { stdio: 'pipe' });
770
+ return true;
771
+ }
772
+ catch {
773
+ return false;
774
+ }
775
+ }
776
+ // ── Asciicast v2 Format ──
777
+ /**
778
+ * Convert a recording to asciinema v2 format (.cast).
779
+ *
780
+ * Produces a file compatible with the asciinema player and asciinema.org.
781
+ * Format spec: https://docs.asciinema.org/manual/asciicast/v2/
782
+ */
783
+ export function toAsciicast(recording) {
784
+ const header = {
785
+ version: 2,
786
+ width: recording.cols || 80,
787
+ height: recording.rows || 24,
788
+ timestamp: Math.floor(new Date(recording.startedAt).getTime() / 1000),
789
+ title: recording.title || 'K:BOT Terminal Recording',
790
+ env: {
791
+ SHELL: recording.env.shell,
792
+ TERM: recording.env.term,
793
+ },
794
+ };
795
+ const lines = [JSON.stringify(header)];
796
+ for (const frame of recording.frames) {
797
+ // Asciicast v2 event: [time, event_type, data]
798
+ // "o" = output (stdout)
799
+ const event = [
800
+ parseFloat(frame.time.toFixed(6)),
801
+ 'o',
802
+ frame.data,
803
+ ];
804
+ lines.push(JSON.stringify(event));
805
+ }
806
+ return lines.join('\n') + '\n';
807
+ }
808
+ // ── Recording from raw frame data (programmatic) ──
809
+ /**
810
+ * Create a recording programmatically from an array of frames.
811
+ * Useful for generating demo recordings without actually running a shell.
812
+ */
813
+ export function createRecording(frames, options = {}) {
814
+ const id = generateId();
815
+ const cols = options.cols || 80;
816
+ const rows = options.rows || 24;
817
+ const duration = frames.length > 0 ? frames[frames.length - 1].time : 0;
818
+ return {
819
+ id,
820
+ title: options.title || `K:BOT Recording ${id}`,
821
+ startedAt: new Date().toISOString(),
822
+ duration,
823
+ cols,
824
+ rows,
825
+ frames,
826
+ env: {
827
+ shell: process.env.SHELL || '/bin/bash',
828
+ term: process.env.TERM || 'xterm-256color',
829
+ platform: platform(),
830
+ },
831
+ };
832
+ }
833
+ // ── Load / List / Delete Recordings ──
834
+ /**
835
+ * Load a recording by ID or file path.
836
+ */
837
+ export function loadRecording(idOrPath) {
838
+ // Try as direct path first
839
+ if (existsSync(idOrPath)) {
840
+ try {
841
+ const ext = extname(idOrPath).toLowerCase();
842
+ if (ext === '.json') {
843
+ return JSON.parse(readFileSync(idOrPath, 'utf-8'));
844
+ }
845
+ if (ext === '.cast') {
846
+ return parseCastFile(idOrPath);
847
+ }
848
+ }
849
+ catch {
850
+ return null;
851
+ }
852
+ }
853
+ // Try as ID in recordings directory
854
+ ensureDir();
855
+ const jsonPath = join(RECORDINGS_DIR, `${idOrPath}.json`);
856
+ if (existsSync(jsonPath)) {
857
+ try {
858
+ return JSON.parse(readFileSync(jsonPath, 'utf-8'));
859
+ }
860
+ catch {
861
+ return null;
862
+ }
863
+ }
864
+ return null;
865
+ }
866
+ /** Parse an asciicast v2 file into a Recording */
867
+ function parseCastFile(filePath) {
868
+ try {
869
+ const content = readFileSync(filePath, 'utf-8');
870
+ const lines = content.trim().split('\n');
871
+ if (lines.length === 0)
872
+ return null;
873
+ const header = JSON.parse(lines[0]);
874
+ const frames = [];
875
+ for (let i = 1; i < lines.length; i++) {
876
+ const event = JSON.parse(lines[i]);
877
+ if (Array.isArray(event) && event[1] === 'o') {
878
+ frames.push({ time: event[0], data: event[2] });
879
+ }
880
+ }
881
+ return {
882
+ id: basename(filePath, extname(filePath)),
883
+ title: header.title || basename(filePath),
884
+ startedAt: header.timestamp
885
+ ? new Date(header.timestamp * 1000).toISOString()
886
+ : new Date().toISOString(),
887
+ duration: frames.length > 0 ? frames[frames.length - 1].time : 0,
888
+ cols: header.width || 80,
889
+ rows: header.height || 24,
890
+ frames,
891
+ env: {
892
+ shell: header.env?.SHELL || '/bin/bash',
893
+ term: header.env?.TERM || 'xterm-256color',
894
+ platform: platform(),
895
+ },
896
+ };
897
+ }
898
+ catch {
899
+ return null;
900
+ }
901
+ }
902
+ /**
903
+ * List all saved recordings.
904
+ */
905
+ export function listRecordings() {
906
+ ensureDir();
907
+ const files = readdirSync(RECORDINGS_DIR)
908
+ .filter(f => f.endsWith('.json') && !f.startsWith('.'))
909
+ .sort()
910
+ .reverse();
911
+ const recordings = [];
912
+ for (const file of files) {
913
+ try {
914
+ const filePath = join(RECORDINGS_DIR, file);
915
+ const stat = statSync(filePath);
916
+ const data = JSON.parse(readFileSync(filePath, 'utf-8'));
917
+ recordings.push({
918
+ id: data.id,
919
+ title: data.title,
920
+ date: new Date(data.startedAt).toLocaleDateString(),
921
+ duration: data.duration,
922
+ frames: data.frames.length,
923
+ size: formatSize(stat.size),
924
+ });
925
+ }
926
+ catch {
927
+ // Skip corrupt files
928
+ }
929
+ }
930
+ return recordings;
931
+ }
932
+ /**
933
+ * Delete a recording by ID.
934
+ */
935
+ export function deleteRecording(id) {
936
+ ensureDir();
937
+ const jsonPath = join(RECORDINGS_DIR, `${id}.json`);
938
+ if (existsSync(jsonPath)) {
939
+ unlinkSync(jsonPath);
940
+ return true;
941
+ }
942
+ return false;
943
+ }
944
+ /** Format byte size to human-readable */
945
+ function formatSize(bytes) {
946
+ if (bytes < 1024)
947
+ return `${bytes}B`;
948
+ if (bytes < 1024 * 1024)
949
+ return `${(bytes / 1024).toFixed(1)}KB`;
950
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
951
+ }
952
+ // ── Terminal Replay ──
953
+ /**
954
+ * Replay a recording in the terminal with original timing.
955
+ *
956
+ * @param recording The recording to play back
957
+ * @param speed Playback speed multiplier (2 = double speed)
958
+ * @returns A promise that resolves when playback is complete,
959
+ * and an abort function to stop early.
960
+ */
961
+ export function replayInTerminal(recording, speed = 1) {
962
+ let aborted = false;
963
+ let currentTimeout = null;
964
+ const abort = () => {
965
+ aborted = true;
966
+ if (currentTimeout)
967
+ clearTimeout(currentTimeout);
968
+ };
969
+ const promise = new Promise((resolve) => {
970
+ if (recording.frames.length === 0) {
971
+ resolve();
972
+ return;
973
+ }
974
+ // Clear screen
975
+ process.stdout.write('\x1b[2J\x1b[H');
976
+ let frameIdx = 0;
977
+ function playNextFrame() {
978
+ if (aborted || frameIdx >= recording.frames.length) {
979
+ process.stdout.write('\n');
980
+ resolve();
981
+ return;
982
+ }
983
+ const frame = recording.frames[frameIdx];
984
+ process.stdout.write(frame.data);
985
+ frameIdx++;
986
+ if (frameIdx < recording.frames.length) {
987
+ const nextFrame = recording.frames[frameIdx];
988
+ const delay = Math.max(1, (nextFrame.time - frame.time) * 1000 / speed);
989
+ currentTimeout = setTimeout(playNextFrame, delay);
990
+ }
991
+ else {
992
+ process.stdout.write('\n');
993
+ resolve();
994
+ }
995
+ }
996
+ // Start with the first frame
997
+ const firstDelay = Math.max(1, recording.frames[0].time * 1000 / speed);
998
+ currentTimeout = setTimeout(playNextFrame, firstDelay);
999
+ });
1000
+ return { promise, abort };
1001
+ }
1002
+ // ── Convert existing recording to different format ──
1003
+ /**
1004
+ * Convert a recording file to a different format.
1005
+ */
1006
+ export function convertRecording(inputPath, outputPath, options = {}) {
1007
+ const recording = loadRecording(inputPath);
1008
+ if (!recording) {
1009
+ throw new Error(`Cannot load recording: ${inputPath}`);
1010
+ }
1011
+ const ext = extname(outputPath).toLowerCase();
1012
+ switch (ext) {
1013
+ case '.svg':
1014
+ writeFileSync(outputPath, toSVG(recording, options), 'utf-8');
1015
+ return outputPath;
1016
+ case '.gif':
1017
+ return toGIF(recording, outputPath, options);
1018
+ case '.cast':
1019
+ writeFileSync(outputPath, toAsciicast(recording), 'utf-8');
1020
+ return outputPath;
1021
+ case '.json':
1022
+ writeFileSync(outputPath, JSON.stringify(recording, null, 2), 'utf-8');
1023
+ return outputPath;
1024
+ default:
1025
+ throw new Error(`Unsupported output format: ${ext}`);
1026
+ }
1027
+ }
1028
+ // ── CLI Integration ──
1029
+ /**
1030
+ * Register the `kbot record` command group with the CLI.
1031
+ *
1032
+ * Subcommands:
1033
+ * kbot record start [--output <file>] [--title <title>]
1034
+ * kbot record stop
1035
+ * kbot record list
1036
+ * kbot record replay <file> [--speed <multiplier>]
1037
+ * kbot record convert <input> <output> [--theme <theme>]
1038
+ * kbot record delete <id>
1039
+ */
1040
+ export function registerRecordCommand(program) {
1041
+ const recordCmd = program
1042
+ .command('record')
1043
+ .description('Record terminal sessions as SVG, GIF, or asciicast for demos');
1044
+ // ── start ──
1045
+ recordCmd
1046
+ .command('start')
1047
+ .description('Start recording the terminal session')
1048
+ .option('-o, --output <file>', 'Output file (extension sets format: .svg, .gif, .cast, .json)')
1049
+ .option('-t, --title <title>', 'Recording title')
1050
+ .option('--cols <cols>', 'Terminal columns', String(process.stdout.columns || 80))
1051
+ .option('--rows <rows>', 'Terminal rows', String(process.stdout.rows || 24))
1052
+ .option('--shell <shell>', 'Shell to use')
1053
+ .action((opts) => {
1054
+ const result = startRecording({
1055
+ output: opts.output,
1056
+ title: opts.title,
1057
+ cols: opts.cols ? parseInt(opts.cols, 10) : undefined,
1058
+ rows: opts.rows ? parseInt(opts.rows, 10) : undefined,
1059
+ shell: opts.shell,
1060
+ });
1061
+ if (result.success) {
1062
+ console.log(`\x1b[32m \u2713 ${result.message}\x1b[0m`);
1063
+ }
1064
+ else {
1065
+ console.error(`\x1b[31m \u2717 ${result.message}\x1b[0m`);
1066
+ process.exit(1);
1067
+ }
1068
+ });
1069
+ // ── stop ──
1070
+ recordCmd
1071
+ .command('stop')
1072
+ .description('Stop the active recording and save it')
1073
+ .action(() => {
1074
+ const result = stopRecording();
1075
+ if (result.success) {
1076
+ console.log(`\x1b[32m \u2713 ${result.message}\x1b[0m`);
1077
+ if (result.outputPath) {
1078
+ console.log(` Output: ${result.outputPath}`);
1079
+ }
1080
+ }
1081
+ else {
1082
+ console.error(`\x1b[31m \u2717 ${result.message}\x1b[0m`);
1083
+ process.exit(1);
1084
+ }
1085
+ });
1086
+ // ── list ──
1087
+ recordCmd
1088
+ .command('list')
1089
+ .description('List all saved recordings')
1090
+ .action(() => {
1091
+ const recordings = listRecordings();
1092
+ if (recordings.length === 0) {
1093
+ console.log(' No recordings found. Run `kbot record start` to create one.');
1094
+ return;
1095
+ }
1096
+ console.log();
1097
+ console.log(' \x1b[1mSaved Recordings\x1b[0m');
1098
+ console.log(' \x1b[2m' + '\u2500'.repeat(60) + '\x1b[0m');
1099
+ for (const rec of recordings) {
1100
+ const dur = rec.duration < 60
1101
+ ? `${rec.duration.toFixed(1)}s`
1102
+ : `${Math.floor(rec.duration / 60)}m${Math.round(rec.duration % 60)}s`;
1103
+ console.log(` \x1b[36m${rec.id}\x1b[0m ${rec.title}`);
1104
+ console.log(` \x1b[2m${rec.date} | ${dur} | ${rec.frames} frames | ${rec.size}\x1b[0m`);
1105
+ console.log();
1106
+ }
1107
+ });
1108
+ // ── replay ──
1109
+ recordCmd
1110
+ .command('replay <file>')
1111
+ .description('Replay a recording in the terminal')
1112
+ .option('-s, --speed <speed>', 'Playback speed multiplier', '1')
1113
+ .action(async (file, opts) => {
1114
+ const recording = loadRecording(file);
1115
+ if (!recording) {
1116
+ console.error(`\x1b[31m \u2717 Cannot load recording: ${file}\x1b[0m`);
1117
+ process.exit(1);
1118
+ }
1119
+ const speed = opts.speed ? parseFloat(opts.speed) : 1;
1120
+ const dur = recording.duration < 60
1121
+ ? `${recording.duration.toFixed(1)}s`
1122
+ : `${Math.floor(recording.duration / 60)}m${Math.round(recording.duration % 60)}s`;
1123
+ console.log(`\x1b[2m Replaying: ${recording.title} (${dur} at ${speed}x)\x1b[0m`);
1124
+ console.log(`\x1b[2m Press Ctrl-C to stop.\x1b[0m`);
1125
+ console.log();
1126
+ const { promise, abort } = replayInTerminal(recording, speed);
1127
+ // Handle Ctrl-C gracefully
1128
+ const handler = () => {
1129
+ abort();
1130
+ process.stdout.write('\x1b[0m\n');
1131
+ console.log('\x1b[2m Playback stopped.\x1b[0m');
1132
+ process.removeListener('SIGINT', handler);
1133
+ };
1134
+ process.on('SIGINT', handler);
1135
+ await promise;
1136
+ process.removeListener('SIGINT', handler);
1137
+ console.log('\x1b[2m Playback complete.\x1b[0m');
1138
+ });
1139
+ // ── convert ──
1140
+ recordCmd
1141
+ .command('convert <input> <output>')
1142
+ .description('Convert a recording to a different format')
1143
+ .option('--theme <theme>', 'SVG/GIF theme: dark, light, monokai, dracula', 'dark')
1144
+ .option('--speed <speed>', 'Playback speed multiplier', '1')
1145
+ .option('--fps <fps>', 'GIF frames per second', '10')
1146
+ .option('--font-size <size>', 'Font size in pixels', '14')
1147
+ .action((input, output, opts) => {
1148
+ try {
1149
+ const result = convertRecording(input, output, {
1150
+ theme: (opts.theme || 'dark'),
1151
+ speed: opts.speed ? parseFloat(opts.speed) : 1,
1152
+ fps: opts.fps ? parseInt(opts.fps, 10) : 10,
1153
+ fontSize: opts.fontSize ? parseInt(opts.fontSize, 10) : 14,
1154
+ });
1155
+ if (result) {
1156
+ console.log(`\x1b[32m \u2713 Converted: ${result}\x1b[0m`);
1157
+ }
1158
+ else {
1159
+ console.error('\x1b[31m \u2717 Conversion failed.\x1b[0m');
1160
+ process.exit(1);
1161
+ }
1162
+ }
1163
+ catch (err) {
1164
+ console.error(`\x1b[31m \u2717 ${err instanceof Error ? err.message : String(err)}\x1b[0m`);
1165
+ process.exit(1);
1166
+ }
1167
+ });
1168
+ // ── delete ──
1169
+ recordCmd
1170
+ .command('delete <id>')
1171
+ .description('Delete a saved recording')
1172
+ .action((id) => {
1173
+ if (deleteRecording(id)) {
1174
+ console.log(`\x1b[32m \u2713 Deleted recording: ${id}\x1b[0m`);
1175
+ }
1176
+ else {
1177
+ console.error(`\x1b[31m \u2717 Recording not found: ${id}\x1b[0m`);
1178
+ process.exit(1);
1179
+ }
1180
+ });
1181
+ }
1182
+ //# sourceMappingURL=record.js.map