vulnctl 0.2.0__tar.gz → 0.2.2__tar.gz

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.
Files changed (130) hide show
  1. {vulnctl-0.2.0 → vulnctl-0.2.2}/CHANGELOG.md +29 -1
  2. {vulnctl-0.2.0 → vulnctl-0.2.2}/CLAUDE.md +1 -0
  3. {vulnctl-0.2.0 → vulnctl-0.2.2}/PKG-INFO +1 -1
  4. {vulnctl-0.2.0 → vulnctl-0.2.2}/docs/cli.md +6 -2
  5. {vulnctl-0.2.0 → vulnctl-0.2.2}/pyproject.toml +1 -1
  6. {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/cli.py +2 -0
  7. vulnctl-0.2.2/src/vulnctl/cli_help.py +191 -0
  8. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_cli.py +69 -0
  9. {vulnctl-0.2.0 → vulnctl-0.2.2}/uv.lock +1 -1
  10. {vulnctl-0.2.0 → vulnctl-0.2.2}/.github/workflows/ci.yml +0 -0
  11. {vulnctl-0.2.0 → vulnctl-0.2.2}/.github/workflows/live-smoke.yml +0 -0
  12. {vulnctl-0.2.0 → vulnctl-0.2.2}/.github/workflows/publish-testpypi.yml +0 -0
  13. {vulnctl-0.2.0 → vulnctl-0.2.2}/.github/workflows/release.yml +0 -0
  14. {vulnctl-0.2.0 → vulnctl-0.2.2}/.gitignore +0 -0
  15. {vulnctl-0.2.0 → vulnctl-0.2.2}/.pre-commit-config.yaml +0 -0
  16. {vulnctl-0.2.0 → vulnctl-0.2.2}/.python-version +0 -0
  17. {vulnctl-0.2.0 → vulnctl-0.2.2}/FRAMEWORK.md +0 -0
  18. {vulnctl-0.2.0 → vulnctl-0.2.2}/LICENSE +0 -0
  19. {vulnctl-0.2.0 → vulnctl-0.2.2}/README.md +0 -0
  20. {vulnctl-0.2.0 → vulnctl-0.2.2}/ROADMAP.md +0 -0
  21. {vulnctl-0.2.0 → vulnctl-0.2.2}/SPEC.md +0 -0
  22. {vulnctl-0.2.0 → vulnctl-0.2.2}/docs/announcement.md +0 -0
  23. {vulnctl-0.2.0 → vulnctl-0.2.2}/docs/case-study.md +0 -0
  24. {vulnctl-0.2.0 → vulnctl-0.2.2}/docs/context.md +0 -0
  25. {vulnctl-0.2.0 → vulnctl-0.2.2}/docs/demo.tape +0 -0
  26. {vulnctl-0.2.0 → vulnctl-0.2.2}/docs/exit-codes.md +0 -0
  27. {vulnctl-0.2.0 → vulnctl-0.2.2}/docs/output.md +0 -0
  28. {vulnctl-0.2.0 → vulnctl-0.2.2}/docs/releasing.md +0 -0
  29. {vulnctl-0.2.0 → vulnctl-0.2.2}/docs/schema.json +0 -0
  30. {vulnctl-0.2.0 → vulnctl-0.2.2}/docs/schema.md +0 -0
  31. {vulnctl-0.2.0 → vulnctl-0.2.2}/docs/trees.md +0 -0
  32. {vulnctl-0.2.0 → vulnctl-0.2.2}/examples/app.cdx.json +0 -0
  33. {vulnctl-0.2.0 → vulnctl-0.2.2}/examples/ci/vulnctl-gate.yml +0 -0
  34. {vulnctl-0.2.0 → vulnctl-0.2.2}/examples/context.yaml +0 -0
  35. {vulnctl-0.2.0 → vulnctl-0.2.2}/scripts/build_exploit_index.py +0 -0
  36. {vulnctl-0.2.0 → vulnctl-0.2.2}/scripts/case_study_stats.py +0 -0
  37. {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/__init__.py +0 -0
  38. {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/adapters/__init__.py +0 -0
  39. {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/adapters/base.py +0 -0
  40. {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/adapters/epss.py +0 -0
  41. {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/adapters/exploits.py +0 -0
  42. {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/adapters/ghsa.py +0 -0
  43. {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/adapters/kev.py +0 -0
  44. {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/adapters/nvd.py +0 -0
  45. {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/adapters/osv.py +0 -0
  46. {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/cache.py +0 -0
  47. {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/commands.py +0 -0
  48. {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/context.py +0 -0
  49. {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/data/__init__.py +0 -0
  50. {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/data/epss_snapshot.csv.gz +0 -0
  51. {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/data/exploit_index.json.gz +0 -0
  52. {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/data/kev_snapshot.json.gz +0 -0
  53. {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/ingest/__init__.py +0 -0
  54. {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/ingest/cve_list.py +0 -0
  55. {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/ingest/cyclonedx.py +0 -0
  56. {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/ingest/grype.py +0 -0
  57. {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/models.py +0 -0
  58. {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/output/__init__.py +0 -0
  59. {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/output/explain.py +0 -0
  60. {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/output/json_out.py +0 -0
  61. {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/output/markdown.py +0 -0
  62. {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/output/progress.py +0 -0
  63. {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/output/render.py +0 -0
  64. {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/output/sarif.py +0 -0
  65. {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/output/table.py +0 -0
  66. {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/pipeline.py +0 -0
  67. {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/py.typed +0 -0
  68. {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/ssvc/__init__.py +0 -0
  69. {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/ssvc/engine.py +0 -0
  70. {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/ssvc/tree.py +0 -0
  71. {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/ssvc/trees/cisa-deployer-v1.yaml +0 -0
  72. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/conftest.py +0 -0
  73. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/epss/batch.json +0 -0
  74. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/epss/malformed.json +0 -0
  75. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/epss/missing.json +0 -0
  76. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/ghsa/advisory-by-cve.json +0 -0
  77. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/ghsa/advisory-by-ghsa-id.json +0 -0
  78. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/ghsa/malformed.json +0 -0
  79. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/ghsa/not-found-empty-list.json +0 -0
  80. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/ghsa/rate-limited.json +0 -0
  81. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/golden/enrich.json +0 -0
  82. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/golden/enrich.md +0 -0
  83. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/grype/duplicate-layers.json +0 -0
  84. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/grype/ghsa-only.json +0 -0
  85. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/grype/npm-app.json +0 -0
  86. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/kev/catalog.json +0 -0
  87. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/nvd/cve-2021-44228.json +0 -0
  88. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/nvd/multiple-cvss.json +0 -0
  89. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/nvd/not-found.json +0 -0
  90. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/nvd/rejected.json +0 -0
  91. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/nvd/v2-only.json +0 -0
  92. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/osv/cve-2021-44228.json +0 -0
  93. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/osv/malformed.json +0 -0
  94. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/osv/not-found.json +0 -0
  95. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/osv/querybatch-npm-app.json +0 -0
  96. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/osv/querybatch.json +0 -0
  97. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/osv/vuln-cve-record.json +0 -0
  98. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/osv/vuln-ghsa-no-cve.json +0 -0
  99. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/osv/vuln-ghsa-with-cve.json +0 -0
  100. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/osv/vuln-go.json +0 -0
  101. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/osv/vuln-pypi.json +0 -0
  102. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/sarif/sarif-schema-2.1.0.json +0 -0
  103. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/sbom/npm-app-1.6.cdx.json +0 -0
  104. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/sbom/npm-app.cdx.json +0 -0
  105. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/sbom/py-app.cdx.json +0 -0
  106. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/trees/toy.yaml +0 -0
  107. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/live/test_live_smoke.py +0 -0
  108. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_adapter_epss.py +0 -0
  109. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_adapter_exploits.py +0 -0
  110. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_adapter_ghsa.py +0 -0
  111. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_adapter_kev.py +0 -0
  112. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_adapter_nvd.py +0 -0
  113. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_adapter_osv.py +0 -0
  114. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_adapters_base.py +0 -0
  115. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_cache.py +0 -0
  116. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_context.py +0 -0
  117. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_ingest_cve_list.py +0 -0
  118. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_ingest_cyclonedx.py +0 -0
  119. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_ingest_grype.py +0 -0
  120. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_models.py +0 -0
  121. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_output_explain.py +0 -0
  122. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_output_json.py +0 -0
  123. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_output_markdown.py +0 -0
  124. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_output_progress.py +0 -0
  125. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_output_sarif.py +0 -0
  126. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_output_table.py +0 -0
  127. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_pipeline.py +0 -0
  128. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_ssvc_cisa_tree.py +0 -0
  129. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_ssvc_engine.py +0 -0
  130. {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_ssvc_tree.py +0 -0
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.2] - 2026-08-01
11
+
12
+ ### Fixed
13
+
14
+ - **Root help type column now matches Typer's own spelling.** The generated
15
+ "Options by command" panel derived its type column itself (`PATH`, `TEXT`),
16
+ which disagreed with the per-command panels on Typer 0.27+ — what a fresh
17
+ `pipx install vulnctl` resolves — where Typer prints `<path>`, `<str>`. It
18
+ now delegates to Click's metavar, so both spellings track the installed
19
+ version. Arguments drop the column entirely: their metavar already names
20
+ what they take.
21
+
22
+ ## [0.2.1] - 2026-08-01
23
+
24
+ ### Added
25
+
26
+ - **Self-contained `vulnctl --help`**: the root help now ends with an
27
+ **Options by command** panel — every command's arguments and flags, what
28
+ each takes, and what it does — and an **Examples** panel covering the
29
+ offline, SBOM, CI-gating, filtering, and `explain` workflows. Previously the
30
+ root help named the three commands and nothing else, so discovering a flag
31
+ meant running `--help` once per command. The summary is generated by walking
32
+ the registered parameters, so it cannot fall out of sync with the real
33
+ signatures. Per-command `--help` is unchanged and remains the place for
34
+ defaults, value ranges, and exit codes.
35
+
10
36
  ## [0.2.0] - 2026-08-01
11
37
 
12
38
  Makes the output actionable and readable, and accepts GHSA identifiers
@@ -116,6 +142,8 @@ that produced it.
116
142
  sign with keyless cosign (OIDC), and publish to PyPI via trusted publishing
117
143
  (no stored token).
118
144
 
119
- [Unreleased]: https://github.com/NokiGuard/vulnctl/compare/v0.2.0...HEAD
145
+ [Unreleased]: https://github.com/NokiGuard/vulnctl/compare/v0.2.2...HEAD
146
+ [0.2.2]: https://github.com/NokiGuard/vulnctl/compare/v0.2.1...v0.2.2
147
+ [0.2.1]: https://github.com/NokiGuard/vulnctl/compare/v0.2.0...v0.2.1
120
148
  [0.2.0]: https://github.com/NokiGuard/vulnctl/compare/v0.1.0...v0.2.0
121
149
  [0.1.0]: https://github.com/NokiGuard/vulnctl/releases/tag/v0.1.0
@@ -38,6 +38,7 @@ All four (pytest, ruff check, ruff format, mypy) must pass before any commit.
38
38
  ```
39
39
  src/vulnctl/
40
40
  ├── cli.py # Typer app wiring + cache commands; thin — no business logic here
41
+ ├── cli_help.py # root --help extras: generated flag summary + examples panels
41
42
  ├── commands.py # enrich/explain command signatures; dispatch only
42
43
  ├── models.py # Pydantic: Finding, Enrichment, Verdict, DecisionPath
43
44
  ├── ingest/ # cve_list.py, cyclonedx.py, spdx.py, grype.py, trivy.py
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vulnctl
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: CLI-first vulnerability prioritization: auditable SSVC verdicts from fused threat intelligence
5
5
  Project-URL: Homepage, https://github.com/NokiGuard/vulnctl
6
6
  Author: Brian Truong
@@ -1,7 +1,11 @@
1
1
  # vulnctl CLI reference
2
2
 
3
- Every command, argument, and option. `vulnctl --help` (and `--help` on any
4
- subcommand) prints the same information from the installed version.
3
+ Every command, argument, and option. `vulnctl --help` prints the same thing
4
+ from the installed version — an **Options by command** panel covering every
5
+ command's flags, plus worked **Examples** — and `--help` on a subcommand
6
+ prints that one command in full, including defaults and value ranges. The
7
+ root summary is generated from the registered parameters, so it cannot drift
8
+ from the real signatures.
5
9
 
6
10
  ```
7
11
  vulnctl [OPTIONS] COMMAND [ARGS]...
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "vulnctl"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  description = "CLI-first vulnerability prioritization: auditable SSVC verdicts from fused threat intelligence"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -11,10 +11,12 @@ from rich.table import Table
11
11
 
12
12
  from vulnctl import commands
13
13
  from vulnctl.cache import Cache
14
+ from vulnctl.cli_help import HelpfulGroup
14
15
  from vulnctl.commands import console
15
16
 
16
17
  app = typer.Typer(
17
18
  name="vulnctl",
19
+ cls=HelpfulGroup, # root --help also lists every command's flags + examples
18
20
  help="Auditable, SSVC-based vulnerability prioritization.",
19
21
  no_args_is_help=True,
20
22
  )
@@ -0,0 +1,191 @@
1
+ """Extra panels for the root ``vulnctl --help``.
2
+
3
+ Typer's root help lists the commands but none of their flags, so a first-time
4
+ user has to run ``--help`` once per command before they know what the tool can
5
+ do. :class:`HelpfulGroup` appends two panels to the root help only: every
6
+ command's parameters — walked from the registered Click objects, so the
7
+ summary cannot drift from the real signatures — and worked examples of the
8
+ common workflows.
9
+
10
+ Per-command ``--help`` is untouched; it remains the place for defaults,
11
+ required-ness, and env vars.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Iterator
17
+ from typing import Any
18
+
19
+ from rich import box
20
+ from rich.console import Console, Group, RenderableType
21
+ from rich.padding import Padding
22
+ from rich.panel import Panel
23
+ from rich.table import Table
24
+ from rich.text import Text
25
+ from typer.core import TyperGroup
26
+
27
+ # Mirror Typer's help palette (typer.rich_utils) so the extra panels read as
28
+ # part of the same page rather than as something bolted on.
29
+ _STYLE_OPTION = "bold cyan"
30
+ _STYLE_SWITCH = "bold green"
31
+ _STYLE_TYPES = "bold yellow"
32
+ _STYLE_BORDER = "dim"
33
+
34
+ # One example per workflow the tool is built around: ad-hoc IDs, SBOM triage,
35
+ # CI gating, filtering, and single-finding forensics.
36
+ _EXAMPLES: tuple[tuple[str, str], ...] = (
37
+ (
38
+ "Rank two CVEs with no network, showing the path behind each verdict",
39
+ "vulnctl enrich CVE-2021-44228 CVE-2019-0708 --offline --show-path",
40
+ ),
41
+ (
42
+ "Rank an SBOM against your org context, as JSON for jq",
43
+ "vulnctl enrich --sbom app.cdx.json --context context.yaml -f json",
44
+ ),
45
+ (
46
+ "Gate CI on a Grype scan: exit 2 if anything lands on Attend or worse",
47
+ "grype my-image:latest -o json | vulnctl enrich --grype - --fail-on attend",
48
+ ),
49
+ (
50
+ "Triage the top of the pile: KEV-listed findings only",
51
+ "vulnctl enrich --sbom app.cdx.json --only-kev --limit 10",
52
+ ),
53
+ (
54
+ "Ask why one finding got its verdict, and what would change it",
55
+ "vulnctl explain CVE-2021-44228",
56
+ ),
57
+ )
58
+
59
+
60
+ def _metavar(param: Any, ctx: Any) -> str:
61
+ """What a parameter takes, spelled the way Typer's own help spells it.
62
+
63
+ Delegates to Click rather than deriving it, so the appended panel agrees
64
+ with the per-command panels on whatever version is installed — Typer 0.26
65
+ renders ``PATH``, 0.27 renders ``<path>``. Click gained the ``ctx``
66
+ argument in 8.2; older versions take none.
67
+ """
68
+ try:
69
+ return str(param.make_metavar(ctx=ctx))
70
+ except TypeError:
71
+ return str(param.make_metavar())
72
+
73
+
74
+ def _visible_params(cmd: Any, kind: str) -> list[Any]:
75
+ return [
76
+ param
77
+ for param in cmd.params
78
+ if param.param_type_name == kind and not getattr(param, "hidden", False)
79
+ ]
80
+
81
+
82
+ def _usage(path: str, cmd: Any, ctx: Any) -> str:
83
+ """The one-line invocation shape, e.g. ``vulnctl enrich [OPTIONS] [VULN_ID...]``."""
84
+ parts = [path]
85
+ if _visible_params(cmd, "option"):
86
+ parts.append("[OPTIONS]")
87
+ parts.extend(_metavar(param, ctx) for param in _visible_params(cmd, "argument"))
88
+ return " ".join(parts)
89
+
90
+
91
+ def _param_rows(cmd: Any, ctx: Any) -> Iterator[tuple[Text, Text, str]]:
92
+ """Yield ``(name, value hint, help)`` for one command's arguments and options."""
93
+ for param in _visible_params(cmd, "argument"):
94
+ # No type column: an argument's metavar already names what it takes.
95
+ yield (
96
+ Text(f" {_metavar(param, ctx)}", style=_STYLE_OPTION),
97
+ Text(""),
98
+ str(getattr(param, "help", "") or ""),
99
+ )
100
+ for param in _visible_params(cmd, "option"):
101
+ switch = bool(getattr(param, "is_flag", False))
102
+ hint = "" if switch else _metavar(param, ctx)
103
+ yield (
104
+ Text(" " + " ".join(param.opts), style=_STYLE_SWITCH if switch else _STYLE_OPTION),
105
+ Text(hint, style=_STYLE_TYPES),
106
+ str(getattr(param, "help", "") or ""),
107
+ )
108
+
109
+
110
+ def _leaf_commands(group: Any, prefix: str) -> Iterator[tuple[str, str, Any]]:
111
+ """Walk the command tree depth-first, yielding ``(label, invocation, command)``.
112
+
113
+ ``label`` is the command as the user types it after the program name
114
+ (``enrich``, ``cache purge``); ``invocation`` includes the program name.
115
+ Subgroups recurse so nested commands are listed rather than hidden behind
116
+ another ``--help``.
117
+ """
118
+ for name, cmd in group.commands.items():
119
+ if getattr(cmd, "hidden", False):
120
+ continue
121
+ label = f"{prefix} {name}".strip()
122
+ subcommands = getattr(cmd, "commands", None)
123
+ if subcommands:
124
+ yield from _leaf_commands(cmd, label)
125
+ else:
126
+ yield label, name, cmd
127
+
128
+
129
+ def _flags_panel(group: Any, prog: str, ctx: Any) -> Panel:
130
+ """Every command's parameters, generated from the registered Click objects."""
131
+ table = Table(box=None, show_header=False, pad_edge=False, padding=(0, 1))
132
+ table.add_column(no_wrap=True) # command / flag
133
+ table.add_column(overflow="fold") # what it takes
134
+ table.add_column() # help
135
+
136
+ for index, (label, _name, cmd) in enumerate(_leaf_commands(group, "")):
137
+ if index:
138
+ table.add_row("", "", "")
139
+ table.add_row(
140
+ Text(label, style="bold"),
141
+ "",
142
+ Text(_usage(f"{prog} {label}", cmd, ctx), style="dim"),
143
+ )
144
+ for name, hint, help_text in _param_rows(cmd, ctx):
145
+ table.add_row(name, hint, help_text)
146
+
147
+ return Panel(
148
+ table,
149
+ title="Options by command",
150
+ title_align="left",
151
+ border_style=_STYLE_BORDER,
152
+ box=box.ROUNDED,
153
+ )
154
+
155
+
156
+ def _examples_panel() -> Panel:
157
+ """Worked invocations, one per supported workflow."""
158
+ lines: list[RenderableType] = []
159
+ for comment, command in _EXAMPLES:
160
+ if lines:
161
+ lines.append(Text(""))
162
+ lines.append(Text(f"# {comment}", style="dim"))
163
+ lines.append(Text(command, style=_STYLE_OPTION))
164
+ return Panel(
165
+ Group(*lines),
166
+ title="Examples",
167
+ title_align="left",
168
+ border_style=_STYLE_BORDER,
169
+ box=box.ROUNDED,
170
+ )
171
+
172
+
173
+ class HelpfulGroup(TyperGroup):
174
+ """Root group whose ``--help`` also lists every command's flags and examples."""
175
+
176
+ def format_help(self, ctx: Any, formatter: Any) -> None:
177
+ super().format_help(ctx, formatter)
178
+ prog = str(ctx.find_root().info_name or "vulnctl")
179
+ console = Console()
180
+ console.print(_flags_panel(self, prog, ctx))
181
+ console.print(_examples_panel())
182
+ console.print(
183
+ Padding(
184
+ Text.assemble(
185
+ ("Run ", "dim"),
186
+ (f"{prog} COMMAND --help", "bold"),
187
+ (" for one command in full: defaults, value ranges, exit codes.", "dim"),
188
+ ),
189
+ (0, 1),
190
+ )
191
+ )
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import re
6
7
  from importlib.metadata import version as pkg_version
7
8
  from pathlib import Path
8
9
 
@@ -12,6 +13,7 @@ from typer.testing import CliRunner
12
13
 
13
14
  from vulnctl.cache import Cache
14
15
  from vulnctl.cli import app
16
+ from vulnctl.cli_help import _EXAMPLES
15
17
 
16
18
  runner = CliRunner()
17
19
 
@@ -37,6 +39,73 @@ def test_no_args_shows_help() -> None:
37
39
  assert "Usage" in result.output
38
40
 
39
41
 
42
+ def _subcommand_flags() -> set[str]:
43
+ """Every option flag registered under a subcommand, walked independently.
44
+
45
+ Deliberately does not reuse ``cli_help``'s own walk — the point is to catch
46
+ a flag the generated root summary would miss. The root's own options are
47
+ excluded: they render in Typer's help panel, which forces colour under
48
+ GITHUB_ACTIONS (see ``test_completion_options_present``).
49
+ """
50
+ flags: set[str] = set()
51
+ stack = list(get_command(app).commands.values())
52
+ while stack:
53
+ cmd = stack.pop()
54
+ stack.extend(getattr(cmd, "commands", {}).values())
55
+ flags.update(
56
+ opt
57
+ for param in cmd.params
58
+ if param.param_type_name == "option" # arguments carry a name, not a flag
59
+ for opt in param.opts
60
+ )
61
+ return flags
62
+
63
+
64
+ def test_root_help_lists_every_subcommand_flag() -> None:
65
+ # `vulnctl --help` on its own has to be usable: Typer's root help names the
66
+ # commands but none of their flags, so cli_help appends a generated summary.
67
+ result = runner.invoke(app, ["--help"])
68
+ assert result.exit_code == 0
69
+ assert sorted(flag for flag in _subcommand_flags() if flag not in result.output) == []
70
+
71
+
72
+ def test_root_help_shows_usage_shapes_and_examples() -> None:
73
+ result = runner.invoke(app, ["--help"])
74
+ assert "vulnctl enrich [OPTIONS] [VULN_ID...]" in result.output
75
+ assert "vulnctl explain [OPTIONS] VULN_ID" in result.output
76
+ assert "vulnctl cache purge [OPTIONS]" in result.output
77
+ assert "vulnctl enrich CVE-2021-44228 CVE-2019-0708 --offline --show-path" in result.output
78
+ assert "vulnctl COMMAND --help" in result.output # pointer to per-command detail
79
+
80
+
81
+ def test_root_help_type_column_matches_per_command_help() -> None:
82
+ # The type column is Click's own metavar, not a hand-derived one, so it
83
+ # tracks whatever spelling the installed Typer uses (0.26 prints `PATH`,
84
+ # 0.27 prints `<path>`) instead of disagreeing with the panels above it.
85
+ def plain(text: str) -> str:
86
+ return re.sub(r"\x1b\[[0-9;]*m", "", text) # CI forces colour on Typer's console
87
+
88
+ def option_row(output: str) -> str:
89
+ # The flag also occurs inside other params' help text; take the row it
90
+ # heads, panel border and indent stripped.
91
+ rows = [ln.lstrip("│ ") for ln in plain(output).splitlines()]
92
+ (row,) = [ln for ln in rows if ln.startswith("--sbom")]
93
+ return row
94
+
95
+ metavar = option_row(runner.invoke(app, ["enrich", "--help"]).output).split()[1]
96
+ assert metavar.strip("<>").lower() == "path"
97
+ assert metavar in option_row(runner.invoke(app, ["--help"]).output)
98
+
99
+
100
+ def test_help_examples_use_real_flags() -> None:
101
+ """Worked examples rot silently; assert every flag in them still exists."""
102
+ known = _subcommand_flags()
103
+ for _comment, command in _EXAMPLES:
104
+ tail = command.split("vulnctl", 1)[1] # skip flags belonging to piped-in tools
105
+ used = {token for token in tail.split() if token.startswith("-") and token != "-"}
106
+ assert used <= known, f"unknown flag(s) in example: {sorted(used - known)}"
107
+
108
+
40
109
  def test_enrich_offline_renders_table_from_snapshots() -> None:
41
110
  """End-to-end offline run: bundled snapshots only, zero network."""
42
111
  result = runner.invoke(app, ["enrich", "--offline", "cve-2021-44228", "CVE-2019-0708"])
@@ -1066,7 +1066,7 @@ wheels = [
1066
1066
 
1067
1067
  [[package]]
1068
1068
  name = "vulnctl"
1069
- version = "0.2.0"
1069
+ version = "0.2.2"
1070
1070
  source = { editable = "." }
1071
1071
  dependencies = [
1072
1072
  { name = "httpx" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes