susops 3.0.0rc3.dev1__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.
susops/tui/cli.py ADDED
@@ -0,0 +1,665 @@
1
+ """Non-interactive CLI for SusOps.
2
+
3
+ Used when stdout is not a TTY (e.g., scripts, cron, tray app subprocess calls).
4
+ Provides all susops commands with text output and semantic exit codes:
5
+ 0 = success / all running
6
+ 1 = error
7
+ 2 = partial (some services stopped)
8
+ 3 = all stopped
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ import susops
17
+ from susops.core.types import ProcessState
18
+
19
+
20
+ def _manager(args=None):
21
+ """Lazy import to avoid circular imports and slow startup when not needed."""
22
+ from pathlib import Path
23
+ from susops.client import SusOpsClient
24
+ workspace = Path.home() / ".susops"
25
+ # `verbose` is daemon-side now; client doesn't pass it through.
26
+ return SusOpsClient(workspace=workspace, process_name="susops-cli")
27
+
28
+
29
+ def _state_exit_code(state) -> int:
30
+ if state == ProcessState.RUNNING:
31
+ return 0
32
+ if state == ProcessState.STOPPED_PARTIALLY:
33
+ return 2
34
+ if state == ProcessState.STOPPED:
35
+ return 3
36
+ return 1
37
+
38
+
39
+ def cmd_start(args, m) -> int:
40
+ result = m.start(tag=args.connection)
41
+ print(result.message)
42
+ for cs in result.connection_statuses:
43
+ icon = "+" if cs.running else "!"
44
+ print(f" [{icon}] {cs.tag}" + (f" port {cs.socks_port}" if cs.socks_port else ""))
45
+ return 0 if result.success else 1
46
+
47
+
48
+ def cmd_stop(args, m) -> int:
49
+ result = m.stop(tag=args.connection, keep_ports=args.keep_ports)
50
+ print(result.message)
51
+ return 0 if result.success else 1
52
+
53
+
54
+ def cmd_restart(args, m) -> int:
55
+ result = m.restart(tag=getattr(args, "connection", None))
56
+ print(result.message)
57
+ return 0 if result.success else 1
58
+
59
+
60
+ def cmd_ps(args, m) -> int:
61
+ """Print the same status information as the tray's 'Show Status' dialog.
62
+
63
+ Sections:
64
+ SusOps <state> · N of M running [· K disabled]
65
+ CONNECTIONS
66
+ PAC SERVER
67
+ SERVICES (Daemon RPC + Reconnect monitor + SSE)
68
+ """
69
+ result = m.status()
70
+ info = m.process_info()
71
+
72
+ # ── Header ────────────────────────────────────────────────────────
73
+ state_glyph = {
74
+ "running": "●",
75
+ "stopped_partially": "◐",
76
+ "stopped": "○",
77
+ "error": "✕",
78
+ "initial": "○",
79
+ }.get(result.state.value, "○")
80
+ enabled = [c for c in result.connection_statuses if c.enabled]
81
+ running_n = sum(1 for c in enabled if c.running)
82
+ disabled_n = sum(1 for c in result.connection_statuses if not c.enabled)
83
+ summary = f"{running_n} of {len(enabled)} running"
84
+ if disabled_n:
85
+ summary += f" · {disabled_n} disabled"
86
+ print(f"{state_glyph} SusOps {result.state.value}")
87
+ print(f" {summary}")
88
+
89
+ # ── CONNECTIONS ───────────────────────────────────────────────────
90
+ print()
91
+ print("CONNECTIONS")
92
+ if not result.connection_statuses:
93
+ print(" (no connections configured)")
94
+ else:
95
+ tag_w = max(max(len(c.tag) for c in result.connection_statuses), 8)
96
+ for cs in result.connection_statuses:
97
+ if not cs.enabled:
98
+ dot, detail = "─", "disabled"
99
+ elif cs.running:
100
+ dot = "●"
101
+ bits = []
102
+ if cs.socks_port:
103
+ bits.append(f"SOCKS {cs.socks_port}")
104
+ if cs.pid:
105
+ bits.append(f"pid {cs.pid}")
106
+ detail = " ".join(bits) if bits else "running"
107
+ else:
108
+ dot, detail = "○", "stopped"
109
+ print(f" {dot} {cs.tag:<{tag_w}} {detail}")
110
+ # Tracked child processes (forward slaves, UDP socats) — keeps
111
+ # parity with the legacy `susops ps` tree view.
112
+ children = info["conn_children"].get(cs.tag, [])
113
+ for i, child in enumerate(children):
114
+ branch = "└" if i == len(children) - 1 else "├"
115
+ ch_icon = "●" if child["running"] else "○"
116
+ ch_pid = f" pid={child['pid']}" if child["pid"] else ""
117
+ print(f" {branch} {ch_icon} {child['display']}{ch_pid}")
118
+
119
+ # ── PAC SERVER ────────────────────────────────────────────────────
120
+ print()
121
+ print("PAC SERVER")
122
+ if result.pac_running and result.pac_port:
123
+ print(f" ● http://localhost:{result.pac_port}/susops.pac")
124
+ else:
125
+ print(" ○ stopped")
126
+
127
+ # ── SERVICES (Daemon RPC + Reconnect + SSE) ───────────────────────
128
+ print()
129
+ print("SERVICES")
130
+ workspace = m.workspace
131
+ try:
132
+ rpc_port = int((workspace / "pids" / "susops-services.port")
133
+ .read_text().strip())
134
+ print(f" ● Daemon RPC http://localhost:{rpc_port}/rpc")
135
+ except (OSError, ValueError):
136
+ print(" ○ Daemon RPC (port file unavailable)")
137
+ try:
138
+ pid = int((workspace / "pids" / "susops-services.pid")
139
+ .read_text().strip())
140
+ print(f" Daemon PID {pid}")
141
+ except (OSError, ValueError):
142
+ pass
143
+ try:
144
+ sse_url = m.get_status_url() or ""
145
+ except Exception:
146
+ sse_url = ""
147
+ if sse_url:
148
+ print(f" Daemon SSE {sse_url}")
149
+ print(f" Workspace {workspace}")
150
+
151
+ rec = info["reconnect"]
152
+ if rec.get("daemon_running"):
153
+ rec_pid = f" pid={rec['pid']}" if rec.get("pid") else ""
154
+ print(f" ● Reconnect daemon{rec_pid}")
155
+ elif rec.get("thread_alive") and rec.get("watching"):
156
+ n = len(rec["watching"])
157
+ print(f" ● Reconnect watching {n} connection{'s' if n != 1 else ''}")
158
+ else:
159
+ print(" ○ Reconnect stopped")
160
+
161
+ return _state_exit_code(result.state)
162
+
163
+
164
+ def cmd_ls(args, m) -> int:
165
+ config = m.list_config()
166
+ print(f"pac_server_port: {config.pac_server_port}")
167
+ for conn in config.connections:
168
+ print(f"\nconnection: {conn.tag}")
169
+ print(f" ssh_host: {conn.ssh_host}")
170
+ print(f" socks_port: {conn.socks_proxy_port}")
171
+ if conn.pac_hosts:
172
+ print(f" pac_hosts:")
173
+ for host in conn.pac_hosts:
174
+ print(f" - {host}")
175
+ if conn.forwards.local:
176
+ print(f" local_forwards:")
177
+ for forward in conn.forwards.local:
178
+ print(f" - {forward.src_addr}:{forward.src_port} → {forward.dst_addr}:{forward.dst_port}" +
179
+ (f" [{forward.tag}]" if forward.tag else ""))
180
+ if conn.forwards.remote:
181
+ print(f" remote_forwards:")
182
+ for forward in conn.forwards.remote:
183
+ print(f" - {forward.src_addr}:{forward.src_port} → {forward.dst_addr}:{forward.dst_port}" +
184
+ (f" [{forward.tag}]" if forward.tag else ""))
185
+ if conn.file_shares:
186
+ print(" file_shares:")
187
+ for share in conn.file_shares:
188
+ print(f" - file_path: {share.file_path}")
189
+ print(f" password: {share.password}")
190
+ print(f" port: {share.port}")
191
+ print(f" stopped: {share.stopped}")
192
+ return 0
193
+
194
+
195
+ def cmd_add_connection(args, m) -> int:
196
+ try:
197
+ conn = m.add_connection(args.tag, args.ssh_host, socks_port=args.socks_port or 0)
198
+ print(f"Added connection '{conn.tag}' → {conn.ssh_host}")
199
+ return 0
200
+ except ValueError as e:
201
+ print(f"Error: {e}", file=sys.stderr)
202
+ return 1
203
+
204
+
205
+ def cmd_rm_connection(args, m) -> int:
206
+ try:
207
+ m.remove_connection(args.tag)
208
+ print(f"Removed connection '{args.tag}'")
209
+ return 0
210
+ except ValueError as e:
211
+ print(f"Error: {e}", file=sys.stderr)
212
+ return 1
213
+
214
+
215
+ def cmd_add(args, m) -> int:
216
+ try:
217
+ if args.local or args.remote:
218
+ # argparse fills positionals left-to-right, so 'host' grabs the
219
+ # first integer; shift everything back when adding forwards.
220
+ from susops.core.config import PortForward
221
+ try:
222
+ local_port = int(args.host) if args.local_port is None else args.local_port
223
+ remote_port = args.local_port if args.remote_port is None and args.local_port != local_port else args.remote_port
224
+ except (TypeError, ValueError):
225
+ local_port = args.local_port
226
+ remote_port = args.remote_port
227
+ conn_tag = args.connection or m.config.connections[0].tag
228
+ if args.local:
229
+ fw = PortForward(
230
+ src_port=local_port,
231
+ dst_port=remote_port if remote_port is not None else local_port,
232
+ src_addr=args.local_addr or "localhost",
233
+ dst_addr=args.remote_addr or "localhost",
234
+ tag=args.forward_tag or "",
235
+ )
236
+ m.add_local_forward(conn_tag, fw)
237
+ print(f"Added local forward {fw.src_port} → {fw.dst_port}")
238
+ else:
239
+ fw = PortForward(
240
+ src_port=remote_port if remote_port is not None else local_port,
241
+ dst_port=local_port,
242
+ src_addr=args.remote_addr or "localhost",
243
+ dst_addr=args.local_addr or "localhost",
244
+ tag=args.forward_tag or "",
245
+ )
246
+ m.add_remote_forward(conn_tag, fw)
247
+ print(f"Added remote forward {fw.src_port} → {fw.dst_port}")
248
+ else:
249
+ m.add_pac_host(args.host, conn_tag=args.connection)
250
+ print(f"Added PAC host '{args.host}'")
251
+ return 0
252
+ except (ValueError, IndexError) as e:
253
+ print(f"Error: {e}", file=sys.stderr)
254
+ return 1
255
+
256
+
257
+ def cmd_rm(args, m) -> int:
258
+ try:
259
+ if args.local or args.remote:
260
+ # 'host' positional grabs the port value; shift back
261
+ try:
262
+ port = int(args.host) if args.port is None else args.port
263
+ except (TypeError, ValueError):
264
+ port = args.port
265
+ if args.local:
266
+ m.remove_local_forward(port)
267
+ print(f"Removed local forward on port {port}")
268
+ else:
269
+ m.remove_remote_forward(port)
270
+ print(f"Removed remote forward on port {port}")
271
+ else:
272
+ m.remove_pac_host(args.host)
273
+ print(f"Removed PAC host '{args.host}'")
274
+ return 0
275
+ except ValueError as e:
276
+ print(f"Error: {e}", file=sys.stderr)
277
+ return 1
278
+
279
+
280
+ def cmd_test(args, m) -> int:
281
+ if args.all:
282
+ results = m.test_all()
283
+ ok = all(r.success for r in results)
284
+ for r in results:
285
+ icon = "✓" if r.success else "✗"
286
+ latency = f" ({r.latency_ms:.0f}ms)" if r.latency_ms else ""
287
+ print(f" {icon} {r.target}{latency}: {r.message}")
288
+ return 0 if ok else 1
289
+ else:
290
+ result = m.test(args.target)
291
+ icon = "✓" if result.success else "✗"
292
+ latency = f" ({result.latency_ms:.0f}ms)" if result.latency_ms else ""
293
+ print(f"{icon} {result.target}{latency}: {result.message}")
294
+ return 0 if result.success else 1
295
+
296
+
297
+ def cmd_share(args, m) -> int:
298
+ try:
299
+ conn_tag = args.connection
300
+ if not conn_tag:
301
+ conns = m.list_config().connections
302
+ if not conns:
303
+ print("Error: no connections configured", file=sys.stderr)
304
+ return 1
305
+ conn_tag = conns[0].tag
306
+ info = m.share(Path(args.file), conn_tag=conn_tag, password=args.password or None, port=args.port or None)
307
+ print(f"Sharing: {info.file_path}")
308
+ print(f"URL: {info.url}")
309
+ print(f"Password: {info.password}")
310
+ print(f"Port: {info.port}")
311
+ print("\nFetch with:")
312
+ print(f" susops fetch {info.port} {info.password}")
313
+ return 0
314
+ except (FileNotFoundError, RuntimeError, ValueError) as e:
315
+ print(f"Error: {e}", file=sys.stderr)
316
+ return 1
317
+
318
+
319
+ def cmd_fetch(args, m) -> int:
320
+ try:
321
+ conn_tag = args.connection
322
+ if not conn_tag:
323
+ conns = m.list_config().connections
324
+ if not conns:
325
+ print("Error: no connections configured", file=sys.stderr)
326
+ return 1
327
+ conn_tag = conns[0].tag
328
+ outfile = Path(args.outfile) if args.outfile else None
329
+ result = m.fetch(port=args.port, password=args.password, conn_tag=conn_tag, outfile=outfile)
330
+ print(f"Downloaded to: {result}")
331
+ return 0
332
+ except Exception as e:
333
+ print(f"Error: {e}", file=sys.stderr)
334
+ return 1
335
+
336
+
337
+ def cmd_reset(args, m) -> int:
338
+ if not args.force:
339
+ answer = input("This will reset the entire workspace. Continue? [y/N] ")
340
+ if answer.lower() != "y":
341
+ print("Aborted.")
342
+ return 1
343
+ m.reset()
344
+ print("Workspace reset.")
345
+ return 0
346
+
347
+
348
+ def cmd_config(args, m) -> int:
349
+ import subprocess, shutil, os
350
+ config_path = m.workspace / "config.yaml"
351
+ editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or "nano"
352
+ for candidate in (editor, "nano", "vim", "vi"):
353
+ if shutil.which(candidate):
354
+ subprocess.call([candidate, str(config_path)])
355
+ return 0
356
+ print(f"Config file: {config_path}", file=sys.stderr)
357
+ print("Error: no editor found (set $EDITOR)", file=sys.stderr)
358
+ return 1
359
+
360
+
361
+ def cmd_chrome(args, m) -> int:
362
+ import subprocess, shutil
363
+ pac_url = m.get_pac_url()
364
+ if not pac_url:
365
+ print("Error: PAC server is not running", file=sys.stderr)
366
+ return 1
367
+ for browser in ("google-chrome-stable", "google-chrome", "chromium", "chromium-browser", "brave-browser"):
368
+ if shutil.which(browser):
369
+ subprocess.Popen([browser, f"--proxy-pac-url={pac_url}"])
370
+ return 0
371
+ print("Error: No Chrome/Chromium browser found", file=sys.stderr)
372
+ return 1
373
+
374
+
375
+ def cmd_chrome_proxy_settings(args, m) -> int:
376
+ import subprocess, shutil
377
+ url = "chrome://settings/system"
378
+ for browser in ("google-chrome-stable", "google-chrome", "chromium", "chromium-browser", "brave-browser"):
379
+ if shutil.which(browser):
380
+ subprocess.Popen([browser, url])
381
+ return 0
382
+ print("Error: No Chrome/Chromium browser found", file=sys.stderr)
383
+ return 1
384
+
385
+
386
+ def cmd_firefox(args, m) -> int:
387
+ import subprocess, shutil
388
+ pac_url = m.get_pac_url()
389
+ if not pac_url:
390
+ print("Error: PAC server is not running", file=sys.stderr)
391
+ return 1
392
+ profile_dir = m.workspace / "firefox_profile"
393
+ profile_dir.mkdir(exist_ok=True)
394
+ user_js = profile_dir / "user.js"
395
+ user_js.write_text(
396
+ f'user_pref("network.proxy.type", 2);\n'
397
+ f'user_pref("network.proxy.autoconfig_url", "{pac_url}");\n'
398
+ f'user_pref("network.proxy.no_proxies_on", "localhost, 127.0.0.1");\n'
399
+ )
400
+ if shutil.which("firefox"):
401
+ subprocess.Popen(["firefox", "-profile", str(profile_dir), "-no-remote"])
402
+ return 0
403
+ print("Error: Firefox not found", file=sys.stderr)
404
+ return 1
405
+
406
+
407
+ def cmd_guide(args, m) -> int:
408
+ config = m.list_config()
409
+
410
+ # Resolve target connection
411
+ if args.connection:
412
+ conn = next((c for c in config.connections if c.tag == args.connection), None)
413
+ if conn is None:
414
+ print(f"Error: connection '{args.connection}' not found", file=sys.stderr)
415
+ return 1
416
+ elif config.connections:
417
+ conn = config.connections[0]
418
+ else:
419
+ print("Error: no connections configured", file=sys.stderr)
420
+ return 1
421
+
422
+ # Get live SOCKS port from a running process; fall back to saved config value
423
+ port = 0
424
+ running = False
425
+ for cs in m.status().connection_statuses:
426
+ if cs.tag == conn.tag:
427
+ port = cs.socks_port
428
+ running = cs.running
429
+ break
430
+ if port == 0:
431
+ port = conn.socks_proxy_port
432
+
433
+ proxy = f"socks5h://127.0.0.1:{port}" if port else "socks5h://127.0.0.1:<port>"
434
+ port_label = str(port) if port else "<port>"
435
+
436
+ is_macos = sys.platform == "darwin"
437
+ # socksify (dante) is the macOS equivalent of proxychains4; it needs SOCKS_SERVER=host:port
438
+ proxy_cmd = f"SOCKS_SERVER=127.0.0.1:{port_label} socksify" if is_macos else "proxychains4"
439
+
440
+ # Warning banner when tunnel is not live
441
+ if not running:
442
+ print(f"Warning: tunnel '{conn.tag}' is not running — start with: susops start -c {conn.tag}")
443
+ if port:
444
+ print(" (port shown below is the configured value, not a live port)")
445
+ else:
446
+ print(" (start the tunnel first to see the assigned port)")
447
+ print()
448
+
449
+ header = f"susops guide — {conn.tag} ({proxy})"
450
+ print(header)
451
+ print("─" * len(header))
452
+ print()
453
+
454
+ # Shell env vars
455
+ print("# Shell (bash/zsh — add to ~/.zshrc or ~/.bash_profile)")
456
+ print(f"export ALL_PROXY={proxy}")
457
+ print("export NO_PROXY=localhost,127.0.0.1")
458
+ print()
459
+
460
+ # Homebrew
461
+ print("# Homebrew")
462
+ print(f"ALL_PROXY={proxy} brew install <package>")
463
+ print("# permanent alias (add to ~/.zshrc):")
464
+ print(f"alias susops-brew='ALL_PROXY={proxy} brew'")
465
+ print("# or set Homebrew's own env var:")
466
+ print(f"export HOMEBREW_ALL_PROXY={proxy}")
467
+ print()
468
+
469
+ # pip
470
+ print("# pip")
471
+ print(f"pip install --proxy {proxy} <package>")
472
+ print("# permanent alias (add to ~/.zshrc):")
473
+ print(f"alias susops-pip='pip --proxy {proxy}'")
474
+ print(f"alias susops-pip3='pip3 --proxy {proxy}'")
475
+ print("# or permanently via config (~/.config/pip/pip.conf):")
476
+ print("# [global]")
477
+ print(f"# proxy = {proxy}")
478
+ print()
479
+
480
+ # npm / yarn / pnpm (Node.js has no native SOCKS5 support)
481
+ print("# npm / yarn / pnpm")
482
+ print(f"{proxy_cmd} npm install <package>")
483
+ print("# permanent alias (add to ~/.zshrc):")
484
+ print(f"alias susops-npm='{proxy_cmd} npm'")
485
+ print(f"alias susops-yarn='{proxy_cmd} yarn'")
486
+ print(f"alias susops-pnpm='{proxy_cmd} pnpm'")
487
+ print()
488
+
489
+ # git
490
+ print("# git")
491
+ print(f"git config --global http.proxy {proxy}")
492
+ print("# undo: git config --global --unset http.proxy")
493
+ print()
494
+
495
+ # curl
496
+ print("# curl")
497
+ print(f"curl --proxy {proxy} <url>")
498
+ print("# permanent alias (add to ~/.zshrc):")
499
+ print(f"alias susops-curl='curl --proxy {proxy}'")
500
+ print(f"# or permanently via ~/.curlrc: proxy = {proxy}")
501
+ print()
502
+
503
+ # wget (no native SOCKS5 support)
504
+ print("# wget")
505
+ print(f"{proxy_cmd} wget <url>")
506
+ print("# permanent alias (add to ~/.zshrc):")
507
+ print(f"alias susops-wget='{proxy_cmd} wget'")
508
+ print()
509
+
510
+ # apt / apt-get (Linux only — no native SOCKS5 support)
511
+ if not is_macos:
512
+ print("# apt / apt-get")
513
+ print(f"sudo {proxy_cmd} apt-get install <pkg>")
514
+ print("# permanent alias (add to ~/.zshrc):")
515
+ print("# alias sudo='sudo ' # allows sudo to expand aliases")
516
+ print(f"alias susops-apt='{proxy_cmd} apt-get'")
517
+ print()
518
+
519
+ # Docker (Go's net/http supports SOCKS5 via ALL_PROXY natively)
520
+ print("# Docker")
521
+ print(f"ALL_PROXY={proxy} docker pull <image>")
522
+ print("# permanent alias (add to ~/.zshrc):")
523
+ print(f"alias susops-docker='ALL_PROXY={proxy} docker'")
524
+ if is_macos:
525
+ print("# permanent for daemon: Docker Desktop → Settings → Resources → Proxies")
526
+ print(f"# set SOCKS proxy to 127.0.0.1:{port_label}")
527
+ else:
528
+ print("# permanent for daemon (/etc/systemd/system/docker.service.d/proxy.conf):")
529
+ print("# [Service]")
530
+ print(f'# Environment="ALL_PROXY={proxy}"')
531
+ print('# Environment="NO_PROXY=localhost,127.0.0.1"')
532
+ print("# Then: sudo systemctl daemon-reload && sudo systemctl restart docker")
533
+ print()
534
+
535
+ # Generic wrapper
536
+ if is_macos:
537
+ print("# Generic (socksify — install: brew install dante)")
538
+ print(f"SOCKS_SERVER=127.0.0.1:{port_label} socksify <any-command>")
539
+ else:
540
+ print("# Generic (proxychains4)")
541
+ print("proxychains4 <any-command>")
542
+ print("# configure /etc/proxychains4.conf:")
543
+ print(f"# socks5 127.0.0.1 {port_label}")
544
+
545
+ return 0
546
+
547
+
548
+ def build_parser() -> argparse.ArgumentParser:
549
+ parser = argparse.ArgumentParser(
550
+ prog="susops",
551
+ description="SusOps — SSH SOCKS5 proxy manager",
552
+ )
553
+ parser.add_argument(
554
+ "-c", "--connection", metavar="TAG",
555
+ help="Target a specific connection by tag",
556
+ )
557
+ parser.add_argument(
558
+ "--version", action="version", version=f"susops {susops.__version__}",
559
+ )
560
+ parser.add_argument(
561
+ "-v", "--verbose", action="store_true",
562
+ help="Enable debug logging (events, state changes)",
563
+ )
564
+ sub = parser.add_subparsers(dest="command", metavar="COMMAND")
565
+
566
+ # start
567
+ p = sub.add_parser("start", help="Start SSH tunnel(s) and PAC server")
568
+ p.set_defaults(func=cmd_start)
569
+
570
+ # stop
571
+ p = sub.add_parser("stop", help="Stop SSH tunnel(s) and PAC server")
572
+ p.add_argument("--keep-ports", action="store_true", help="Preserve port assignments")
573
+ p.set_defaults(func=cmd_stop)
574
+
575
+ # restart
576
+ p = sub.add_parser("restart", help="Restart tunnel(s)")
577
+ p.set_defaults(func=cmd_restart)
578
+
579
+ # ps
580
+ p = sub.add_parser("ps", help="Show process status")
581
+ p.set_defaults(func=cmd_ps)
582
+
583
+ # ls
584
+ p = sub.add_parser("ls", help="List all config")
585
+ p.set_defaults(func=cmd_ls)
586
+
587
+ # add-connection
588
+ p = sub.add_parser("add-connection", help="Add a new SSH connection")
589
+ p.add_argument("tag", help="Unique identifier for this connection")
590
+ p.add_argument("ssh_host", metavar="SSH_HOST", help="SSH host string (user@host)")
591
+ p.add_argument("socks_port", metavar="SOCKS_PORT", type=int, nargs="?", default=0,
592
+ help="SOCKS port (0 = auto-assign)")
593
+ p.set_defaults(func=cmd_add_connection)
594
+
595
+ # rm-connection
596
+ p = sub.add_parser("rm-connection", help="Remove an SSH connection")
597
+ p.add_argument("tag", help="Connection tag to remove")
598
+ p.set_defaults(func=cmd_rm_connection)
599
+
600
+ # add (PAC host or port forward)
601
+ p = sub.add_parser("add", help="Add PAC host or port forward")
602
+ p.add_argument("host", nargs="?", help="Hostname, wildcard, or CIDR")
603
+ p.add_argument("-l", "--local", action="store_true", help="Add local forward")
604
+ p.add_argument("-r", "--remote", action="store_true", help="Add remote forward")
605
+ p.add_argument("local_port", type=int, nargs="?", metavar="LOCAL_PORT")
606
+ p.add_argument("remote_port", type=int, nargs="?", metavar="REMOTE_PORT")
607
+ p.add_argument("forward_tag", nargs="?", metavar="TAG", help="Forward label")
608
+ p.add_argument("local_addr", nargs="?", metavar="LOCAL_ADDR", default=None)
609
+ p.add_argument("remote_addr", nargs="?", metavar="REMOTE_ADDR", default=None)
610
+ p.set_defaults(func=cmd_add)
611
+
612
+ # rm (PAC host or port forward)
613
+ p = sub.add_parser("rm", help="Remove PAC host or port forward")
614
+ p.add_argument("host", nargs="?", help="Hostname to remove")
615
+ p.add_argument("-l", "--local", action="store_true")
616
+ p.add_argument("-r", "--remote", action="store_true")
617
+ p.add_argument("port", type=int, nargs="?", metavar="PORT")
618
+ p.set_defaults(func=cmd_rm)
619
+
620
+ # test
621
+ p = sub.add_parser("test", help="Test connectivity")
622
+ p.add_argument("target", nargs="?", default="", help="Hostname or port to test")
623
+ p.add_argument("--all", action="store_true", help="Test all PAC hosts")
624
+ p.set_defaults(func=cmd_test)
625
+
626
+ # share
627
+ p = sub.add_parser("share", help="Share an encrypted file")
628
+ p.add_argument("file", help="File to share")
629
+ p.add_argument("password", nargs="?", default=None, help="Encryption password (auto-generated if omitted)")
630
+ p.add_argument("port", type=int, nargs="?", default=0, help="Port to listen on (0 = auto)")
631
+ p.set_defaults(func=cmd_share)
632
+
633
+ # fetch
634
+ p = sub.add_parser("fetch", help="Fetch an encrypted shared file")
635
+ p.add_argument("port", type=int, help="Port the share is on")
636
+ p.add_argument("password", help="Decryption password")
637
+ p.add_argument("outfile", nargs="?", default=None, help="Output file path")
638
+ p.set_defaults(func=cmd_fetch)
639
+
640
+ # reset
641
+ p = sub.add_parser("reset", help="Reset workspace (destructive)")
642
+ p.add_argument("--force", action="store_true", help="Skip confirmation prompt")
643
+ p.set_defaults(func=cmd_reset)
644
+
645
+ # config
646
+ sub.add_parser("config", help="Open config file in $EDITOR").set_defaults(func=cmd_config)
647
+
648
+ # chrome / firefox
649
+ sub.add_parser("chrome", help="Launch Chrome with PAC proxy").set_defaults(func=cmd_chrome)
650
+ sub.add_parser("chrome-proxy-settings", help="Open Chrome proxy settings").set_defaults(
651
+ func=cmd_chrome_proxy_settings)
652
+ sub.add_parser("firefox", help="Launch Firefox with PAC proxy").set_defaults(func=cmd_firefox)
653
+
654
+ # guide
655
+ sub.add_parser("guide", help="Print proxy setup guide for common tools").set_defaults(func=cmd_guide)
656
+
657
+ return parser
658
+
659
+
660
+ def dispatch(args: argparse.Namespace) -> int:
661
+ """Dispatch a parsed command to its handler. Returns exit code."""
662
+ if not hasattr(args, "func") or args.func is None:
663
+ return 1
664
+ m = _manager(args)
665
+ return args.func(args, m)