dbxdebug 0.2.1__py3-none-any.whl

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.
dbxdebug/cli.py ADDED
@@ -0,0 +1,618 @@
1
+ """
2
+ Command-line interface for DOSBox-X remote debugging.
3
+
4
+ Commands:
5
+ dbxdebug mem <command> - Memory operations (via GDB)
6
+ dbxdebug cpu <command> - CPU state and execution control (via GDB)
7
+ dbxdebug key <command> - Keyboard input (via QMP)
8
+ dbxdebug screen <command> - Screen capture and recording
9
+ """
10
+
11
+ import sys
12
+ import time
13
+
14
+ import click
15
+ from loguru import logger
16
+
17
+ from .capture_io import ScreenRecorder, load_capture
18
+ from .gdb import GDBClient
19
+ from .html import analyze_dos_video_colors, dos_video_to_html
20
+ from .qmp import QMPClient, QMPError
21
+ from .utils import hexdump, parse_x86_address
22
+ from .video import DOSVideoTools
23
+
24
+ # Configure loguru
25
+ logger.remove()
26
+ logger.add(sys.stderr, level="WARNING")
27
+
28
+
29
+ @click.group()
30
+ @click.option("-v", "--verbose", is_flag=True, help="Enable verbose output")
31
+ @click.option("--debug", is_flag=True, help="Enable debug logging")
32
+ @click.version_option(version="0.1.0")
33
+ def main(verbose: bool, debug: bool):
34
+ """DOSBox-X remote debug client.
35
+
36
+ Connects to DOSBox-X GDB server (port 2159) for debugging and memory access,
37
+ and QMP server (port 4444) for keyboard input.
38
+ """
39
+ if debug:
40
+ logger.remove()
41
+ logger.add(sys.stderr, level="DEBUG")
42
+ elif verbose:
43
+ logger.remove()
44
+ logger.add(sys.stderr, level="INFO")
45
+
46
+
47
+ # =============================================================================
48
+ # Memory Commands (GDB)
49
+ # =============================================================================
50
+
51
+
52
+ @main.group()
53
+ @click.option("--host", default="localhost", help="GDB server hostname")
54
+ @click.option("--port", default=2159, help="GDB server port (default: 2159)")
55
+ @click.pass_context
56
+ def mem(ctx, host: str, port: int):
57
+ """Memory read/write operations via GDB protocol."""
58
+ ctx.ensure_object(dict)
59
+ ctx.obj["gdb_host"] = host
60
+ ctx.obj["gdb_port"] = port
61
+
62
+
63
+ @mem.command("read")
64
+ @click.argument("address", metavar="ADDRESS")
65
+ @click.argument("length", type=int, metavar="LENGTH")
66
+ @click.option("--hex", "output_hex", is_flag=True, help="Format output as hex dump")
67
+ @click.pass_context
68
+ def mem_read(ctx, address: str, length: int, output_hex: bool):
69
+ """Read LENGTH bytes from ADDRESS.
70
+
71
+ ADDRESS can be segment:offset (e.g., b800:0000) or linear (e.g., 0xb8000).
72
+
73
+ Examples:
74
+ dbxdebug mem read b800:0000 4000 --hex
75
+ dbxdebug mem read 0x1000 256
76
+ """
77
+ try:
78
+ with GDBClient(ctx.obj["gdb_host"], ctx.obj["gdb_port"]) as gdb:
79
+ data = gdb.read_memory(address, length)
80
+ if output_hex:
81
+ linear = parse_x86_address(address)
82
+ for line in hexdump(data, start_addr=linear):
83
+ click.echo(line)
84
+ else:
85
+ sys.stdout.buffer.write(data)
86
+ except Exception as e:
87
+ click.echo(f"Error: {e}", err=True)
88
+ sys.exit(1)
89
+
90
+
91
+ @mem.command("write")
92
+ @click.argument("address", metavar="ADDRESS")
93
+ @click.argument("hexdata", metavar="HEXDATA")
94
+ @click.pass_context
95
+ def mem_write(ctx, address: str, hexdata: str):
96
+ """Write HEXDATA bytes to ADDRESS.
97
+
98
+ HEXDATA is a hex string without spaces (e.g., 'deadbeef').
99
+
100
+ Examples:
101
+ dbxdebug mem write 0x1000 90909090
102
+ dbxdebug mem write b800:0000 4142
103
+ """
104
+ try:
105
+ with GDBClient(ctx.obj["gdb_host"], ctx.obj["gdb_port"]) as gdb:
106
+ data = bytes.fromhex(hexdata)
107
+ gdb.write_memory(address, data)
108
+ click.echo(f"Wrote {len(data)} bytes to {address}")
109
+ except Exception as e:
110
+ click.echo(f"Error: {e}", err=True)
111
+ sys.exit(1)
112
+
113
+
114
+ # =============================================================================
115
+ # CPU Commands (GDB)
116
+ # =============================================================================
117
+
118
+
119
+ @main.group()
120
+ @click.option("--host", default="localhost", help="GDB server hostname")
121
+ @click.option("--port", default=2159, help="GDB server port (default: 2159)")
122
+ @click.pass_context
123
+ def cpu(ctx, host: str, port: int):
124
+ """CPU registers and execution control via GDB protocol."""
125
+ ctx.ensure_object(dict)
126
+ ctx.obj["gdb_host"] = host
127
+ ctx.obj["gdb_port"] = port
128
+
129
+
130
+ @cpu.command("regs")
131
+ @click.pass_context
132
+ def cpu_regs(ctx):
133
+ """Display all CPU registers."""
134
+ try:
135
+ with GDBClient(ctx.obj["gdb_host"], ctx.obj["gdb_port"]) as gdb:
136
+ regs = gdb.read_registers()
137
+ click.echo("General Purpose:")
138
+ click.echo(f" EAX={regs['eax']:08X} ECX={regs['ecx']:08X}")
139
+ click.echo(f" EDX={regs['edx']:08X} EBX={regs['ebx']:08X}")
140
+ click.echo(f" ESP={regs['esp']:08X} EBP={regs['ebp']:08X}")
141
+ click.echo(f" ESI={regs['esi']:08X} EDI={regs['edi']:08X}")
142
+ click.echo()
143
+ click.echo("Instruction Pointer:")
144
+ click.echo(f" EIP={regs['eip']:08X} EFLAGS={regs['eflags']:08X}")
145
+ click.echo()
146
+ click.echo("Segment Registers:")
147
+ click.echo(f" CS={regs['cs']:04X} SS={regs['ss']:04X} DS={regs['ds']:04X}")
148
+ click.echo(f" ES={regs['es']:04X} FS={regs['fs']:04X} GS={regs['gs']:04X}")
149
+ except Exception as e:
150
+ click.echo(f"Error: {e}", err=True)
151
+ sys.exit(1)
152
+
153
+
154
+ @cpu.command("break")
155
+ @click.argument("address", metavar="ADDRESS")
156
+ @click.pass_context
157
+ def cpu_break(ctx, address: str):
158
+ """Set software breakpoint at ADDRESS.
159
+
160
+ Example:
161
+ dbxdebug cpu break 0x1000
162
+ """
163
+ try:
164
+ with GDBClient(ctx.obj["gdb_host"], ctx.obj["gdb_port"]) as gdb:
165
+ if gdb.set_breakpoint(address):
166
+ linear = parse_x86_address(address)
167
+ click.echo(f"Breakpoint set at 0x{linear:X}")
168
+ else:
169
+ click.echo(f"Failed to set breakpoint at {address}", err=True)
170
+ sys.exit(1)
171
+ except Exception as e:
172
+ click.echo(f"Error: {e}", err=True)
173
+ sys.exit(1)
174
+
175
+
176
+ @cpu.command("delete")
177
+ @click.argument("address", metavar="ADDRESS")
178
+ @click.pass_context
179
+ def cpu_delete(ctx, address: str):
180
+ """Remove breakpoint at ADDRESS.
181
+
182
+ Example:
183
+ dbxdebug cpu delete 0x1000
184
+ """
185
+ try:
186
+ with GDBClient(ctx.obj["gdb_host"], ctx.obj["gdb_port"]) as gdb:
187
+ if gdb.remove_breakpoint(address):
188
+ linear = parse_x86_address(address)
189
+ click.echo(f"Breakpoint removed at 0x{linear:X}")
190
+ else:
191
+ click.echo(f"No breakpoint at {address}", err=True)
192
+ sys.exit(1)
193
+ except Exception as e:
194
+ click.echo(f"Error: {e}", err=True)
195
+ sys.exit(1)
196
+
197
+
198
+ @cpu.command("step")
199
+ @click.pass_context
200
+ def cpu_step(ctx):
201
+ """Execute single instruction and stop."""
202
+ try:
203
+ with GDBClient(ctx.obj["gdb_host"], ctx.obj["gdb_port"]) as gdb:
204
+ response = gdb.step()
205
+ click.echo(f"Stopped: {response.decode()}")
206
+ except Exception as e:
207
+ click.echo(f"Error: {e}", err=True)
208
+ sys.exit(1)
209
+
210
+
211
+ @cpu.command("cont")
212
+ @click.pass_context
213
+ def cpu_cont(ctx):
214
+ """Continue execution until breakpoint or Ctrl+C."""
215
+ try:
216
+ with GDBClient(ctx.obj["gdb_host"], ctx.obj["gdb_port"]) as gdb:
217
+ click.echo("Continuing... (Ctrl+C to interrupt)")
218
+ response = gdb.continue_execution()
219
+ click.echo(f"Stopped: {response.decode()}")
220
+ except KeyboardInterrupt:
221
+ click.echo("\nInterrupted")
222
+ except Exception as e:
223
+ click.echo(f"Error: {e}", err=True)
224
+ sys.exit(1)
225
+
226
+
227
+ @cpu.command("halt")
228
+ @click.pass_context
229
+ def cpu_halt(ctx):
230
+ """Break into debugger (stop execution)."""
231
+ try:
232
+ with GDBClient(ctx.obj["gdb_host"], ctx.obj["gdb_port"]) as gdb:
233
+ response = gdb.halt()
234
+ click.echo(f"Halted: {response.decode()}")
235
+ except Exception as e:
236
+ click.echo(f"Error: {e}", err=True)
237
+ sys.exit(1)
238
+
239
+
240
+ # =============================================================================
241
+ # Keyboard Commands (QMP)
242
+ # =============================================================================
243
+
244
+
245
+ @main.group()
246
+ @click.option("--host", default="localhost", help="QMP server hostname")
247
+ @click.option("--port", default=4444, help="QMP server port (default: 4444)")
248
+ @click.pass_context
249
+ def key(ctx, host: str, port: int):
250
+ """Keyboard input via QMP protocol."""
251
+ ctx.ensure_object(dict)
252
+ ctx.obj["qmp_host"] = host
253
+ ctx.obj["qmp_port"] = port
254
+
255
+
256
+ @key.command("send")
257
+ @click.argument("keys", nargs=-1, required=True, metavar="KEY...")
258
+ @click.option("--hold", default=100, help="Hold time in milliseconds (default: 100)")
259
+ @click.pass_context
260
+ def key_send(ctx, keys: tuple[str, ...], hold: int):
261
+ """Send key chord (simultaneous key presses).
262
+
263
+ KEYS are QMP qcode names. Multiple keys are pressed together.
264
+
265
+ Common qcodes: a-z, 0-9, f1-f12, ctrl, shift, alt, ret (Enter),
266
+ spc (Space), esc, tab, backspace, delete, insert, home, end,
267
+ pgup, pgdn, left, right, up, down.
268
+
269
+ Examples:
270
+ dbxdebug key send a
271
+ dbxdebug key send ctrl c
272
+ dbxdebug key send ctrl alt delete
273
+ """
274
+ try:
275
+ with QMPClient(ctx.obj["qmp_host"], ctx.obj["qmp_port"]) as qmp:
276
+ qmp.send_key(list(keys), hold)
277
+ click.echo(f"Sent: {' + '.join(keys)}")
278
+ except QMPError as e:
279
+ click.echo(f"QMP error: {e}", err=True)
280
+ sys.exit(1)
281
+ except Exception as e:
282
+ click.echo(f"Error: {e}", err=True)
283
+ sys.exit(1)
284
+
285
+
286
+ @key.command("type")
287
+ @click.argument("text", metavar="TEXT")
288
+ @click.option("--delay", default=0.05, help="Delay between keys in seconds (default: 0.05)")
289
+ @click.pass_context
290
+ def key_type(ctx, text: str, delay: float):
291
+ """Type a text string character by character.
292
+
293
+ Automatically handles shift for uppercase letters and symbols.
294
+
295
+ Example:
296
+ dbxdebug key type "Hello World!"
297
+ """
298
+ try:
299
+ with QMPClient(ctx.obj["qmp_host"], ctx.obj["qmp_port"]) as qmp:
300
+ qmp.type_text(text, delay)
301
+ click.echo(f"Typed: {text}")
302
+ except QMPError as e:
303
+ click.echo(f"QMP error: {e}", err=True)
304
+ sys.exit(1)
305
+ except Exception as e:
306
+ click.echo(f"Error: {e}", err=True)
307
+ sys.exit(1)
308
+
309
+
310
+ @key.command("down")
311
+ @click.argument("keycode", metavar="KEY")
312
+ @click.pass_context
313
+ def key_down(ctx, keycode: str):
314
+ """Press and hold a key (no release).
315
+
316
+ Use 'key up' to release. Useful for modifier keys.
317
+
318
+ Example:
319
+ dbxdebug key down shift
320
+ dbxdebug key send a
321
+ dbxdebug key up shift
322
+ """
323
+ try:
324
+ with QMPClient(ctx.obj["qmp_host"], ctx.obj["qmp_port"]) as qmp:
325
+ qmp.key_down(keycode)
326
+ click.echo(f"Key down: {keycode}")
327
+ except QMPError as e:
328
+ click.echo(f"QMP error: {e}", err=True)
329
+ sys.exit(1)
330
+ except Exception as e:
331
+ click.echo(f"Error: {e}", err=True)
332
+ sys.exit(1)
333
+
334
+
335
+ @key.command("up")
336
+ @click.argument("keycode", metavar="KEY")
337
+ @click.pass_context
338
+ def key_up(ctx, keycode: str):
339
+ """Release a held key.
340
+
341
+ Example:
342
+ dbxdebug key up shift
343
+ """
344
+ try:
345
+ with QMPClient(ctx.obj["qmp_host"], ctx.obj["qmp_port"]) as qmp:
346
+ qmp.key_up(keycode)
347
+ click.echo(f"Key up: {keycode}")
348
+ except QMPError as e:
349
+ click.echo(f"QMP error: {e}", err=True)
350
+ sys.exit(1)
351
+ except Exception as e:
352
+ click.echo(f"Error: {e}", err=True)
353
+ sys.exit(1)
354
+
355
+
356
+ @key.command("list")
357
+ @click.pass_context
358
+ def key_list_cmd(ctx):
359
+ """List available QMP commands on server."""
360
+ try:
361
+ with QMPClient(ctx.obj["qmp_host"], ctx.obj["qmp_port"]) as qmp:
362
+ commands = qmp.query_commands()
363
+ click.echo("Available QMP commands:")
364
+ for cmd in sorted(commands):
365
+ click.echo(f" {cmd}")
366
+ except QMPError as e:
367
+ click.echo(f"QMP error: {e}", err=True)
368
+ sys.exit(1)
369
+ except Exception as e:
370
+ click.echo(f"Error: {e}", err=True)
371
+ sys.exit(1)
372
+
373
+
374
+ # =============================================================================
375
+ # Screen Commands (GDB memory access)
376
+ # =============================================================================
377
+
378
+
379
+ @main.group()
380
+ @click.option("--host", default="localhost", help="GDB server hostname")
381
+ @click.option("--port", default=2159, help="GDB server port (default: 2159)")
382
+ @click.pass_context
383
+ def screen(ctx, host: str, port: int):
384
+ """Screen capture and video memory access."""
385
+ ctx.ensure_object(dict)
386
+ ctx.obj["gdb_host"] = host
387
+ ctx.obj["gdb_port"] = port
388
+
389
+
390
+ @screen.command("show")
391
+ @click.pass_context
392
+ def screen_show(ctx):
393
+ """Display current DOS text screen (80x25) to stdout."""
394
+ try:
395
+ with DOSVideoTools(ctx.obj["gdb_host"], ctx.obj["gdb_port"]) as video:
396
+ lines = video.screen_dump()
397
+ if lines:
398
+ for line in lines:
399
+ click.echo(line)
400
+ else:
401
+ click.echo("Failed to read screen", err=True)
402
+ sys.exit(1)
403
+ except Exception as e:
404
+ click.echo(f"Error: {e}", err=True)
405
+ sys.exit(1)
406
+
407
+
408
+ @screen.command("capture")
409
+ @click.option("-o", "--output", default="screen", help="Output filename (without extension)")
410
+ @click.option(
411
+ "-f",
412
+ "--format",
413
+ "fmt",
414
+ type=click.Choice(["raw", "html", "text"]),
415
+ default="raw",
416
+ help="Output format (default: raw)",
417
+ )
418
+ @click.pass_context
419
+ def screen_capture(ctx, output: str, fmt: str):
420
+ """Save single screen frame to file.
421
+
422
+ Formats:
423
+ raw - Binary video memory with attributes (.bin)
424
+ html - Rendered HTML with VGA colors (.html)
425
+ text - Plain text, 80x25 characters (.txt)
426
+
427
+ Examples:
428
+ dbxdebug screen capture -o snapshot
429
+ dbxdebug screen capture -f html -o pretty
430
+ """
431
+ try:
432
+ with DOSVideoTools(ctx.obj["gdb_host"], ctx.obj["gdb_port"]) as video:
433
+ if fmt == "raw":
434
+ data = video.screen_raw()
435
+ if not data:
436
+ click.echo("Failed to read screen", err=True)
437
+ sys.exit(1)
438
+ filename = f"{output}.bin" if not output.endswith(".bin") else output
439
+ with open(filename, "wb") as f:
440
+ f.write(data)
441
+ click.echo(f"Saved raw video memory to {filename}")
442
+
443
+ elif fmt == "html":
444
+ data = video.screen_raw()
445
+ if not data:
446
+ click.echo("Failed to read screen", err=True)
447
+ sys.exit(1)
448
+ filename = f"{output}.html" if not output.endswith(".html") else output
449
+ html = dos_video_to_html(data)
450
+ with open(filename, "w", encoding="utf-8") as f:
451
+ f.write(html)
452
+ click.echo(f"Saved HTML to {filename}")
453
+
454
+ elif fmt == "text":
455
+ lines = video.screen_dump()
456
+ if not lines:
457
+ click.echo("Failed to read screen", err=True)
458
+ sys.exit(1)
459
+ filename = f"{output}.txt" if not output.endswith(".txt") else output
460
+ with open(filename, "w", encoding="utf-8") as f:
461
+ f.write("\n".join(lines))
462
+ click.echo(f"Saved text to {filename}")
463
+
464
+ except Exception as e:
465
+ click.echo(f"Error: {e}", err=True)
466
+ sys.exit(1)
467
+
468
+
469
+ @screen.command("info")
470
+ @click.pass_context
471
+ def screen_info(ctx):
472
+ """Show video mode and BIOS timer info."""
473
+ try:
474
+ with DOSVideoTools(ctx.obj["gdb_host"], ctx.obj["gdb_port"]) as video:
475
+ mode = video.read_video_mode()
476
+ ticks = video.read_timer_ticks()
477
+ click.echo(f"Video mode: {mode} (0x{mode:02X})" if mode else "Video mode: unknown")
478
+ click.echo(f"BIOS ticks: {ticks}" if ticks else "BIOS ticks: unknown")
479
+ if ticks:
480
+ seconds = ticks / 18.2065
481
+ click.echo(f"Uptime: {seconds:.1f}s ({seconds/60:.1f}m)")
482
+ except Exception as e:
483
+ click.echo(f"Error: {e}", err=True)
484
+ sys.exit(1)
485
+
486
+
487
+ @screen.command("watch")
488
+ @click.option("-i", "--interval", default=0.5, help="Update interval in seconds")
489
+ @click.pass_context
490
+ def screen_watch(ctx, interval: float):
491
+ """Watch screen in real-time. Press Ctrl+C to stop."""
492
+ try:
493
+ with DOSVideoTools(ctx.obj["gdb_host"], ctx.obj["gdb_port"]) as video:
494
+ click.echo("Watching screen... (Ctrl+C to stop)\n")
495
+ while True:
496
+ click.echo("\033[2J\033[H", nl=False) # Clear and home
497
+ lines, ticks = video.screen_dump_with_ticks()
498
+ if lines:
499
+ for line in lines:
500
+ click.echo(line)
501
+ if ticks is not None:
502
+ click.echo(f"\n[Ticks: {ticks}]")
503
+ else:
504
+ click.echo("Failed to read screen")
505
+ time.sleep(interval)
506
+ except KeyboardInterrupt:
507
+ click.echo("\nStopped")
508
+ except Exception as e:
509
+ click.echo(f"Error: {e}", err=True)
510
+ sys.exit(1)
511
+
512
+
513
+ @screen.command("record")
514
+ @click.option("-o", "--output", default="capture.capture.gz", help="Output filename")
515
+ @click.option("-d", "--duration", default=10.0, help="Duration in seconds")
516
+ @click.option("-r", "--rate", default=50.0, help="Sample rate in Hz")
517
+ @click.option("--raw", is_flag=True, help="Capture raw bytes (with attributes)")
518
+ @click.pass_context
519
+ def screen_record(ctx, output: str, duration: float, rate: float, raw: bool):
520
+ """Record screen captures to a .capture.gz file.
521
+
522
+ Creates a timestamped capture file for later analysis.
523
+
524
+ Example:
525
+ dbxdebug screen record -d 60 -r 30 -o session.capture.gz
526
+ """
527
+ try:
528
+ with DOSVideoTools(ctx.obj["gdb_host"], ctx.obj["gdb_port"]) as video:
529
+ recorder = ScreenRecorder({"duration": duration, "sample_rate": rate})
530
+ total_samples = int(duration * rate)
531
+
532
+ click.echo(f"Recording {duration}s at {rate}Hz ({total_samples} frames)...")
533
+
534
+ with click.progressbar(length=total_samples, label="Recording") as bar:
535
+ sample_interval = 1.0 / rate
536
+ start_time = time.time()
537
+ capture_fn = recorder.capture_raw if raw else recorder.capture
538
+
539
+ for i in range(total_samples):
540
+ capture_fn(video)
541
+ bar.update(1)
542
+
543
+ elapsed = time.time() - start_time
544
+ next_sample = (i + 1) * sample_interval
545
+ if (sleep_time := next_sample - elapsed) > 0:
546
+ time.sleep(sleep_time)
547
+
548
+ recorder.save(output)
549
+ click.echo(f"Saved {len(recorder)} frames to {output}")
550
+ except Exception as e:
551
+ click.echo(f"Error: {e}", err=True)
552
+ sys.exit(1)
553
+
554
+
555
+ @screen.command("colors")
556
+ @click.argument("capture_file", type=click.Path(exists=True), required=False)
557
+ @click.pass_context
558
+ def screen_colors(ctx, capture_file: str | None):
559
+ """Analyze colors in current screen or capture file.
560
+
561
+ Without argument, analyzes live screen. With file, analyzes capture.
562
+ """
563
+ try:
564
+ if capture_file:
565
+ data = load_capture(capture_file)
566
+ screens = data.get("screens", data)
567
+ # Get first raw screen from capture
568
+ if isinstance(screens, dict):
569
+ first_key = next(iter(screens))
570
+ video_data = screens[first_key]
571
+ if isinstance(video_data, list):
572
+ click.echo("Capture contains text-only screens (no color data)")
573
+ return
574
+ pages = [video_data]
575
+ else:
576
+ pages = [screens]
577
+ else:
578
+ with DOSVideoTools(ctx.obj["gdb_host"], ctx.obj["gdb_port"]) as video:
579
+ raw = video.screen_raw()
580
+ if not raw:
581
+ click.echo("Failed to read screen", err=True)
582
+ sys.exit(1)
583
+ pages = [raw]
584
+
585
+ analysis = analyze_dos_video_colors(pages)
586
+ summary = analysis["summary"]
587
+
588
+ click.echo(f"Cells analyzed: {summary['total_cells']}")
589
+ click.echo(f"Content cells: {summary['content_cells']}")
590
+ click.echo(f"Foreground colors: {summary['unique_fg_colors']}")
591
+ click.echo(f"Background colors: {summary['unique_bg_colors']}")
592
+ click.echo(f"Color combinations: {summary['unique_combinations']}")
593
+ click.echo(f"Blink used: {'Yes' if summary['blink_used'] else 'No'}")
594
+
595
+ click.echo("\nForeground colors:")
596
+ for c in analysis["foreground_colors"]:
597
+ click.echo(f" {c['id']:2d} {c['name']:<14} {c['count']:5d} ({c['percentage']:5.1f}%)")
598
+
599
+ click.echo("\nBackground colors:")
600
+ for c in analysis["background_colors"]:
601
+ click.echo(f" {c['id']:2d} {c['name']:<14} {c['count']:5d} ({c['percentage']:5.1f}%)")
602
+
603
+ except Exception as e:
604
+ click.echo(f"Error: {e}", err=True)
605
+ sys.exit(1)
606
+
607
+
608
+ # =============================================================================
609
+ # Backwards compatibility aliases
610
+ # =============================================================================
611
+
612
+ # Keep old 'gdb' and 'qmp' as aliases
613
+ main.add_command(mem, name="gdb") # gdb is alias for mem
614
+ main.add_command(key, name="qmp") # qmp is alias for key
615
+
616
+
617
+ if __name__ == "__main__":
618
+ main()