stealth-message-cli 0.1.0__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.
File without changes
@@ -0,0 +1,711 @@
1
+ """Entry point for stealth-message CLI.
2
+
3
+ Usage:
4
+ python -m stealth_cli # auto-detect mode (prompts if needed)
5
+ python -m stealth_cli --host # host mode on default port 8765
6
+ python -m stealth_cli --host 9000 # host mode on custom port
7
+ python -m stealth_cli --join ws://192.168.1.10:8765
8
+
9
+ Flow:
10
+ 1. First use → run setup wizard → save keypair → open chat
11
+ 2. Known user → ask passphrase → open chat
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import asyncio
18
+ import logging
19
+ import sys
20
+ import warnings
21
+
22
+ from prompt_toolkit import PromptSession
23
+ from prompt_toolkit.formatted_text import HTML
24
+ from prompt_toolkit.styles import Style
25
+ from rich.console import Console
26
+ from rich.markdown import Markdown
27
+ from rich.padding import Padding
28
+ from rich.panel import Panel
29
+ from rich.rule import Rule
30
+ from rich.text import Text
31
+
32
+ from stealth_cli import config
33
+ from stealth_cli.crypto.keys import load_private_key
34
+ from stealth_cli.network.client import query_rooms
35
+ from stealth_cli.ui.chat import run_chat
36
+ from stealth_cli.ui.setup import run_setup
37
+
38
+ console = Console()
39
+ logger = logging.getLogger(__name__)
40
+
41
+ # Suppress known pgpy warnings that do not affect crypto correctness.
42
+ # These are unimplemented features inside pgpy itself (self-sig parsing,
43
+ # revocation checks, flags) and a benign compression preference mismatch.
44
+ warnings.filterwarnings("ignore", message=".*compression algorithm.*", category=UserWarning)
45
+ warnings.filterwarnings("ignore", message=".*Self-sigs verification.*", category=UserWarning)
46
+ warnings.filterwarnings("ignore", message=".*Revocation checks.*", category=UserWarning)
47
+ warnings.filterwarnings("ignore", message=".*Flags.*checks are not yet implemented.*", category=UserWarning)
48
+ warnings.filterwarnings("ignore", message=".*TripleDES.*", category=UserWarning)
49
+
50
+ _STYLE = Style.from_dict({"prompt": "bold cyan"})
51
+
52
+ DEFAULT_PORT = 8765
53
+
54
+
55
+ # --------------------------------------------------------------------------- #
56
+ # CLI argument parsing #
57
+ # --------------------------------------------------------------------------- #
58
+
59
+
60
+ def _parse_args() -> argparse.Namespace:
61
+ parser = argparse.ArgumentParser(
62
+ prog="stealth-cli",
63
+ description="End-to-end encrypted PGP chat — no server, no accounts.",
64
+ epilog="Run with --manual for the full user guide.",
65
+ )
66
+
67
+ mode = parser.add_mutually_exclusive_group()
68
+ mode.add_argument(
69
+ "--host",
70
+ nargs="?",
71
+ const=DEFAULT_PORT,
72
+ type=int,
73
+ metavar="PORT",
74
+ help=f"Host a chat session on PORT (default: {DEFAULT_PORT})",
75
+ )
76
+ mode.add_argument(
77
+ "--join",
78
+ metavar="URI",
79
+ help="Join a session at ws://HOST:PORT",
80
+ )
81
+ mode.add_argument(
82
+ "--manual",
83
+ action="store_true",
84
+ help="Show the full user manual and exit",
85
+ )
86
+
87
+ parser.add_argument(
88
+ "--rooms",
89
+ metavar="ROOMS",
90
+ help=(
91
+ "Comma-separated list of room names to create (host mode only). "
92
+ "Example: --rooms pepe,juan Each room admits exactly one peer."
93
+ ),
94
+ )
95
+ parser.add_argument(
96
+ "--room",
97
+ metavar="ROOM",
98
+ default="default",
99
+ help="Room to join (join mode only, default: 'default')",
100
+ )
101
+ parser.add_argument(
102
+ "--reset",
103
+ action="store_true",
104
+ help="Delete the saved keypair and start the setup wizard to create a new identity",
105
+ )
106
+ parser.add_argument(
107
+ "--debug",
108
+ action="store_true",
109
+ help="Enable debug logging",
110
+ )
111
+
112
+ return parser.parse_args()
113
+
114
+
115
+ # --------------------------------------------------------------------------- #
116
+ # Async main #
117
+ # --------------------------------------------------------------------------- #
118
+
119
+
120
+ async def _async_main() -> int:
121
+ args = _parse_args()
122
+
123
+ if args.debug:
124
+ logging.basicConfig(level=logging.DEBUG)
125
+
126
+ if args.manual:
127
+ _print_manual()
128
+ return 0
129
+
130
+ # ------------------------------------------------------------------ #
131
+ # Step 0 — --reset: wipe keypair and force the setup wizard. #
132
+ # ------------------------------------------------------------------ #
133
+ if args.reset:
134
+ if config.is_first_use():
135
+ console.print("[dim]No saved identity found — nothing to reset.[/dim]")
136
+ else:
137
+ config.delete_keypair()
138
+ console.print("[yellow]Identity deleted.[/yellow] Starting setup wizard…\n")
139
+
140
+ # ------------------------------------------------------------------ #
141
+ # Step 1 — First use: run the setup wizard. #
142
+ # ------------------------------------------------------------------ #
143
+ if config.is_first_use():
144
+ alias, armored_private, passphrase = await run_setup()
145
+ else:
146
+ # ------------------------------------------------------------------ #
147
+ # Step 2 — Known user: load keys and ask for passphrase. #
148
+ # ------------------------------------------------------------------ #
149
+ alias = config.load_alias() or "unknown"
150
+ armored_private = config.load_armored_private()
151
+ passphrase = await _prompt_passphrase(alias)
152
+
153
+ # Validate passphrase eagerly so the user gets immediate feedback.
154
+ if not _validate_passphrase(armored_private, passphrase):
155
+ console.print("[red]Wrong passphrase. Exiting.[/red]")
156
+ return 1
157
+
158
+ # ------------------------------------------------------------------ #
159
+ # Step 3 — Determine chat mode from CLI flags or interactive prompt. #
160
+ # ------------------------------------------------------------------ #
161
+ rooms: list[str] | None = None
162
+ room: str = "default"
163
+
164
+ if args.host is not None:
165
+ mode = "host"
166
+ port = args.host
167
+ uri = None
168
+ if args.rooms:
169
+ rooms = [r.strip() for r in args.rooms.split(",") if r.strip()]
170
+ elif args.join is not None:
171
+ mode = "join"
172
+ port = DEFAULT_PORT
173
+ uri = args.join
174
+ if not uri.startswith("ws://") and not uri.startswith("wss://"):
175
+ uri = "ws://" + uri
176
+ room = args.room or "default"
177
+ else:
178
+ mode, port, uri, rooms, room = await _prompt_mode()
179
+
180
+ # ------------------------------------------------------------------ #
181
+ # Step 4 — Launch the chat screen. #
182
+ # ------------------------------------------------------------------ #
183
+ try:
184
+ await run_chat(
185
+ mode=mode, # type: ignore[arg-type]
186
+ alias=alias,
187
+ armored_private=armored_private,
188
+ passphrase=passphrase,
189
+ port=port,
190
+ uri=uri,
191
+ rooms=rooms,
192
+ room=room,
193
+ )
194
+ except KeyboardInterrupt:
195
+ pass
196
+ except Exception as exc:
197
+ if args.debug:
198
+ raise
199
+ console.print(f"[red]Error:[/red] {exc}")
200
+ return 1
201
+
202
+ return 0
203
+
204
+
205
+ # --------------------------------------------------------------------------- #
206
+ # Interactive helpers #
207
+ # --------------------------------------------------------------------------- #
208
+
209
+
210
+ async def _print_room_list(uri: str) -> None:
211
+ """Query the server for room info and print a formatted list."""
212
+ from rich.table import Table
213
+
214
+ console.print(f"[dim]Fetching rooms from[/dim] [bold]{uri}[/bold][dim]…[/dim]")
215
+ rooms = await query_rooms(uri)
216
+ if not rooms:
217
+ console.print("[yellow] Could not retrieve room list (server may not support it).[/yellow]")
218
+ return
219
+
220
+ t = Table.grid(padding=(0, 2))
221
+ t.add_column(no_wrap=True) # room name
222
+ t.add_column(no_wrap=True) # kind badge
223
+ t.add_column() # status
224
+
225
+ for room in rooms:
226
+ room_id = str(room.get("id", ""))
227
+ kind = str(room.get("kind", "1:1"))
228
+ peers = int(room.get("peers", 0))
229
+
230
+ if kind == "group":
231
+ badge = "[yellow]group[/yellow]"
232
+ if peers == 0:
233
+ status = "[dim]empty — host only[/dim]"
234
+ elif peers == 1:
235
+ status = f"[dim]host + {peers} user[/dim]"
236
+ else:
237
+ status = f"[dim]host + {peers} users[/dim]"
238
+ else:
239
+ badge = "[cyan]1:1[/cyan]"
240
+ available = bool(room.get("available", True))
241
+ if available:
242
+ status = "[green]available[/green]"
243
+ else:
244
+ status = "[red]occupied[/red]"
245
+
246
+ t.add_row(f" [bold]{room_id}[/bold]", badge, status)
247
+
248
+ console.print()
249
+ console.print("[bold]Available rooms:[/bold]")
250
+ console.print(t)
251
+ console.print()
252
+
253
+
254
+ async def _prompt_passphrase(alias: str) -> str:
255
+ """Ask the user for their passphrase at startup."""
256
+ session: PromptSession[str] = PromptSession(style=_STYLE)
257
+ console.print(f"[cyan]Welcome back,[/cyan] [bold]{alias}[/bold]")
258
+ passphrase: str = await session.prompt_async(
259
+ HTML("<prompt>Passphrase: </prompt>"),
260
+ is_password=True,
261
+ )
262
+ return passphrase
263
+
264
+
265
+ def _validate_passphrase(armored_private: str, passphrase: str) -> bool:
266
+ """Return True if the passphrase successfully unlocks the private key."""
267
+ try:
268
+ load_private_key(armored_private, passphrase)
269
+ return True
270
+ except Exception:
271
+ return False
272
+
273
+
274
+ async def _prompt_mode() -> tuple[str, int, str | None, list[str] | None, str]:
275
+ """Interactively ask whether to host or join."""
276
+ session: PromptSession[str] = PromptSession(style=_STYLE)
277
+
278
+ console.print()
279
+ console.print("[bold]What do you want to do?[/bold]")
280
+ console.print(" [cyan]h[/cyan] Host a new session")
281
+ console.print(" [cyan]j[/cyan] Join an existing session")
282
+ console.print()
283
+
284
+ while True:
285
+ choice: str = await session.prompt_async(
286
+ HTML("<prompt>Choice [h/j]: </prompt>"),
287
+ )
288
+ choice = choice.strip().lower()
289
+ if choice in ("h", "host"):
290
+ port_str: str = await session.prompt_async(
291
+ HTML(f"<prompt>Port [{DEFAULT_PORT}]: </prompt>"),
292
+ )
293
+ port = int(port_str.strip()) if port_str.strip().isdigit() else DEFAULT_PORT
294
+
295
+ rooms_str: str = await session.prompt_async(
296
+ HTML("<prompt>Rooms (comma-separated, blank for single): </prompt>"),
297
+ )
298
+ rooms_str = rooms_str.strip()
299
+ rooms: list[str] | None = (
300
+ [r.strip() for r in rooms_str.split(",") if r.strip()]
301
+ if rooms_str
302
+ else None
303
+ )
304
+ return "host", port, None, rooms, "default"
305
+
306
+ if choice in ("j", "join"):
307
+ uri: str = await session.prompt_async(
308
+ HTML("<prompt>Server URI (ws://host:port): </prompt>"),
309
+ )
310
+ uri = uri.strip()
311
+ if uri and not uri.startswith("ws://") and not uri.startswith("wss://"):
312
+ uri = "ws://" + uri
313
+ await _print_room_list(uri)
314
+ room_str: str = await session.prompt_async(
315
+ HTML("<prompt>Room [default]: </prompt>"),
316
+ )
317
+ room = room_str.strip() or "default"
318
+ return "join", DEFAULT_PORT, uri, None, room
319
+
320
+ console.print("[red]Please enter 'h' or 'j'.[/red]")
321
+
322
+
323
+ # --------------------------------------------------------------------------- #
324
+ # Manual #
325
+ # --------------------------------------------------------------------------- #
326
+
327
+
328
+ def _print_manual() -> None:
329
+ """Print the full user manual with Rich formatting."""
330
+ c = Console()
331
+
332
+ # Header
333
+ c.print()
334
+ c.print(
335
+ Panel(
336
+ Text.assemble(
337
+ ("stealth-message", "bold white"),
338
+ " — ",
339
+ ("User Manual", "bold cyan"),
340
+ "\n",
341
+ ("End-to-end encrypted PGP chat. No server. No accounts. No metadata.", "dim"),
342
+ ),
343
+ border_style="cyan",
344
+ padding=(1, 4),
345
+ )
346
+ )
347
+
348
+ manual = """\
349
+ ## How it works
350
+
351
+ Participants communicate directly, machine to machine, over a WebSocket
352
+ connection. Every message is encrypted with the recipient's PGP public key
353
+ and signed with the sender's private key before leaving the machine.
354
+ No server ever sees the content.
355
+
356
+ One participant acts as **host** (starts the server) and the others **join**.
357
+ All roles send and receive messages equally once connected.
358
+
359
+ ---
360
+
361
+ ## First use
362
+
363
+ The first time you run the program, a setup wizard starts automatically:
364
+
365
+ ```
366
+ python -m stealth_cli
367
+ ```
368
+
369
+ The wizard asks for:
370
+
371
+ - **Alias** — your display name, visible to peers (max 64 chars).
372
+ - **Passphrase** — protects your private key on disk (min 8 chars, asked twice).
373
+
374
+ An RSA-4096 key pair is then generated and saved to disk:
375
+
376
+ - **macOS:** `~/Library/Application Support/stealth-message/`
377
+ - **Linux / WSL:** `~/.config/stealth-message/`
378
+
379
+ Your **fingerprint** is shown at the end. Share it with your peers over an
380
+ independent channel (in person, by phone) so they can verify your identity.
381
+
382
+ ---
383
+
384
+ ## Starting a session
385
+
386
+ ### Alice — host mode (single 1-on-1 room)
387
+
388
+ ```
389
+ python -m stealth_cli --host # default port 8765
390
+ python -m stealth_cli --host 9000 # custom port
391
+ ```
392
+
393
+ ### Alice — host mode (multiple rooms)
394
+
395
+ ```
396
+ python -m stealth_cli --host --rooms bob,carol,team
397
+ ```
398
+
399
+ This creates three independent rooms. Peers connect to a specific room by name.
400
+
401
+ ### Bob — join mode
402
+
403
+ ```
404
+ python -m stealth_cli --join ALICE_IP:8765 # ws:// added automatically
405
+ python -m stealth_cli --join ALICE_IP:8765 --room bob
406
+ ```
407
+
408
+ ### Interactive mode (no flags)
409
+
410
+ ```
411
+ python -m stealth_cli
412
+ ```
413
+
414
+ When joining interactively, after entering the server address the program
415
+ fetches and displays the available rooms before asking which one to join:
416
+
417
+ ```
418
+ Available rooms:
419
+ lobby 1:1 available
420
+ work 1:1 occupied
421
+ team group host + 2 users
422
+ ```
423
+
424
+ Room names are shown but connected user names are never disclosed.
425
+
426
+ ---
427
+
428
+ ## Room types
429
+
430
+ ### 1-on-1 rooms (default)
431
+
432
+ Each room admits exactly **one peer**. A second peer trying to connect gets
433
+ error 4006 (room occupied). The host can hold multiple 1-on-1 rooms in parallel
434
+ and switch between them with `/switch`.
435
+
436
+ ### Group rooms
437
+
438
+ Group rooms admit **multiple peers** with host approval:
439
+
440
+ ```
441
+ [Alice@room1] /group team # convert a room to group mode
442
+ [Alice@room1] /move Bob team # invite Bob — pre-approved, no prompt
443
+ ```
444
+
445
+ When a new peer tries to join a group room that already has someone:
446
+
447
+ ```
448
+ ⚠ Join request: Carol wants to enter room team
449
+ FP: XXXX XXXX XXXX ...
450
+ /allow Carol or /deny Carol
451
+ ```
452
+
453
+ - `/allow Carol` → Carol enters the room
454
+ - `/deny Carol` → Carol is rejected
455
+
456
+ In group rooms, messages are forwarded to **all** other peers in the room.
457
+ The host re-encrypts each message for each recipient individually.
458
+
459
+ ### Room discovery
460
+
461
+ After connecting, every peer receives the list of group rooms on the server.
462
+ Running `/rooms` shows all known rooms including ones not yet visited:
463
+
464
+ ```
465
+ ▶ team ✓ Alice, Bob
466
+ lobby waiting for peer…
467
+ open-chat group /switch to join
468
+ ```
469
+
470
+ ---
471
+
472
+ ## Connecting over the internet
473
+
474
+ The host needs a publicly reachable IP. Two options:
475
+
476
+ **Option A — Port forwarding (no third-party software)**
477
+
478
+ 1. Find your public IP: `curl ifconfig.me`
479
+ 2. In your router: NAT → Port Forwarding → TCP port 8765 → your local IP.
480
+ 3. Give peers: `ALICE_PUBLIC_IP:8765`
481
+ 4. Close the port forwarding rule when you finish.
482
+
483
+ > If port forwarding does not work, your ISP may use CG-NAT.
484
+ > Check: compare the WAN IP shown in your router with `curl ifconfig.me`.
485
+ > If they differ, contact your ISP and request a dedicated public IP.
486
+
487
+ **Option B — Tailscale (no port forwarding)**
488
+
489
+ Tailscale creates a private WireGuard tunnel directly between machines.
490
+ No router configuration needed.
491
+
492
+ 1. All participants install Tailscale (free for personal use).
493
+ 2. Alice shares her device with peers from the Tailscale web console
494
+ ("Share node" — they only see Alice's machine, not her whole network).
495
+ 3. Run `tailscale status` to see each other's `100.x.x.x` addresses.
496
+ 4. Alice: `python -m stealth_cli --host`
497
+ 5. Others: `python -m stealth_cli --join ALICE_TAILSCALE_IP:8765 --room <name>`
498
+ 6. Revoke the share when done.
499
+
500
+ > With Tailscale, messages travel encrypted by WireGuard AND by PGP —
501
+ > two independent layers.
502
+
503
+ ---
504
+
505
+ ## Chat commands — all users
506
+
507
+ | Command | Action |
508
+ |---------|--------|
509
+ | `/fp` | Show the current peer's PGP fingerprint |
510
+ | `/rooms` | List all known rooms and their status |
511
+ | `/switch <room>` | Change active room (join mode: reconnects; host mode: changes focus) |
512
+ | `/help` | Show available commands |
513
+ | `/quit` or `/exit` or `/q` | Close the session cleanly |
514
+ | `Ctrl+C` | Also closes the session |
515
+
516
+ ## Chat commands — host only
517
+
518
+ | Command | Action |
519
+ |---------|--------|
520
+ | `/new <room>` | Create a new 1-on-1 room at runtime |
521
+ | `/group <room>` | Convert a room to group mode (multiple peers) |
522
+ | `/move <alias> <room>` | Move a peer to a different room (pre-approved, no prompt) |
523
+ | `/allow <alias>` | Approve a pending join request |
524
+ | `/deny <alias>` | Deny a pending join request |
525
+ | `/pending` | List all pending join requests |
526
+ | `/disconnect [alias]` | Force-disconnect a peer (alias optional in 1-on-1 rooms) |
527
+
528
+ ---
529
+
530
+ ## Example: Alice hosts, Bob and Carol join separate 1-on-1 rooms
531
+
532
+ **Alice (host):**
533
+ ```
534
+ python -m stealth_cli --host --rooms bob,carol
535
+ ```
536
+
537
+ **Bob:**
538
+ ```
539
+ python -m stealth_cli --join ALICE_IP:8765 --room bob
540
+ ```
541
+
542
+ **Carol:**
543
+ ```
544
+ python -m stealth_cli --join ALICE_IP:8765 --room carol
545
+ ```
546
+
547
+ Alice uses `/switch bob` and `/switch carol` to alternate between conversations.
548
+ Neither Bob nor Carol can see each other's messages.
549
+
550
+ ---
551
+
552
+ ## Example: Alice hosts a group room with Bob and Carol
553
+
554
+ **Alice (host):**
555
+ ```
556
+ python -m stealth_cli --host --rooms lobby,team
557
+ [Alice@lobby] /group team # convert team to group mode
558
+ [Alice@lobby] /move Bob team # move Bob — pre-approved
559
+ ```
560
+
561
+ **Bob** (already connected to lobby):
562
+ ```
563
+ ↪ Host is moving you to room team…
564
+ ✓ Switched to room team — connected to Alice
565
+ ```
566
+
567
+ **Carol** (joins directly):
568
+ ```
569
+ python -m stealth_cli --join ALICE_IP:8765 --room team
570
+ ⏳ Waiting for host to approve your entry into room team…
571
+ ```
572
+
573
+ **Alice approves:**
574
+ ```
575
+ ⚠ Join request: Carol wants to enter room team
576
+ [Alice@team] /allow Carol
577
+ ```
578
+
579
+ Now Alice, Bob and Carol are all in room `team`. Any message sent by
580
+ any of them reaches all the others.
581
+
582
+ ---
583
+
584
+ ## Identity verification
585
+
586
+ After connecting, both sides see the peer's alias and fingerprint:
587
+
588
+ ```
589
+ ✓ Connected to Alice [room: bob]
590
+ Fingerprint: F7B3 E55E EA71 1A09 C6C5 0BB7 BA84 DD16 8A77 AA9A
591
+ ```
592
+
593
+ **Always verify the fingerprint over an independent channel before trusting
594
+ the conversation.** If the fingerprints match, the connection is authentic.
595
+ If they do not, disconnect immediately.
596
+
597
+ ---
598
+
599
+ ## Security model
600
+
601
+ | Property | Guarantee |
602
+ |----------|-----------|
603
+ | Message confidentiality | Only the recipient can decrypt (RSA-4096 + AES-256) |
604
+ | Message authenticity | Every message is signed; invalid signatures are rejected |
605
+ | Group room relay | Host re-encrypts per recipient; host sees plaintext during relay |
606
+ | Room isolation | Peers in different rooms cannot read each other's messages |
607
+ | Room discovery | Room list shows counts only — connected user names are never disclosed |
608
+ | Access control | Group rooms require explicit host approval for each new peer |
609
+ | Forward secrecy | Not yet implemented (planned for protocol v2) |
610
+ | Private key storage | Disk-encrypted with your passphrase (AES-256) |
611
+ | Passphrase | Never written to disk, only held in memory during the session |
612
+ | No accounts | Identity is the PGP key — no username, email, or phone number |
613
+
614
+ ---
615
+
616
+ ## Subsequent uses
617
+
618
+ From the second run onward, the wizard is skipped. The program asks only
619
+ for the passphrase:
620
+
621
+ ```
622
+ Welcome back, Alice
623
+ Passphrase: ****
624
+ ```
625
+
626
+ A wrong passphrase exits immediately without loading any data.
627
+
628
+ ---
629
+
630
+ ## Resetting your identity
631
+
632
+ To delete your saved keypair and start over with a new alias:
633
+
634
+ ```
635
+ python -m stealth_cli --reset
636
+ ```
637
+
638
+ This wipes the stored keys and config and immediately runs the setup wizard
639
+ so you can choose a new alias and generate a fresh RSA-4096 keypair in one step.
640
+
641
+ > **Note:** Your previous fingerprint will be invalidated. Any peer who had it
642
+ > saved will need to verify your new fingerprint before trusting the new identity.
643
+
644
+ ---
645
+
646
+ ## Room names
647
+
648
+ Room names can contain any characters, including spaces, and can be up to
649
+ 64 characters long. When passing a name with spaces on the command line,
650
+ wrap it in quotes:
651
+
652
+ ```
653
+ python -m stealth_cli --host --rooms "sala 1","sala 2"
654
+ python -m stealth_cli --join HOST:8765 --room "sala 1"
655
+ ```
656
+
657
+ Inside the chat (interactive commands) quotes are not needed — everything
658
+ after the command is taken as the room name:
659
+
660
+ ```
661
+ /new sala 1
662
+ /switch sala 1
663
+ /group sala 1
664
+ ```
665
+
666
+ ---
667
+
668
+ ## Flags reference
669
+
670
+ | Flag | Description |
671
+ |------|-------------|
672
+ | `--host [PORT]` | Host a session on PORT (default 8765) |
673
+ | `--rooms ROOMS` | Comma-separated room names (host mode) |
674
+ | `--join URI` | Join a session at host:port (ws:// added automatically) |
675
+ | `--room ROOM` | Room to join (join mode, default: "default") |
676
+ | `--manual` | Show this manual |
677
+ | `--reset` | Delete saved identity and run setup wizard |
678
+ | `--debug` | Enable verbose debug logging |
679
+ | `--help` | Show short usage summary |
680
+
681
+ ---
682
+
683
+ ## Running the tests
684
+
685
+ ```
686
+ cd cli
687
+ source .venv/bin/activate
688
+ pytest tests/ -v
689
+ ```
690
+ """
691
+
692
+ c.print(Padding(Markdown(manual), (0, 2)))
693
+ c.print()
694
+
695
+
696
+ # --------------------------------------------------------------------------- #
697
+ # Synchronous entry point #
698
+ # --------------------------------------------------------------------------- #
699
+
700
+
701
+ def main() -> None:
702
+ """Synchronous entry point for the stealth-cli console script."""
703
+ try:
704
+ code = asyncio.run(_async_main())
705
+ except KeyboardInterrupt:
706
+ code = 0
707
+ sys.exit(code)
708
+
709
+
710
+ if __name__ == "__main__":
711
+ main()