qualys-cli 0.1.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.
qualys_cli/cli.py ADDED
@@ -0,0 +1,1168 @@
1
+ from __future__ import annotations
2
+
3
+ import signal as _signal
4
+ import sys
5
+ from typing import Any
6
+
7
+ import typer
8
+ from rich import box
9
+ from rich.align import Align
10
+ from rich.console import Console
11
+ from rich.panel import Panel
12
+ from rich.rule import Rule
13
+ from rich.table import Table
14
+ from rich.text import Text
15
+
16
+ from . import audit as _audit
17
+ from . import config as _cfg
18
+ from . import extras as _extras
19
+ from .commands import asset, ca, cs, csam, etm, pc, pm, scanauth, sub, tc, user, vm, was
20
+
21
+ _err = Console(stderr=True)
22
+
23
+
24
+ def _qualys_cli_version() -> str:
25
+ try:
26
+ from importlib.metadata import version
27
+ return version("qualys-cli")
28
+ except Exception:
29
+ return "0.0.0"
30
+
31
+ app = typer.Typer(
32
+ name="qualys-cli",
33
+ help="Qualys API CLI — VM, PC, PM, ETM, WAS, TC, CA, CS, CSAM and more.",
34
+ invoke_without_command=True,
35
+ pretty_exceptions_enable=False,
36
+ rich_markup_mode="rich",
37
+ )
38
+
39
+ # Qualys brand red (#ed2e26).
40
+ _QUALYS_RED = "#ed2e26"
41
+
42
+ # "QUALYS" wordmark in block capitals (the Q keeps its little tail).
43
+ _WORDMARK = """\
44
+ ██████ ██ ██ █████ ██ ██ ██ ███████
45
+ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
46
+ ██ ██ ██ ██ ███████ ██ ████ ███████
47
+ ██ ▄▄ ██ ██ ██ ██ ██ ██ ██ ██
48
+ ██████ ██████ ██ ██ ███████ ██ ███████
49
+ ▀▀\
50
+ """
51
+
52
+
53
+ def _print_banner() -> None:
54
+ if not _err.is_terminal:
55
+ return
56
+ try:
57
+ from importlib.metadata import version
58
+ ver = version("qualys-cli")
59
+ except Exception:
60
+ ver = "0.1.0"
61
+
62
+ _err.print()
63
+ _err.print(Align.center(Text(_WORDMARK, style=f"bold {_QUALYS_RED}")))
64
+ _err.print(Rule(
65
+ f"[dim]v{ver} · A unified command-line interface for the Qualys REST APIs[/dim]",
66
+ style=_QUALYS_RED,
67
+ ))
68
+ _err.print()
69
+
70
+ # ── Capabilities summary ────────────────────────────────────────────────
71
+ cap = Table(
72
+ box=None, show_header=False, show_edge=False,
73
+ padding=(0, 1), pad_edge=False,
74
+ )
75
+ cap.add_column(no_wrap=True) # icon
76
+ cap.add_column(no_wrap=True, min_width=20) # label
77
+ cap.add_column(no_wrap=False) # description
78
+ cap.add_row(
79
+ "[bold #ed2e26]●[/bold #ed2e26]",
80
+ "[bold]Vuln & Compliance[/bold]",
81
+ "[red1]vm[/red1] · [red1]pc[/red1] · [red1]was[/red1] "
82
+ "[dim]scans, hosts, KB, policies, posture, web-app findings[/dim]",
83
+ )
84
+ cap.add_row(
85
+ "[bold #ed2e26]●[/bold #ed2e26]",
86
+ "[bold]Cloud Security[/bold]",
87
+ "[dodger_blue1]tc[/dodger_blue1] · [dodger_blue1]ca[/dodger_blue1] · "
88
+ "[dodger_blue1]cs[/dodger_blue1] · [dodger_blue1]csam[/dodger_blue1] "
89
+ "[dim]CSPM, agents, containers, asset & EASM inventory[/dim]",
90
+ )
91
+ cap.add_row(
92
+ "[bold #ed2e26]●[/bold #ed2e26]",
93
+ "[bold]Patch & Risk[/bold]",
94
+ "[dark_orange]pm[/dark_orange] · [dark_orange]etm[/dark_orange] "
95
+ "[dim]deployment jobs, mitigations, TruRisk reports[/dim]",
96
+ )
97
+ cap.add_row(
98
+ "[bold #ed2e26]●[/bold #ed2e26]",
99
+ "[bold]Asset & User Mgmt[/bold]",
100
+ "[medium_spring_green]asset[/medium_spring_green] · "
101
+ "[medium_spring_green]user[/medium_spring_green] · "
102
+ "[medium_spring_green]scanauth[/medium_spring_green] · "
103
+ "[medium_spring_green]sub[/medium_spring_green] "
104
+ "[dim]IPs, networks, groups, auth records, subscription[/dim]",
105
+ )
106
+ _err.print(cap)
107
+ _err.print()
108
+
109
+ # ── Quick-start hints ───────────────────────────────────────────────────
110
+ hint = Table(
111
+ box=None, show_header=False, show_edge=False,
112
+ padding=(0, 1), pad_edge=False,
113
+ )
114
+ hint.add_column(no_wrap=True) # arrow
115
+ hint.add_column(no_wrap=True, min_width=20) # description
116
+ hint.add_column(no_wrap=False) # command
117
+ hint.add_row("[bold yellow]→[/bold yellow]", "[dim]Get started[/dim]",
118
+ "[cyan]qualys-cli configure[/cyan]")
119
+ hint.add_row("[bold yellow]→[/bold yellow]", "[dim]Browse all modules[/dim]",
120
+ "[cyan]qualys-cli help[/cyan]")
121
+ hint.add_row("[bold yellow]→[/bold yellow]", "[dim]Switch output mode[/dim]",
122
+ "[cyan]qualys-cli mode agentic[/cyan] "
123
+ "[dim](JSON for scripts/agents)[/dim]")
124
+ hint.add_row("[bold yellow]→[/bold yellow]", "[dim]Per-command flags[/dim]",
125
+ "[cyan]qualys-cli <module> <cmd> --help[/cyan]")
126
+ _err.print(hint)
127
+
128
+ _err.print()
129
+ _err.print(Rule(
130
+ "[dim]209 commands across 13 Qualys APIs · "
131
+ "rich tables for humans, clean JSON for agents · "
132
+ "JWT auto-refresh · retry/backoff · OS-keyring credential storage[/dim]",
133
+ style=_QUALYS_RED,
134
+ ))
135
+ _err.print()
136
+
137
+ # ── Register every command group into a named Typer help panel ─────────────
138
+ # Typer's `rich_help_panel` argument groups entries into separately-bordered
139
+ # boxes in the root --help output. Without this, all 27 sub-apps render as
140
+ # one flat list, which is hard to scan. Panels mirror the four operational
141
+ # domains (Vuln & Compliance, Cloud Security, Patch & Risk, Asset & User
142
+ # Management) plus an "Operator & Automation" section for the cross-cutting
143
+ # utility commands and a "Configuration & Diagnostics" section for the local
144
+ # top-level entries (configure / mode / profiles / doctor / etc.).
145
+
146
+ _PANEL_VC = "Vulnerability & Compliance"
147
+ _PANEL_CS = "Cloud Security"
148
+ _PANEL_PR = "Patch & Risk"
149
+ _PANEL_AU = "Asset & User Management"
150
+ _PANEL_OP = "Operator & Automation"
151
+ _PANEL_AGENT = "Agent & Integration Bridges"
152
+ _PANEL_CFG = "Configuration & Diagnostics"
153
+
154
+ # Vulnerability & Compliance APIs
155
+ app.add_typer(vm.app, rich_help_panel=_PANEL_VC)
156
+ app.add_typer(pc.app, rich_help_panel=_PANEL_VC)
157
+ app.add_typer(was.app, rich_help_panel=_PANEL_VC)
158
+
159
+ # Cloud Security APIs
160
+ app.add_typer(tc.app, rich_help_panel=_PANEL_CS)
161
+ app.add_typer(ca.app, rich_help_panel=_PANEL_CS)
162
+ app.add_typer(cs.app, rich_help_panel=_PANEL_CS)
163
+ app.add_typer(csam.app, rich_help_panel=_PANEL_CS)
164
+
165
+ # Patch & Risk APIs
166
+ app.add_typer(pm.app, rich_help_panel=_PANEL_PR)
167
+ app.add_typer(etm.app, rich_help_panel=_PANEL_PR)
168
+
169
+ # Asset & User Management APIs
170
+ app.add_typer(asset.app, rich_help_panel=_PANEL_AU)
171
+ app.add_typer(scanauth.app, rich_help_panel=_PANEL_AU)
172
+ app.add_typer(user.app, rich_help_panel=_PANEL_AU)
173
+ app.add_typer(sub.app, rich_help_panel=_PANEL_AU)
174
+
175
+ # Operator & Automation utilities — preflight, audit, history, batch
176
+ app.add_typer(_extras.doctor_app, name="doctor", rich_help_panel=_PANEL_OP)
177
+ app.add_typer(_extras.health_app, name="health", rich_help_panel=_PANEL_OP)
178
+ app.add_typer(_extras.stats_app, name="stats", rich_help_panel=_PANEL_OP)
179
+ app.add_typer(_extras.verify_audit_app, name="verify-audit", rich_help_panel=_PANEL_OP)
180
+ app.add_typer(_extras.rotate_jwt_app, name="rotate-jwt", rich_help_panel=_PANEL_OP)
181
+ app.add_typer(_extras.last_app, name="last", rich_help_panel=_PANEL_OP)
182
+ app.add_typer(_extras.replay_app, name="replay", rich_help_panel=_PANEL_OP)
183
+ app.add_typer(_extras.watch_app, name="watch", rich_help_panel=_PANEL_OP)
184
+ app.add_typer(_extras.batch_app, name="batch", rich_help_panel=_PANEL_OP)
185
+ app.add_typer(_extras.query_app, name="query", rich_help_panel=_PANEL_OP)
186
+ app.add_typer(_extras.cache_app, name="cache", rich_help_panel=_PANEL_OP)
187
+
188
+ # Agent / integration bridges (MCP, HTTP server, shell completion)
189
+ app.add_typer(_extras.mcp_app, name="mcp", rich_help_panel=_PANEL_AGENT)
190
+ app.add_typer(_extras.serve_app, name="serve", rich_help_panel=_PANEL_AGENT)
191
+ app.add_typer(_extras.completion_app, name="completion", rich_help_panel=_PANEL_AGENT)
192
+
193
+
194
+ def _version_callback(value: bool) -> None:
195
+ if value:
196
+ try:
197
+ from importlib.metadata import version
198
+ ver = version("qualys-cli")
199
+ except Exception:
200
+ ver = "0.1.0"
201
+ _err.print(f"[bold]qualys-cli[/bold] [cyan]{ver}[/cyan]")
202
+ raise typer.Exit()
203
+
204
+
205
+ _NO_AUTH_CMDS = {
206
+ "configure", "profiles", "help", "mode",
207
+ # Local-only utility commands that don't need credentials to be useful
208
+ "stats", "verify-audit", "last", "replay", "completion", "cache",
209
+ "query", "batch", "watch",
210
+ }
211
+
212
+
213
+ def _apply_profile_mode(profile_name: str) -> None:
214
+ """Read output_mode from the profile and patch all Console instances."""
215
+ import os
216
+
217
+ from rich.console import Console as _Console
218
+
219
+ from . import client as _client_mod
220
+ from . import formatters as _fmt
221
+
222
+ mode = os.environ.get("QUALYS_OUTPUT_MODE", "")
223
+ if not mode:
224
+ try:
225
+ p = _cfg.get_profile(profile_name)
226
+ mode = p.output_mode
227
+ except Exception:
228
+ mode = "interactive"
229
+
230
+ if mode == "agentic":
231
+ force, no_color = False, True
232
+ else: # interactive
233
+ force, no_color = True, False
234
+
235
+ global _err
236
+ _err = _Console(stderr=True, force_terminal=force, no_color=no_color)
237
+ _fmt._console = _Console(force_terminal=force, no_color=no_color)
238
+ _fmt._err = _Console(stderr=True, force_terminal=force, no_color=no_color)
239
+ _client_mod._console = _Console(stderr=True, force_terminal=force, no_color=no_color)
240
+
241
+
242
+ def _maybe_auto_configure(profile: str) -> None:
243
+ """If credentials are missing and we're in a TTY, run the configure wizard automatically."""
244
+ # In non-TTY mode errors surface as JSON — don't prompt
245
+ if not _err.is_terminal:
246
+ return
247
+ try:
248
+ _cfg.get_profile(profile)
249
+ return # credentials OK
250
+ except ValueError:
251
+ pass
252
+
253
+ _err.print()
254
+ _err.print(Panel(
255
+ f"No credentials found for profile [bold]{profile}[/bold].\n"
256
+ "Let's configure them now.",
257
+ title="[bold yellow] First-time Setup [/bold yellow]",
258
+ border_style="yellow",
259
+ expand=False,
260
+ ))
261
+ _err.print()
262
+
263
+ username = typer.prompt("Username")
264
+ password = typer.prompt("Password", hide_input=True, confirmation_prompt=True)
265
+ _save_and_show(profile, username, password, output_mode="interactive", continuing=True)
266
+
267
+
268
+ @app.callback()
269
+ def _root(
270
+ ctx: typer.Context,
271
+ profile: str = typer.Option(
272
+ "default", "--profile", "-p", envvar="QUALYS_PROFILE",
273
+ help="Named credential profile",
274
+ ),
275
+ version: bool | None = typer.Option(
276
+ None, "--version", "-V",
277
+ is_eager=True, callback=_version_callback,
278
+ help="Show version and exit",
279
+ ),
280
+ dry_run: bool = typer.Option(
281
+ False, "--dry-run", "--explain",
282
+ help="Print the resolved HTTP request and exit without sending it",
283
+ ),
284
+ ) -> None:
285
+ """Qualys API CLI."""
286
+ ctx.ensure_object(dict)
287
+ ctx.obj["profile_name"] = profile
288
+
289
+ # Apply output mode before any output so banners/JSON/spinners are correct.
290
+ _apply_profile_mode(profile)
291
+
292
+ # Always assign so a test using --dry-run doesn't leak DRY_RUN=True into
293
+ # subsequent in-process invocations (CliRunner reuses module state).
294
+ from . import client as _client_mod
295
+ _client_mod.DRY_RUN = bool(dry_run)
296
+
297
+ # Stamp the active profile/username onto the audit log as soon as we know
298
+ # them. The username may be unavailable (no config yet) — that's fine,
299
+ # subsequent log lines still carry the correlation ID.
300
+ try:
301
+ prof = _cfg.get_profile(profile)
302
+ username = prof.username
303
+ except Exception:
304
+ prof = None
305
+ username = ""
306
+ _audit.init(profile=profile, username=username)
307
+
308
+ # Surface schema warnings on the active profile (typos in config.toml etc.)
309
+ # once per invocation, never blocking the command itself.
310
+ for warn in _cfg.collect_warnings():
311
+ if _err.is_terminal:
312
+ _err.print(f" [yellow]⚠ config:[/yellow] [dim]{warn}[/dim]")
313
+
314
+ # Wire profile-driven output redaction (PII / customer-field protection).
315
+ # Always assign LOCKED_MODE (even when there's no profile yet) so a
316
+ # locked profile from an earlier in-process invocation can't leak into
317
+ # a later one that has no profile (relevant for CliRunner-based tests
318
+ # that reuse module state, same concern as DRY_RUN above).
319
+ from . import formatters as _fmt
320
+ _fmt.LOCKED_MODE = bool(prof and prof.locked_mode)
321
+ if prof and prof.redact_fields:
322
+ _fmt.REDACT_FIELDS = list(prof.redact_fields)
323
+
324
+ # Locked-mode and scope-restricted profile enforcement.
325
+ if prof:
326
+ invoked = ctx.invoked_subcommand or ""
327
+
328
+ # Locked mode: refuses free-form --output-file paths (enforced in
329
+ # formatters.guard_output_file / output()), suppresses verbose request
330
+ # logging (enforced in client.make(), which zeroes `verbose` for a
331
+ # locked profile), and refuses to run at all against a profile that
332
+ # still has a plaintext password in config.toml.
333
+ if prof.locked_mode:
334
+ has_plaintext = any(
335
+ "plaintext" in w and f"'{profile}'" in w for w in _cfg.collect_warnings()
336
+ )
337
+ if has_plaintext and invoked not in _NO_AUTH_CMDS:
338
+ _err.print(Panel(
339
+ f"Profile [bold]{profile}[/bold] is locked but still has a "
340
+ "plaintext password in config.toml.\n\n"
341
+ "Run [cyan]qualys-cli configure[/cyan] to move it into the "
342
+ "OS keyring before using a locked profile.",
343
+ title="[bold red] Locked mode — plaintext password refused [/bold red]",
344
+ border_style="red", expand=False,
345
+ ))
346
+ raise typer.Exit(3)
347
+
348
+ if prof.allowed_modules and invoked and invoked not in _NO_AUTH_CMDS \
349
+ and invoked not in {*prof.allowed_modules, "doctor", "health"}:
350
+ _err.print(Panel(
351
+ f"Profile [bold]{profile}[/bold] is scope-restricted and may not invoke "
352
+ f"the [bold]{invoked}[/bold] module.\n\n"
353
+ f"Allowed: [cyan]{', '.join(prof.allowed_modules)}[/cyan]",
354
+ title="[bold red] Scope restriction [/bold red]",
355
+ border_style="red", expand=False,
356
+ ))
357
+ raise typer.Exit(3)
358
+
359
+ if ctx.invoked_subcommand is None:
360
+ _print_banner()
361
+ _err.print(ctx.get_help())
362
+ raise typer.Exit()
363
+
364
+ if ctx.invoked_subcommand not in _NO_AUTH_CMDS:
365
+ _maybe_auto_configure(profile)
366
+
367
+
368
+ _OP_STYLES: dict[str, str] = {
369
+ "list": "bold cyan",
370
+ "get": "bold cyan",
371
+ "count": "bold cyan",
372
+ "create": "bold green",
373
+ "update": "bold green",
374
+ "patch": "bold green",
375
+ "import": "bold green",
376
+ "add": "bold green",
377
+ "manage": "bold green",
378
+ "launch": "bold yellow",
379
+ "run": "bold yellow",
380
+ "fetch": "bold yellow",
381
+ "scan": "bold yellow",
382
+ "delete": "bold red",
383
+ "remove": "bold red",
384
+ "cancel": "bold red",
385
+ "purge": "bold red",
386
+ "export": "bold blue",
387
+ "download": "bold blue",
388
+ "config": "bold magenta",
389
+ "info": "bold cyan",
390
+ "sync": "bold green",
391
+ "activate": "bold green",
392
+ "deactivate": "bold red",
393
+ "enable": "bold green",
394
+ "disable": "bold red",
395
+ }
396
+
397
+ # Per-module metadata for the in-CLI help screen. Each entry:
398
+ # color, friendly title, domain (groups modules into colored sections)
399
+ _VC = "Vulnerability & Compliance"
400
+ _CS = "Cloud Security"
401
+ _PR = "Patch & Risk"
402
+ _AU = "Asset & User Management"
403
+ _MODULE_DISPLAY: dict[str, tuple[str, str, str]] = {
404
+ "vm": ("red1", "Vulnerability Management", _VC),
405
+ "pc": ("red1", "Policy Compliance", _VC),
406
+ "was": ("red1", "Web App Scanning", _VC),
407
+ "tc": ("dodger_blue1", "TotalCloud CSPM", _CS),
408
+ "ca": ("dodger_blue1", "Cloud Agent", _CS),
409
+ "cs": ("dodger_blue1", "Container Security", _CS),
410
+ "csam": ("dodger_blue1", "CyberSecurity Asset Mgmt", _CS),
411
+ "pm": ("dark_orange", "Patch Management", _PR),
412
+ "etm": ("dark_orange", "Enterprise TruRisk Mgmt", _PR),
413
+ "asset": ("medium_spring_green", "IP / Network / Group Mgmt", _AU),
414
+ "user": ("medium_spring_green", "User Management", _AU),
415
+ "scanauth": ("medium_spring_green", "Scan Auth Records", _AU),
416
+ "sub": ("medium_spring_green", "Subscription", _AU),
417
+ }
418
+
419
+ # Order of domain sections in the help screen
420
+ _DOMAIN_ORDER = [
421
+ "Vulnerability & Compliance",
422
+ "Cloud Security",
423
+ "Patch & Risk",
424
+ "Asset & User Management",
425
+ ]
426
+ _DOMAIN_RULE_COLORS = {
427
+ "Vulnerability & Compliance": "red1",
428
+ "Cloud Security": "dodger_blue1",
429
+ "Patch & Risk": "dark_orange",
430
+ "Asset & User Management": "medium_spring_green",
431
+ }
432
+
433
+
434
+ _BUCKET_REWRITES: dict[str, str] = {
435
+ # Map command-final tokens to a sensible action bucket so help stays tidy
436
+ "scan-again": "run",
437
+ "edit-severity": "manage",
438
+ "ignore": "manage",
439
+ "reactivate": "manage",
440
+ "restore": "manage",
441
+ "retest": "run",
442
+ "retest-status": "get",
443
+ "results": "fetch",
444
+ "burp": "import",
445
+ "owasp-zap": "import",
446
+ "add-to-subscription": "create",
447
+ "create-webapp": "create",
448
+ "create-scan": "create",
449
+ "list-by-asset": "list",
450
+ "list-easm": "list",
451
+ "list-scan": "list",
452
+ "import-asset": "import",
453
+ "import-app": "import",
454
+ "sn-export": "export",
455
+ "change-status": "manage",
456
+ "bulk-delete": "delete",
457
+ "bulk-activate": "manage",
458
+ "bulk-deactivate": "manage",
459
+ "launch-ods": "launch",
460
+ "user-prefs": "config",
461
+ "download-resource": "download",
462
+ "data": "get",
463
+ "products": "list",
464
+ "columns": "get",
465
+ "runs": "list",
466
+ "summary": "get",
467
+ "findings": "list",
468
+ "vulns": "list",
469
+ "software": "list",
470
+ "generate": "run",
471
+ "change-password": "manage",
472
+ "orgs": "list",
473
+ }
474
+
475
+
476
+ def _walk_module(t: typer.Typer, prefix: str = "") -> list[tuple[str, str]]:
477
+ """Return [(operation_name, action_bucket), ...] for every command under a Typer.
478
+
479
+ operation_name is the dotted command path inside the module
480
+ (e.g. "scan list", "host detection", "act-key create").
481
+ action_bucket is the bucket label used to colour and group the entry
482
+ (e.g. "list", "create", "delete").
483
+ """
484
+ out: list[tuple[str, str]] = []
485
+ for c in (t.registered_commands or []):
486
+ if isinstance(c.name, str):
487
+ cname = c.name
488
+ elif c.callback is not None:
489
+ cname = c.callback.__name__.replace("_", "-")
490
+ else:
491
+ continue
492
+ full = f"{prefix} {cname}".strip()
493
+ # Pick the bucket from the final token; rewrites map oddities into
494
+ # canonical buckets (e.g. scan-again → run, owasp-zap → import).
495
+ bucket = _BUCKET_REWRITES.get(cname, cname)
496
+ out.append((full, bucket))
497
+ for g in (t.registered_groups or []):
498
+ sub = g.typer_instance
499
+ if sub is None:
500
+ continue
501
+ gname = sub.info.name if sub.info and isinstance(sub.info.name, str) else g.name
502
+ if not isinstance(gname, str):
503
+ continue
504
+ out.extend(_walk_module(sub, f"{prefix} {gname}".strip()))
505
+ return out
506
+
507
+
508
+ def _bucket_ops(ops: list[tuple[str, str]]) -> list[tuple[str, str]]:
509
+ """Group commands by their action word into [(label, "cmd1 · cmd2"), ...]."""
510
+ buckets: dict[str, list[str]] = {}
511
+ for op, action in ops:
512
+ buckets.setdefault(action, []).append(op)
513
+ # Order: list-style first, then mutate, then delete. Falls back to alpha.
514
+ order = [
515
+ "info", "list", "count", "get", "status",
516
+ "create", "add", "import", "patch", "update", "activate", "enable", "sync",
517
+ "launch", "run", "fetch", "scan", "scan-again",
518
+ "export", "download",
519
+ "deactivate", "disable", "cancel", "purge", "remove", "delete",
520
+ ]
521
+ out: list[tuple[str, str]] = []
522
+ seen: set[str] = set()
523
+ for label in order:
524
+ if label in buckets:
525
+ out.append((label, " · ".join(buckets[label])))
526
+ seen.add(label)
527
+ for label in sorted(buckets):
528
+ if label not in seen:
529
+ out.append((label, " · ".join(buckets[label])))
530
+ return out
531
+
532
+
533
+ def _module_table(
534
+ color: str,
535
+ modules: list[tuple[str, str, list[tuple[str, str]]]],
536
+ ) -> Table:
537
+ """Build a table for one module group.
538
+
539
+ Each module gets a bold header row followed by indented operation rows,
540
+ one row per action type, color-coded by type.
541
+ """
542
+ t = Table(box=None, show_header=False, show_edge=False, padding=(0, 1), pad_edge=True)
543
+ t.add_column("cmd", min_width=12, no_wrap=True)
544
+ t.add_column("label", min_width=10, no_wrap=True)
545
+ t.add_column("cmds", no_wrap=False)
546
+
547
+ for i, (cmd, name, ops) in enumerate(modules):
548
+ if i:
549
+ t.add_row("", "", "") # blank spacer between modules
550
+ # Module header row
551
+ t.add_row(
552
+ f"[bold {color}]{cmd}[/bold {color}]",
553
+ f"[bold white]{name}[/bold white]",
554
+ "",
555
+ )
556
+ # One row per operation bucket
557
+ for label, cmds in ops:
558
+ style = _OP_STYLES.get(label, "bold white")
559
+ t.add_row(
560
+ "",
561
+ f" [{style}]{label}[/{style}]",
562
+ f"[dim]{cmds}[/dim]",
563
+ )
564
+ return t
565
+
566
+
567
+ @app.command("help", rich_help_panel=_PANEL_CFG)
568
+ def help_cmd(ctx: typer.Context) -> None:
569
+ """Show detailed help: modules grouped by domain, with color-coded operations.
570
+
571
+ The list is generated by introspecting the live Typer app, so it stays in
572
+ sync with the actual registered commands automatically.
573
+ """
574
+ _print_banner()
575
+
576
+ # Collect every module's commands by walking the live Typer app
577
+ by_module: dict[str, list[tuple[str, str]]] = {}
578
+ for grp in (app.registered_groups or []):
579
+ sub = grp.typer_instance
580
+ if sub is None:
581
+ continue
582
+ mod_key = sub.info.name if sub.info else grp.name
583
+ if not isinstance(mod_key, str) or mod_key not in _MODULE_DISPLAY:
584
+ continue
585
+ by_module[mod_key] = _walk_module(sub)
586
+
587
+ # Group modules by their domain section
588
+ by_domain: dict[str, list[tuple[str, str, list[tuple[str, str]]]]] = {}
589
+ for mod_key, ops in by_module.items():
590
+ color, title, domain = _MODULE_DISPLAY[mod_key]
591
+ by_domain.setdefault(domain, []).append((mod_key, title, _bucket_ops(ops)))
592
+
593
+ # Render each domain section in the documented order
594
+ for i, domain in enumerate(_DOMAIN_ORDER):
595
+ if domain not in by_domain:
596
+ continue
597
+ if i:
598
+ _err.print()
599
+ rule_color = _DOMAIN_RULE_COLORS[domain]
600
+ _err.print(Rule(
601
+ f"[bold {rule_color}] {domain} [/bold {rule_color}]",
602
+ style=rule_color,
603
+ ))
604
+ # All entries in a domain share the same accent colour
605
+ _err.print(_module_table(rule_color, by_domain[domain]))
606
+
607
+ # ── Output formats ──────────────────────────────────────────────────────
608
+ _err.print()
609
+ _err.print(Rule("[bold] Output Formats [/bold]", style="dim"))
610
+ fmt = Table(
611
+ box=box.SIMPLE_HEAD, show_header=True, border_style="dim",
612
+ show_edge=False, padding=(0, 1), header_style="bold",
613
+ )
614
+ fmt.add_column("Flag / Context", style="cyan", min_width=28)
615
+ fmt.add_column("Behaviour", style="white")
616
+ fmt.add_row("qualys-cli mode",
617
+ "Show or change the persistent output mode for this profile")
618
+ fmt.add_row("qualys-cli mode interactive",
619
+ "Default: rich tables · colours · spinners")
620
+ fmt.add_row("qualys-cli mode agentic",
621
+ "Clean JSON · no decoration — for scripts and AI agents")
622
+ fmt.add_row("--format json", "One-shot: full JSON to stdout")
623
+ fmt.add_row("--format jsonl", "One JSON object per line (NDJSON)")
624
+ fmt.add_row("--format yaml", "Full YAML to stdout")
625
+ fmt.add_row("--format table", "Rich table (forced)")
626
+ fmt.add_row("--output-file out.json",
627
+ "Dump full data to file (JSON or YAML by extension)")
628
+ fmt.add_row("--limit 0", "Remove 25-row cap — return all rows")
629
+ fmt.add_row("--profile NAME", "Use a named credential profile")
630
+ fmt.add_row("-v / -vv",
631
+ "Log requests and response sizes (secrets never logged)")
632
+ _err.print(fmt)
633
+
634
+ # ── Quick examples ──────────────────────────────────────────────────────
635
+ _err.print()
636
+ _err.print(Rule("[bold] Quick Examples [/bold]", style="dim"))
637
+ ex = Table(
638
+ box=None, show_header=False, show_edge=False, padding=(0, 1),
639
+ )
640
+ ex.add_column("desc", style="dim", min_width=30)
641
+ ex.add_column("cmd", style="cyan")
642
+
643
+ examples = [
644
+ ("First-time setup", "qualys-cli configure"),
645
+ ("VM host detections", "qualys-cli vm host detection --ips 10.0.0.0/24"),
646
+ ("Launch a VM scan",
647
+ 'qualys-cli vm scan launch --title "Weekly" --ip 10.0.1.0/24 --option-id 123'),
648
+ ("List patch jobs (JSON)", "qualys-cli pm job list --format json"),
649
+ ("All CSAM assets, no row cap", "qualys-cli csam asset list --limit 0"),
650
+ ("Pipe-friendly output", "qualys-cli csam asset list | jq '.[].name'"),
651
+ ("TotalCloud AWS connectors", "qualys-cli tc connector list --provider AWS"),
652
+ ("Container images with vulns",
653
+ 'qualys-cli cs image list --filter "vulns.severity5Count:>0"'),
654
+ ("Cloud agent list", "qualys-cli ca agent list"),
655
+ ("Subscription info", "qualys-cli sub info"),
656
+ ]
657
+ for desc, cmd in examples:
658
+ ex.add_row(desc, cmd)
659
+ _err.print(ex)
660
+ _err.print()
661
+
662
+
663
+ def _save_and_show(
664
+ profile: str,
665
+ username: str,
666
+ password: str,
667
+ output_mode: str = "interactive",
668
+ continuing: bool = False,
669
+ ) -> None:
670
+ """Save credentials and display a confirmation panel with detected platform info."""
671
+ from .platforms import detect as _detect_platform
672
+
673
+ in_keyring = _cfg.save_profile(
674
+ profile, username=username, password=password, output_mode=output_mode,
675
+ )
676
+
677
+ try:
678
+ plat = _detect_platform(username)
679
+ api_url = plat.api_url
680
+ gateway_url = plat.gateway_url
681
+ platform_name = plat.name
682
+ except ValueError:
683
+ api_url = gateway_url = "[dim]unknown — set QUALYS_URL to override[/dim]"
684
+ platform_name = "unknown"
685
+
686
+ mode_label = (
687
+ "[cyan]agentic[/cyan] (JSON, no decoration)"
688
+ if output_mode == "agentic"
689
+ else "[green]interactive[/green] (rich tables)"
690
+ )
691
+
692
+ t = Table(
693
+ box=box.SIMPLE_HEAD, show_header=False, border_style="dim",
694
+ show_edge=False, padding=(0, 1),
695
+ )
696
+ t.add_column("key", style="dim")
697
+ t.add_column("val")
698
+ t.add_row("Profile", f"[bold]{profile}[/bold]")
699
+ t.add_row("Platform", f"[cyan]{platform_name}[/cyan]")
700
+ t.add_row("API URL", api_url)
701
+ t.add_row("Gateway URL", gateway_url)
702
+ t.add_row("Username", username)
703
+ t.add_row(
704
+ "Password",
705
+ "[dim]stored in system keyring[/dim]" if in_keyring
706
+ else "[bold yellow]WARNING[/bold yellow] [dim]keyring unavailable — "
707
+ "stored in plaintext in config.toml[/dim]",
708
+ )
709
+ t.add_row("Output mode", mode_label)
710
+
711
+ suffix = " Continuing with your command…" if continuing else ""
712
+ _err.print()
713
+ _err.print(Panel(
714
+ t,
715
+ title="[bold green] Profile saved [/bold green]",
716
+ subtitle=f"[dim]{suffix}[/dim]" if suffix else None,
717
+ border_style="green",
718
+ expand=False,
719
+ ))
720
+ _err.print()
721
+
722
+
723
+ @app.command("configure", rich_help_panel=_PANEL_CFG)
724
+ def configure(
725
+ profile: str = typer.Option(
726
+ "default", "--profile", "-p", help="Profile name to create/update"
727
+ ),
728
+ username: str | None = typer.Option(None, "--username", "-u"),
729
+ password: str | None = typer.Option(
730
+ None, "--password",
731
+ help="Stored in system keyring. WARNING: passing a secret as a CLI "
732
+ "flag can leak via shell history or `ps`; prefer the "
733
+ "interactive prompt or the QUALYS_PASSWORD env var.",
734
+ ),
735
+ output_mode: str | None = typer.Option(
736
+ None, "--output-mode",
737
+ help="interactive (rich tables) or agentic (JSON, no decoration)",
738
+ ),
739
+ ) -> None:
740
+ """Create or update a named credential profile.
741
+
742
+ Platform URLs are auto-detected from your username
743
+ (see https://www.qualys.com/platform-identification).
744
+ Override with [dim]QUALYS_URL[/dim] / [dim]QUALYS_GATEWAY_URL[/dim] env vars if needed.
745
+ """
746
+ if not username:
747
+ username = typer.prompt("Username")
748
+ if password and _err.is_terminal:
749
+ _err.print(
750
+ " [yellow]⚠[/yellow] [dim]--password was passed on the command line — it may be "
751
+ "visible in shell history or `ps`. Prefer the interactive prompt or "
752
+ "QUALYS_PASSWORD.[/dim]"
753
+ )
754
+ if not password:
755
+ password = typer.prompt("Password", hide_input=True, confirmation_prompt=True)
756
+ if not output_mode:
757
+ raw = typer.prompt(
758
+ "Output mode",
759
+ default="interactive",
760
+ prompt_suffix=" [interactive/agentic]: ",
761
+ show_default=False,
762
+ ).strip().lower()
763
+ output_mode = raw if raw in ("interactive", "agentic") else "interactive"
764
+
765
+ _save_and_show(profile, username, password, output_mode=output_mode)
766
+
767
+
768
+ @app.command("mode", rich_help_panel=_PANEL_CFG)
769
+ def mode_cmd(
770
+ ctx: typer.Context,
771
+ new_mode: str | None = typer.Argument(
772
+ None, help="Set mode: [bold]interactive[/bold] or [bold]agentic[/bold]"
773
+ ),
774
+ profile: str = typer.Option(
775
+ "default", "--profile", "-p", help="Profile to update"
776
+ ),
777
+ ) -> None:
778
+ """Show or change the output mode for a profile.
779
+
780
+ [bold]interactive[/bold] Rich tables, colours, spinners — for human use.\n
781
+ [bold]agentic[/bold] Plain JSON, no decoration — for scripts and AI agents.
782
+
783
+ Examples:\n
784
+ qualys-cli mode\n
785
+ qualys-cli mode agentic\n
786
+ qualys-cli mode interactive
787
+ """
788
+ _VALID = ("interactive", "agentic")
789
+
790
+ if new_mode is None:
791
+ # Show current mode
792
+ try:
793
+ p = _cfg.get_profile(profile)
794
+ current = p.output_mode
795
+ except Exception:
796
+ current = "interactive"
797
+ _err.print()
798
+ _err.print(
799
+ f" Profile [bold]{profile}[/bold] is in "
800
+ f"[bold cyan]{current}[/bold cyan] mode."
801
+ )
802
+ other = "agentic" if current == "interactive" else "interactive"
803
+ _err.print(
804
+ f" Run [bold]qualys-cli mode {other}[/bold] to switch."
805
+ )
806
+ _err.print()
807
+ return
808
+
809
+ new_mode = new_mode.lower()
810
+ if new_mode not in _VALID:
811
+ _err.print(
812
+ f" [bold red]Invalid mode[/bold red] [bold]{new_mode!r}[/bold]. "
813
+ f"Choose: {' | '.join(_VALID)}"
814
+ )
815
+ raise typer.Exit(1)
816
+
817
+ _cfg.set_output_mode(profile, new_mode)
818
+ label = (
819
+ "[green]interactive[/green] (rich tables)"
820
+ if new_mode == "interactive"
821
+ else "[cyan]agentic[/cyan] (JSON, no decoration)"
822
+ )
823
+ _err.print()
824
+ _err.print(
825
+ f" [bold green]✓[/bold green] Profile [bold]{profile}[/bold] "
826
+ f"→ output mode set to {label}."
827
+ )
828
+ _err.print(
829
+ " [dim]Takes effect on the next command.[/dim]"
830
+ )
831
+ _err.print()
832
+
833
+
834
+ @app.command("profiles", rich_help_panel=_PANEL_CFG)
835
+ def profiles() -> None:
836
+ """List configured credential profiles."""
837
+ from pathlib import Path
838
+ config_file = Path.home() / ".config" / "qualys-cli" / "config.toml"
839
+ if not config_file.exists():
840
+ _err.print(Panel(
841
+ "No profiles configured.\n\n"
842
+ "Run [bold cyan]qualys-cli configure[/bold cyan] to get started.",
843
+ title="[bold yellow] No profiles [/bold yellow]",
844
+ border_style="yellow",
845
+ expand=False,
846
+ ))
847
+ raise typer.Exit(1)
848
+
849
+ try:
850
+ import tomllib
851
+ except ImportError:
852
+ import tomli as tomllib # type: ignore[no-redef]
853
+
854
+ data = tomllib.loads(config_file.read_text())
855
+ profiles_data = data.get("profiles", {})
856
+ if not profiles_data:
857
+ _err.print("[dim]No profiles configured.[/dim]")
858
+ raise typer.Exit(1)
859
+
860
+ from .platforms import detect as _detect_platform
861
+
862
+ t = Table(
863
+ box=box.SIMPLE_HEAD, header_style="bold", border_style="dim",
864
+ show_edge=False, padding=(0, 1),
865
+ )
866
+ t.add_column("Profile")
867
+ t.add_column("Platform")
868
+ t.add_column("Username")
869
+ t.add_column("API URL")
870
+ for name, prof in profiles_data.items():
871
+ uname = prof.get("username", "")
872
+ try:
873
+ plat = _detect_platform(uname)
874
+ platform_name = f"[cyan]{plat.name}[/cyan]"
875
+ api_url = prof.get("url") or plat.api_url
876
+ except ValueError:
877
+ platform_name = "[dim]unknown[/dim]"
878
+ api_url = prof.get("url", "[dim]—[/dim]")
879
+ t.add_row(f"[bold]{name}[/bold]", platform_name, uname or "[dim]—[/dim]", api_url)
880
+
881
+ _err.print()
882
+ _err.print(t)
883
+ _err.print()
884
+
885
+
886
+ # ---------------------------------------------------------------------------
887
+ # Entry point with global exception handling
888
+ # ---------------------------------------------------------------------------
889
+
890
+ # Map APIError subclasses to (panel title, machine "type" tag) used by _show_error.
891
+ _ERROR_TITLES: dict[str, tuple[str, str]] = {
892
+ "AuthError": ("Authentication Failed", "auth_error"),
893
+ "NotFoundError": ("Not Found", "not_found"),
894
+ "RateLimitError": ("Rate Limited", "rate_limit"),
895
+ "ServerError": ("Server Error", "server_error"),
896
+ "TimeoutError": ("Timeout", "timeout"),
897
+ "NetworkError": ("Network Error", "network_error"),
898
+ "APIError": ("API Error", "api_error"),
899
+ }
900
+
901
+
902
+ def _show_error(exc: Exception) -> int:
903
+ """Render *exc* and return the exit code the process should use."""
904
+ import json as _json
905
+ import sys as _sys
906
+
907
+ from .client import APIError
908
+
909
+ if isinstance(exc, APIError):
910
+ kind = type(exc).__name__
911
+ title, type_tag = _ERROR_TITLES.get(kind, _ERROR_TITLES["APIError"])
912
+ exit_code = exc.exit_code
913
+ # Record the failure in the audit log so reviewers can see *why* a
914
+ # command exited non-zero, not just that it did.
915
+ _audit.log_error(kind, exc.message, status=exc.status or None)
916
+
917
+ if not _err.is_terminal:
918
+ payload = {
919
+ "error": exc.message,
920
+ "status": exc.status,
921
+ "type": type_tag,
922
+ "correlation": exc.correlation or _audit.CORRELATION_ID,
923
+ }
924
+ if exc.request_id:
925
+ payload["request_id"] = exc.request_id
926
+ print(_json.dumps(payload), file=_sys.stderr)
927
+ return exit_code
928
+
929
+ # Build a friendly panel: the API message, then a hint, then the
930
+ # correlation/request IDs so users can find this run in the audit log.
931
+ body = f"[white]{exc.message}[/white]"
932
+ if exc.hint:
933
+ body += f"\n\n[dim]{exc.hint}[/dim]"
934
+ ids: list[str] = []
935
+ if exc.correlation or _audit.CORRELATION_ID:
936
+ ids.append(f"correlation [bold]{exc.correlation or _audit.CORRELATION_ID}[/bold]")
937
+ if exc.request_id:
938
+ ids.append(f"request [bold]{exc.request_id}[/bold]")
939
+ if ids:
940
+ body += f"\n[dim]({' · '.join(ids)})[/dim]"
941
+ status_label = f"HTTP {exc.status}" if exc.status else "Network"
942
+ _err.print(Panel(
943
+ body,
944
+ title=f"[bold red] {status_label} — {title} [/bold red]",
945
+ border_style="red",
946
+ padding=(0, 1),
947
+ expand=False,
948
+ ))
949
+ return exit_code
950
+
951
+ if isinstance(exc, ValueError):
952
+ _audit.log_error("ConfigurationError", str(exc))
953
+ if not _err.is_terminal:
954
+ print(_json.dumps({
955
+ "error": str(exc),
956
+ "type": "configuration_error",
957
+ "correlation": _audit.CORRELATION_ID,
958
+ }), file=_sys.stderr)
959
+ return 2
960
+ _err.print(Panel(
961
+ f"[white]{exc}[/white]\n\n"
962
+ "[dim]Run [cyan]qualys-cli configure[/cyan] to set up credentials, or set "
963
+ "the QUALYS_USERNAME / QUALYS_PASSWORD env vars.[/dim]",
964
+ title="[bold red] Configuration Error [/bold red]",
965
+ border_style="red",
966
+ padding=(0, 1),
967
+ expand=False,
968
+ ))
969
+ return 2
970
+
971
+ # Unknown / unexpected error — still log it so it shows up in audit.
972
+ _audit.log_error(type(exc).__name__, str(exc))
973
+ if not _err.is_terminal:
974
+ print(_json.dumps({
975
+ "error": str(exc),
976
+ "type": "error",
977
+ "correlation": _audit.CORRELATION_ID,
978
+ }), file=_sys.stderr)
979
+ return 1
980
+ _err.print(Panel(
981
+ f"[white]{exc}[/white]\n\n"
982
+ f"[dim]correlation [bold]{_audit.CORRELATION_ID}[/bold] — share this ID "
983
+ "if you file a bug report.[/dim]",
984
+ title="[bold red] Error [/bold red]",
985
+ border_style="red",
986
+ padding=(0, 1),
987
+ expand=False,
988
+ ))
989
+ return 1
990
+
991
+
992
+ def _build_post_options() -> list[Any]:
993
+ """Construct the cross-cutting post-processing options
994
+ (``--where`` / ``--columns`` / ``--sort`` / ``--sort-by`` / ``--sort-desc``
995
+ / ``--jq``) used by the formatter.
996
+
997
+ Each option is registered with ``expose_value=False`` so it never reaches
998
+ the underlying Typer command function — instead a callback stashes the
999
+ parsed value into a ContextVar that the output formatter reads at render
1000
+ time. This avoids threading these flags through ~200 command signatures.
1001
+ """
1002
+ import click as _click
1003
+
1004
+ from . import formatters as _fmt
1005
+
1006
+ def _cb(name: str) -> Any:
1007
+ def _set(_ctx: _click.Context, _param: _click.Parameter, value: Any) -> Any:
1008
+ if value is not None:
1009
+ _fmt.set_post_process_opt(name, value)
1010
+ return value
1011
+ return _set
1012
+
1013
+ return [
1014
+ _click.Option(
1015
+ ["--where"], type=str, default=None, expose_value=False,
1016
+ callback=_cb("where"),
1017
+ help="Filter rows (ops: =, !=, ~, >=, >, <=, <). See docs/usage.html.",
1018
+ ),
1019
+ _click.Option(
1020
+ ["--columns"], type=str, default=None, expose_value=False,
1021
+ callback=_cb("columns"),
1022
+ help="Override default table columns (comma-separated)",
1023
+ ),
1024
+ _click.Option(
1025
+ ["--sort"], type=str, default=None, expose_value=False,
1026
+ callback=_cb("sort"),
1027
+ help="Sort rows by field[:asc|:desc]",
1028
+ ),
1029
+ _click.Option(
1030
+ ["--sort-by"], type=str, default=None, expose_value=False,
1031
+ callback=_cb("sort_by"),
1032
+ help="Sort rows by FIELD (combine with --sort-desc for descending)",
1033
+ ),
1034
+ _click.Option(
1035
+ ["--sort-desc"], is_flag=True, default=False, expose_value=False,
1036
+ callback=_cb("sort_desc"),
1037
+ help="Reverse sort order when using --sort-by",
1038
+ ),
1039
+ _click.Option(
1040
+ ["--jq"], type=str, default=None, expose_value=False,
1041
+ callback=_cb("jq"),
1042
+ help="jq-lite query against the parsed payload, e.g. '.foo[].id'",
1043
+ ),
1044
+ ]
1045
+
1046
+
1047
+ def _inject_post_options_into(root_cmd: Any) -> Any:
1048
+ """Walk a Click command tree and append the post-processing options to
1049
+ every leaf (idempotent — skips options already present).
1050
+
1051
+ Collision is checked against the user-facing flag strings (``--sort``,
1052
+ ``--where``, …) rather than the click ``.name`` attribute, because Typer
1053
+ derives the click name from the Python parameter name, not from the
1054
+ ``--flag`` declaration. A command that declared ``sort: str = typer.Option(
1055
+ None, "--server-sort")`` would otherwise still shadow our global
1056
+ ``--sort`` despite the rename.
1057
+ """
1058
+ import click as _click
1059
+
1060
+ post_opts = _build_post_options()
1061
+
1062
+ def _walk(cmd: _click.Command) -> None:
1063
+ if isinstance(cmd, _click.Group):
1064
+ for sub in cmd.commands.values():
1065
+ _walk(sub)
1066
+ return
1067
+ existing_flags: set[str] = set()
1068
+ for p in cmd.params:
1069
+ existing_flags.update(getattr(p, "opts", ()) or ())
1070
+ for opt in post_opts:
1071
+ if not any(decl in existing_flags for decl in opt.opts):
1072
+ cmd.params.append(opt)
1073
+
1074
+ _walk(root_cmd)
1075
+ return root_cmd
1076
+
1077
+
1078
+ # Patch ``typer.main.get_command`` so that any caller — the qualys-cli binary
1079
+ # via main(), Typer's CliRunner used by the test suite, or anything that imports
1080
+ # ``app`` and invokes it — sees the injected options. Without this, options
1081
+ # only attach when main() is the entry point and tests / MCP / embedded use
1082
+ # would lose them.
1083
+ #
1084
+ # Scope / lifetime caveats:
1085
+ # - The patch is process-lifetime; there is no unpatch hook. We guard with
1086
+ # ``typer_instance is app`` so unrelated Typer apps in the same process
1087
+ # are not mutated.
1088
+ # - If qualys-cli is ever imported as a library alongside other Typer apps,
1089
+ # the patched function is still on ``typer.main`` — those other apps just
1090
+ # fall through to the original behaviour via the identity check.
1091
+ # - typer.testing's late-binding alias is also patched below for the same
1092
+ # reason CliRunner.invoke would otherwise miss the injection.
1093
+ _original_get_command = typer.main.get_command
1094
+
1095
+
1096
+ def _patched_get_command(typer_instance: typer.Typer) -> Any:
1097
+ cmd = _original_get_command(typer_instance)
1098
+ if typer_instance is app:
1099
+ _inject_post_options_into(cmd)
1100
+ return cmd
1101
+
1102
+
1103
+ typer.main.get_command = _patched_get_command
1104
+
1105
+ # typer.testing rebinds get_command at *its* module-load time via
1106
+ # ``from typer.main import get_command as _get_command``, so if a test module
1107
+ # imported typer.testing before this module, its alias still points to the
1108
+ # original. Patch the alias directly when the testing module is loaded so
1109
+ # CliRunner.invoke(app, ...) also sees the injected options.
1110
+ try:
1111
+ import typer.testing as _typer_testing
1112
+ _typer_testing._get_command = _patched_get_command # type: ignore[attr-defined]
1113
+ except ImportError:
1114
+ pass
1115
+
1116
+
1117
+ def main() -> None:
1118
+ import contextlib as _ctx
1119
+
1120
+ # Top-level Ctrl-C handler: ensures the spinner / audit state is flushed
1121
+ # before the process exits even when interrupted between calls.
1122
+ def _sigint(_signum: int, _frame: object) -> None:
1123
+ raise KeyboardInterrupt()
1124
+ with _ctx.suppress(ValueError, OSError):
1125
+ _signal.signal(_signal.SIGINT, _sigint)
1126
+
1127
+ # Initialise audit early so even argument-parsing errors are captured.
1128
+ _audit.init()
1129
+ version = _qualys_cli_version()
1130
+ started = _audit.log_command_start(sys.argv, version)
1131
+ exit_code = 0
1132
+ # Clear any leftover formatter post-processing state. Today every CLI
1133
+ # invocation is a fresh process so this is paranoia; if a future caller
1134
+ # ever invokes main() twice in-process (e.g. embedded harness) the reset
1135
+ # guarantees option values can't bleed between runs.
1136
+ from . import formatters as _fmt
1137
+ _fmt.reset_post_process_opts()
1138
+ try:
1139
+ app(standalone_mode=False)
1140
+ except typer.Exit as e:
1141
+ exit_code = int(e.exit_code or 0)
1142
+ _audit.log_command_end(started, exit_code, version)
1143
+ raise SystemExit(exit_code)
1144
+ except typer.Abort:
1145
+ exit_code = 130
1146
+ _err.print("\n [yellow]Aborted.[/yellow]\n")
1147
+ _audit.log_command_end(started, exit_code, version)
1148
+ raise SystemExit(exit_code)
1149
+ except KeyboardInterrupt:
1150
+ exit_code = 130
1151
+ _err.print("\n [yellow]Interrupted.[/yellow]\n")
1152
+ _audit.log_command_end(started, exit_code, version)
1153
+ raise SystemExit(exit_code)
1154
+ except SystemExit as se:
1155
+ exit_code = int(se.code or 0) if se.code is not None else 0
1156
+ _audit.log_command_end(started, exit_code, version)
1157
+ raise
1158
+ except Exception as exc:
1159
+ # Special-case --dry-run: it's not an error; exit cleanly with 0.
1160
+ from .client import DryRunExit as _DryRunExit
1161
+ if isinstance(exc, _DryRunExit):
1162
+ _audit.log_command_end(started, 0, version)
1163
+ raise SystemExit(0)
1164
+ exit_code = _show_error(exc)
1165
+ _audit.log_command_end(started, exit_code, version)
1166
+ raise SystemExit(exit_code)
1167
+ else:
1168
+ _audit.log_command_end(started, exit_code, version)