asciinema_win 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,591 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciinemaWin
4
+ # Command-line interface for asciinema-win
5
+ #
6
+ # Provides commands for recording, playing back, and inspecting
7
+ # terminal recordings. Uses only Ruby standard library for argument
8
+ # parsing (no external gems).
9
+ #
10
+ # @example Run the CLI
11
+ # AsciinemaWin::CLI.run(ARGV)
12
+ class CLI
13
+ # Available commands
14
+ COMMANDS = %w[rec play cat info export help version].freeze
15
+
16
+ # ANSI color codes for output
17
+ module Colors
18
+ RESET = "\e[0m"
19
+ BOLD = "\e[1m"
20
+ RED = "\e[31m"
21
+ GREEN = "\e[32m"
22
+ YELLOW = "\e[33m"
23
+ BLUE = "\e[34m"
24
+ CYAN = "\e[36m"
25
+ end
26
+
27
+ # Run the CLI with the given arguments
28
+ #
29
+ # @param args [Array<String>] Command-line arguments
30
+ # @return [Integer] Exit code
31
+ def self.run(args)
32
+ new.run(args)
33
+ end
34
+
35
+ # Run the CLI
36
+ #
37
+ # @param args [Array<String>] Command-line arguments
38
+ # @return [Integer] Exit code
39
+ def run(args)
40
+ if args.empty?
41
+ print_usage
42
+ return 0
43
+ end
44
+
45
+ command = args.shift
46
+
47
+ case command
48
+ when "rec", "record"
49
+ cmd_rec(args)
50
+ when "play"
51
+ cmd_play(args)
52
+ when "cat"
53
+ cmd_cat(args)
54
+ when "info"
55
+ cmd_info(args)
56
+ when "export"
57
+ cmd_export(args)
58
+ when "help", "-h", "--help"
59
+ cmd_help(args)
60
+ when "version", "-v", "--version"
61
+ cmd_version
62
+ else
63
+ error("Unknown command: #{command}")
64
+ print_usage
65
+ 1
66
+ end
67
+ rescue StandardError => e
68
+ error(e.message)
69
+ error(e.backtrace.first(5).join("\n")) if ENV["DEBUG"]
70
+ 1
71
+ end
72
+
73
+ private
74
+
75
+ # =========================================================================
76
+ # Commands
77
+ # =========================================================================
78
+
79
+ # Record command
80
+ #
81
+ # @param args [Array<String>] Command arguments
82
+ # @return [Integer] Exit code
83
+ def cmd_rec(args)
84
+ options = parse_options(args, {
85
+ "t" => :title,
86
+ "title" => :title,
87
+ "c" => :command,
88
+ "command" => :command,
89
+ "i" => :idle_time_limit,
90
+ "idle-time-limit" => :idle_time_limit,
91
+ "y" => :overwrite,
92
+ "overwrite" => :overwrite
93
+ })
94
+
95
+ output_path = args.shift
96
+
97
+ unless output_path
98
+ error("Missing output file path")
99
+ puts "Usage: asciinema_win rec [options] <filename>"
100
+ return 1
101
+ end
102
+
103
+ # Check if file exists and overwrite not set
104
+ if File.exist?(output_path) && !options[:overwrite]
105
+ error("File already exists: #{output_path}")
106
+ puts "Use -y or --overwrite to overwrite"
107
+ return 1
108
+ end
109
+
110
+ # Create recorder
111
+ recorder = Recorder.new(
112
+ title: options[:title],
113
+ command: options[:command],
114
+ idle_time_limit: options[:idle_time_limit]&.to_f || Recorder::DEFAULT_IDLE_TIME_LIMIT
115
+ )
116
+
117
+ # Start recording
118
+ stats = recorder.record(output_path)
119
+
120
+ success("Recording saved to #{output_path}")
121
+ puts "Duration: #{format("%.2f", stats[:duration])}s"
122
+ puts "Events: #{stats[:event_count]}"
123
+
124
+ 0
125
+ end
126
+
127
+ # Play command
128
+ #
129
+ # @param args [Array<String>] Command arguments
130
+ # @return [Integer] Exit code
131
+ def cmd_play(args)
132
+ options = parse_options(args, {
133
+ "s" => :speed,
134
+ "speed" => :speed,
135
+ "i" => :idle_time_limit,
136
+ "idle-time-limit" => :idle_time_limit,
137
+ "m" => :pause_on_markers,
138
+ "pause-on-markers" => :pause_on_markers
139
+ })
140
+
141
+ input_path = args.shift
142
+
143
+ unless input_path
144
+ error("Missing input file path")
145
+ puts "Usage: asciinema_win play [options] <filename>"
146
+ return 1
147
+ end
148
+
149
+ unless File.exist?(input_path)
150
+ error("File not found: #{input_path}")
151
+ return 1
152
+ end
153
+
154
+ # Create player
155
+ player = Player.new(
156
+ speed: options[:speed]&.to_f || 1.0,
157
+ idle_time_limit: options[:idle_time_limit]&.to_f,
158
+ pause_on_markers: !!options[:pause_on_markers]
159
+ )
160
+
161
+ # Start playback
162
+ player.play(input_path)
163
+
164
+ 0
165
+ end
166
+
167
+ # Cat command (output without timing)
168
+ #
169
+ # @param args [Array<String>] Command arguments
170
+ # @return [Integer] Exit code
171
+ def cmd_cat(args)
172
+ input_path = args.shift
173
+
174
+ unless input_path
175
+ error("Missing input file path")
176
+ puts "Usage: asciinema_win cat <filename>"
177
+ return 1
178
+ end
179
+
180
+ unless File.exist?(input_path)
181
+ error("File not found: #{input_path}")
182
+ return 1
183
+ end
184
+
185
+ # Use raw player for immediate output
186
+ player = RawPlayer.new
187
+ player.play(input_path)
188
+
189
+ 0
190
+ end
191
+
192
+ # Info command
193
+ #
194
+ # @param args [Array<String>] Command arguments
195
+ # @return [Integer] Exit code
196
+ def cmd_info(args)
197
+ input_path = args.shift
198
+
199
+ unless input_path
200
+ error("Missing input file path")
201
+ puts "Usage: asciinema_win info <filename>"
202
+ return 1
203
+ end
204
+
205
+ unless File.exist?(input_path)
206
+ error("File not found: #{input_path}")
207
+ return 1
208
+ end
209
+
210
+ # Get info
211
+ info_data = Asciicast::Reader.info(input_path)
212
+
213
+ # Print info
214
+ puts "#{Colors::BOLD}File:#{Colors::RESET} #{input_path}"
215
+ puts "#{Colors::BOLD}Version:#{Colors::RESET} #{info_data[:version]}"
216
+ puts "#{Colors::BOLD}Size:#{Colors::RESET} #{info_data[:width]}x#{info_data[:height]}"
217
+ puts "#{Colors::BOLD}Duration:#{Colors::RESET} #{format("%.2f", info_data[:duration])}s"
218
+ puts "#{Colors::BOLD}Events:#{Colors::RESET} #{info_data[:event_count]}"
219
+
220
+ if info_data[:title]
221
+ puts "#{Colors::BOLD}Title:#{Colors::RESET} #{info_data[:title]}"
222
+ end
223
+
224
+ if info_data[:command]
225
+ puts "#{Colors::BOLD}Command:#{Colors::RESET} #{info_data[:command]}"
226
+ end
227
+
228
+ if info_data[:timestamp]
229
+ time = Time.at(info_data[:timestamp])
230
+ puts "#{Colors::BOLD}Recorded:#{Colors::RESET} #{time.strftime("%Y-%m-%d %H:%M:%S")}"
231
+ end
232
+
233
+ if info_data[:idle_time_limit]
234
+ puts "#{Colors::BOLD}Idle limit:#{Colors::RESET} #{info_data[:idle_time_limit]}s"
235
+ end
236
+
237
+ unless info_data[:env].empty?
238
+ puts "#{Colors::BOLD}Environment:#{Colors::RESET}"
239
+ info_data[:env].each do |key, value|
240
+ puts " #{key}=#{value}"
241
+ end
242
+ end
243
+
244
+ 0
245
+ end
246
+
247
+ # Export command
248
+ #
249
+ # @param args [Array<String>] Command arguments
250
+ # @return [Integer] Exit code
251
+ def cmd_export(args)
252
+ options = parse_options(args, {
253
+ "f" => :format,
254
+ "format" => :format,
255
+ "o" => :output,
256
+ "output" => :output,
257
+ "t" => :title,
258
+ "title" => :title
259
+ })
260
+
261
+ input_path = args.shift
262
+
263
+ unless input_path
264
+ error("Missing input file path")
265
+ puts "Usage: asciinema_win export [options] <input.cast> [-o output]"
266
+ return 1
267
+ end
268
+
269
+ unless File.exist?(input_path)
270
+ error("File not found: #{input_path}")
271
+ return 1
272
+ end
273
+
274
+ # Determine format from output extension or option
275
+ output_path = options[:output]
276
+ format = options[:format]&.to_sym
277
+
278
+ unless output_path
279
+ # Default output based on format
280
+ format ||= :html
281
+ ext = format == :text ? ".txt" : ".#{format}"
282
+ output_path = input_path.sub(/\.cast$/, ext)
283
+ end
284
+
285
+ unless format
286
+ # Infer from output extension
287
+ ext = File.extname(output_path).downcase
288
+ format = case ext
289
+ when ".html" then :html
290
+ when ".svg" then :svg
291
+ when ".txt" then :text
292
+ when ".json" then :json
293
+ when ".gif" then :gif
294
+ when ".mp4" then :mp4
295
+ when ".webm" then :webm
296
+ else :html
297
+ end
298
+ end
299
+
300
+ puts "Exporting #{input_path} to #{output_path} (#{format})..."
301
+
302
+ Export.export(input_path, output_path, format: format, title: options[:title])
303
+
304
+ success("Exported to #{output_path}")
305
+ 0
306
+ rescue ExportError => e
307
+ error(e.message)
308
+ 1
309
+ end
310
+
311
+ # Help command
312
+ #
313
+ # @param args [Array<String>] Command arguments
314
+ # @return [Integer] Exit code
315
+ def cmd_help(args)
316
+ command = args.shift
317
+
318
+ case command
319
+ when "rec", "record"
320
+ print_rec_help
321
+ when "play"
322
+ print_play_help
323
+ when "cat"
324
+ print_cat_help
325
+ when "info"
326
+ print_info_help
327
+ when "export"
328
+ print_export_help
329
+ else
330
+ print_usage
331
+ end
332
+
333
+ 0
334
+ end
335
+
336
+ # Version command
337
+ #
338
+ # @return [Integer] Exit code
339
+ def cmd_version
340
+ puts "asciinema-win #{VERSION}"
341
+ puts "Ruby #{RUBY_VERSION} (#{RUBY_PLATFORM})"
342
+ 0
343
+ end
344
+
345
+ # =========================================================================
346
+ # Option Parsing
347
+ # =========================================================================
348
+
349
+ # Parse command-line options
350
+ #
351
+ # @param args [Array<String>] Arguments (modified in place)
352
+ # @param valid_options [Hash] Map of option names to symbols
353
+ # @return [Hash] Parsed options
354
+ def parse_options(args, valid_options)
355
+ options = {}
356
+ remaining = []
357
+
358
+ i = 0
359
+ while i < args.length
360
+ arg = args[i]
361
+
362
+ if arg.start_with?("--")
363
+ # Long option
364
+ key = arg[2..]
365
+ if key.include?("=")
366
+ key, value = key.split("=", 2)
367
+ else
368
+ value = nil
369
+ end
370
+
371
+ sym = valid_options[key]
372
+ if sym
373
+ if value.nil? && i + 1 < args.length && !args[i + 1].start_with?("-")
374
+ i += 1
375
+ value = args[i]
376
+ end
377
+ options[sym] = value || true
378
+ else
379
+ remaining << arg
380
+ end
381
+ elsif arg.start_with?("-") && arg.length > 1
382
+ # Short option
383
+ key = arg[1]
384
+ value = arg.length > 2 ? arg[2..] : nil
385
+
386
+ sym = valid_options[key]
387
+ if sym
388
+ if value.nil? && i + 1 < args.length && !args[i + 1].start_with?("-")
389
+ i += 1
390
+ value = args[i]
391
+ end
392
+ options[sym] = value || true
393
+ else
394
+ remaining << arg
395
+ end
396
+ else
397
+ remaining << arg
398
+ end
399
+
400
+ i += 1
401
+ end
402
+
403
+ args.replace(remaining)
404
+ options
405
+ end
406
+
407
+ # =========================================================================
408
+ # Help Text
409
+ # =========================================================================
410
+
411
+ # Print main usage
412
+ def print_usage
413
+ puts <<~USAGE
414
+ #{Colors::BOLD}asciinema-win#{Colors::RESET} - Native Windows terminal recorder
415
+
416
+ #{Colors::BOLD}USAGE:#{Colors::RESET}
417
+ asciinema_win <command> [options]
418
+
419
+ #{Colors::BOLD}COMMANDS:#{Colors::RESET}
420
+ rec Record terminal session
421
+ play Play back a recording
422
+ cat Output recording to stdout (no timing)
423
+ info Show recording metadata
424
+ export Export to HTML, SVG, text, or video
425
+ help Show help for a command
426
+ version Show version information
427
+
428
+ #{Colors::BOLD}EXAMPLES:#{Colors::RESET}
429
+ asciinema_win rec session.cast
430
+ asciinema_win rec -c "dir /s" output.cast
431
+ asciinema_win play session.cast
432
+ asciinema_win play -s 2 session.cast
433
+ asciinema_win info session.cast
434
+
435
+ #{Colors::BOLD}OPTIONS:#{Colors::RESET}
436
+ -h, --help Show help
437
+ -v, --version Show version
438
+
439
+ Run 'asciinema_win help <command>' for command-specific help.
440
+ USAGE
441
+ end
442
+
443
+ # Print rec command help
444
+ def print_rec_help
445
+ puts <<~HELP
446
+ #{Colors::BOLD}asciinema_win rec#{Colors::RESET} - Record terminal session
447
+
448
+ #{Colors::BOLD}USAGE:#{Colors::RESET}
449
+ asciinema_win rec [options] <filename>
450
+
451
+ #{Colors::BOLD}DESCRIPTION:#{Colors::RESET}
452
+ Record terminal session and save it to a file. Press Ctrl+D to stop
453
+ recording. The recording is saved in asciicast v2 format, compatible
454
+ with asciinema.org.
455
+
456
+ #{Colors::BOLD}OPTIONS:#{Colors::RESET}
457
+ -t, --title <title> Recording title
458
+ -c, --command <cmd> Record specific command instead of interactive session
459
+ -i, --idle-time-limit Maximum idle time between events (default: 2.0s)
460
+ -y, --overwrite Overwrite existing file
461
+
462
+ #{Colors::BOLD}EXAMPLES:#{Colors::RESET}
463
+ asciinema_win rec demo.cast
464
+ asciinema_win rec -t "My Demo" demo.cast
465
+ asciinema_win rec -c "ping localhost" network.cast
466
+ asciinema_win rec -i 1.0 fast.cast
467
+ HELP
468
+ end
469
+
470
+ # Print play command help
471
+ def print_play_help
472
+ puts <<~HELP
473
+ #{Colors::BOLD}asciinema_win play#{Colors::RESET} - Play back a recording
474
+
475
+ #{Colors::BOLD}USAGE:#{Colors::RESET}
476
+ asciinema_win play [options] <filename>
477
+
478
+ #{Colors::BOLD}DESCRIPTION:#{Colors::RESET}
479
+ Play back a terminal recording with accurate timing. Press Ctrl+C
480
+ to stop playback.
481
+
482
+ #{Colors::BOLD}OPTIONS:#{Colors::RESET}
483
+ -s, --speed <factor> Playback speed (default: 1.0)
484
+ -i, --idle-time-limit <s> Max idle time between frames
485
+ -m, --pause-on-markers Pause at marker events
486
+
487
+ #{Colors::BOLD}EXAMPLES:#{Colors::RESET}
488
+ asciinema_win play demo.cast
489
+ asciinema_win play -s 2 demo.cast # 2x speed
490
+ asciinema_win play -s 0.5 demo.cast # Half speed
491
+ asciinema_win play -i 0.5 demo.cast # Max 0.5s idle
492
+ HELP
493
+ end
494
+
495
+ # Print cat command help
496
+ def print_cat_help
497
+ puts <<~HELP
498
+ #{Colors::BOLD}asciinema_win cat#{Colors::RESET} - Output recording to stdout
499
+
500
+ #{Colors::BOLD}USAGE:#{Colors::RESET}
501
+ asciinema_win cat <filename>
502
+
503
+ #{Colors::BOLD}DESCRIPTION:#{Colors::RESET}
504
+ Output the recording to stdout without timing. Useful for piping
505
+ to other tools or capturing the final output.
506
+
507
+ #{Colors::BOLD}EXAMPLES:#{Colors::RESET}
508
+ asciinema_win cat demo.cast
509
+ asciinema_win cat demo.cast | more
510
+ asciinema_win cat demo.cast > output.txt
511
+ HELP
512
+ end
513
+
514
+ # Print info command help
515
+ def print_info_help
516
+ puts <<~HELP
517
+ #{Colors::BOLD}asciinema_win info#{Colors::RESET} - Show recording metadata
518
+
519
+ #{Colors::BOLD}USAGE:#{Colors::RESET}
520
+ asciinema_win info <filename>
521
+
522
+ #{Colors::BOLD}DESCRIPTION:#{Colors::RESET}
523
+ Display metadata about a recording, including duration, dimensions,
524
+ title, and environment variables.
525
+
526
+ #{Colors::BOLD}EXAMPLES:#{Colors::RESET}
527
+ asciinema_win info demo.cast
528
+ HELP
529
+ end
530
+
531
+ # Print export command help
532
+ def print_export_help
533
+ puts <<~HELP
534
+ #{Colors::BOLD}asciinema_win export#{Colors::RESET} - Export recording to other formats
535
+
536
+ #{Colors::BOLD}USAGE:#{Colors::RESET}
537
+ asciinema_win export [options] <filename>
538
+
539
+ #{Colors::BOLD}DESCRIPTION:#{Colors::RESET}
540
+ Export a recording to different formats. Native formats (cast, HTML,
541
+ SVG, text, JSON) require no external dependencies. Video formats
542
+ (GIF, MP4, WebM) require FFmpeg to be installed.
543
+
544
+ #{Colors::BOLD}FORMATS:#{Colors::RESET}
545
+ cast asciicast v2 (copy or transform)
546
+ html Standalone HTML with embedded asciinema-player
547
+ svg SVG image (static snapshot)
548
+ txt Plain text (ANSI codes stripped)
549
+ json Normalized JSON format
550
+ gif Animated GIF (requires FFmpeg)
551
+ mp4 MP4 video (requires FFmpeg)
552
+ webm WebM video (requires FFmpeg)
553
+
554
+ #{Colors::BOLD}OPTIONS:#{Colors::RESET}
555
+ -f, --format <fmt> Output format (default: inferred from extension)
556
+ -o, --output <file> Output file path
557
+ -t, --title <title> Title for HTML export or cast transform
558
+
559
+ #{Colors::BOLD}EXAMPLES:#{Colors::RESET}
560
+ asciinema_win export demo.cast -o demo.html
561
+ asciinema_win export demo.cast -f svg -o preview.svg
562
+ asciinema_win export demo.cast -f cast -t "New Title" -o renamed.cast
563
+ HELP
564
+ end
565
+
566
+ # =========================================================================
567
+ # Output Helpers
568
+ # =========================================================================
569
+
570
+ # Print error message
571
+ #
572
+ # @param message [String] Error message
573
+ def error(message)
574
+ $stderr.puts "#{Colors::RED}Error:#{Colors::RESET} #{message}"
575
+ end
576
+
577
+ # Print success message
578
+ #
579
+ # @param message [String] Success message
580
+ def success(message)
581
+ puts "#{Colors::GREEN}#{message}#{Colors::RESET}"
582
+ end
583
+
584
+ # Print warning message
585
+ #
586
+ # @param message [String] Warning message
587
+ def warning(message)
588
+ puts "#{Colors::YELLOW}Warning:#{Colors::RESET} #{message}"
589
+ end
590
+ end
591
+ end