@slicedearth/whoisleuth-cli 2.2.0 → 2.4.0

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 (282) hide show
  1. package/NOTICE +6 -0
  2. package/README.md +20 -15
  3. package/bin/whoisleuth.mjs +8 -1
  4. package/cli/archive-content-digest.mjs +14 -0
  5. package/cli/archive-inspect.mjs +20 -53
  6. package/cli/arguments.mjs +454 -1748
  7. package/cli/artifact-validation/case-response.mjs +66 -35
  8. package/cli/artifact-validation/investigation-capsule.mjs +52 -31
  9. package/cli/artifact-validation/lookup-evidence.mjs +12 -6
  10. package/cli/artifact-validation/signed-review.mjs +112 -72
  11. package/cli/artifact-validation/structure-primitives.mjs +1 -105
  12. package/cli/artifact-verify.mjs +90 -24
  13. package/cli/assurance-command-runner.mjs +39 -4
  14. package/cli/bagit-folder.mjs +57 -0
  15. package/cli/bagit-review.mjs +23 -0
  16. package/cli/bounded-json.mjs +1 -1
  17. package/cli/bulk-checkpoint.mjs +34 -3
  18. package/cli/bulk-command-runner.mjs +2 -0
  19. package/cli/bulk-output.mjs +29 -3
  20. package/cli/case-command.mjs +195 -0
  21. package/cli/case-pack.mjs +66 -111
  22. package/cli/command-argument-grammar.mjs +215 -0
  23. package/cli/command-reference.mjs +342 -285
  24. package/cli/completion.mjs +303 -80
  25. package/cli/config-profile.mjs +7 -5
  26. package/cli/ct-event-intake.mjs +12 -12
  27. package/cli/discover.mjs +4 -3
  28. package/cli/discovery-observation-snapshot.mjs +59 -18
  29. package/cli/discovery-scan-command-runner.mjs +21 -0
  30. package/cli/discovery-scan.mjs +13 -4
  31. package/cli/discovery-workflow.mjs +1 -1
  32. package/cli/domain-control-monitor.mjs +50 -20
  33. package/cli/domain-control-observations.mjs +149 -102
  34. package/cli/errors.mjs +33 -1
  35. package/cli/evidence-command-runner.mjs +16 -5
  36. package/cli/evidence-signing.mjs +2 -2
  37. package/cli/external-observation-mapping.mjs +7 -8
  38. package/cli/formatters/terminal-lookup.mjs +18 -46
  39. package/cli/formatters/terminal-shared.mjs +5 -3
  40. package/cli/formatters/terminal-technology.mjs +114 -0
  41. package/cli/inline-command-families.mjs +2 -0
  42. package/cli/input.mjs +116 -0
  43. package/cli/interactive-launcher.mjs +2 -160
  44. package/cli/interchange-report.mjs +18 -8
  45. package/cli/investigation-artifacts.mjs +176 -0
  46. package/cli/investigation-checkpoint.mjs +10 -0
  47. package/cli/investigation-folder.mjs +114 -0
  48. package/cli/investigation-manifest.mjs +1 -112
  49. package/cli/investigation-package-review.mjs +103 -0
  50. package/cli/investigation-plan.mjs +32 -19
  51. package/cli/investigation-run.mjs +188 -33
  52. package/cli/local-document-checkpoint.mjs +170 -0
  53. package/cli/lookalike-calibration.mjs +7 -2
  54. package/cli/lookup-brief.mjs +4 -2
  55. package/cli/lookup-browser-view.mjs +1 -1
  56. package/cli/lookup-browser.mjs +5 -12
  57. package/cli/lookup-command-runner.mjs +17 -6
  58. package/cli/lookup-diff.mjs +37 -3
  59. package/cli/lookup-plan.mjs +11 -5
  60. package/cli/lookup-reconcile.mjs +10 -8
  61. package/cli/mail-header-review.mjs +467 -0
  62. package/cli/mail-review.mjs +66 -14
  63. package/cli/manual.mjs +1 -0
  64. package/cli/network-command-runner.mjs +2 -0
  65. package/cli/output-file.mjs +31 -5
  66. package/cli/registry-doctor.mjs +3 -1
  67. package/cli/registry-support.mjs +1 -1
  68. package/cli/retained-artifact-diff.mjs +2 -2
  69. package/cli/review-command-runner.mjs +70 -0
  70. package/cli/risk-calibration.mjs +19 -17
  71. package/cli/runner.mjs +62 -7
  72. package/cli/sharing-review.mjs +21 -34
  73. package/cli/signer-trust.mjs +104 -0
  74. package/cli/source-reliability.mjs +74 -83
  75. package/cli/terminal-input.mjs +168 -0
  76. package/cli/workflow-command-runner.mjs +68 -13
  77. package/docs/cli-reference.md +77 -24
  78. package/docs/cli.md +300 -25
  79. package/lib/abort.mjs +4 -1
  80. package/lib/availability.mjs +161 -112
  81. package/lib/bounded-file.mjs +3 -1
  82. package/lib/bounded-html-document.mjs +125 -0
  83. package/lib/bounded-json.mjs +51 -8
  84. package/lib/browser-library-profile.mjs +42 -51
  85. package/lib/browser-library-worker.mjs +112 -0
  86. package/lib/client-behavior-profile.mjs +3 -3
  87. package/lib/ct-search.mjs +11 -4
  88. package/lib/dns-convergence-review.mjs +1 -0
  89. package/lib/dns-delegation-health.mjs +24 -14
  90. package/lib/dns-inheritance-review.mjs +217 -0
  91. package/lib/dns-intelligence.mjs +73 -26
  92. package/lib/dns-mx.mjs +1 -1
  93. package/lib/dnssec-chain-validation.mjs +6 -0
  94. package/lib/dnssec-evidence-validation.mjs +11 -3
  95. package/lib/domain-change-packet.mjs +49 -16
  96. package/lib/domain-change-review.mjs +14 -8
  97. package/lib/domain-control-flight-recorder.mjs +221 -117
  98. package/lib/domain-control-manifest.mjs +129 -69
  99. package/lib/domain-posture-analysis.mjs +20 -7
  100. package/lib/domain-posture-parsers.mjs +201 -8
  101. package/lib/domain-posture.mjs +147 -29
  102. package/lib/evidence-export-privacy.mjs +561 -0
  103. package/lib/evidence-export.mjs +11 -543
  104. package/lib/evidence-report.mjs +5 -3
  105. package/lib/favicon.mjs +117 -41
  106. package/lib/generated/cisa-kev-catalog.mjs +12 -4
  107. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  108. package/lib/generated/retire-browser-catalog.mjs +2335 -940
  109. package/lib/hostname.mjs +2 -38
  110. package/lib/html-signals.mjs +111 -108
  111. package/lib/http-evidence-bounds.mjs +2 -1
  112. package/lib/http-intelligence.mjs +39 -26
  113. package/lib/local-geoip-evidence.mjs +19 -3
  114. package/lib/lookup-cache.mjs +34 -11
  115. package/lib/lookup-child-profile-contract.mjs +24 -15
  116. package/lib/lookup-diagnostics.mjs +24 -1
  117. package/lib/lookup-progress.mjs +30 -13
  118. package/lib/lookup-response-contract.mjs +1048 -0
  119. package/lib/lookup-response.mjs +8 -10
  120. package/lib/lookup-source-progress.mjs +40 -14
  121. package/lib/lookup.mjs +48 -27
  122. package/lib/nameserver-preflight-review.mjs +1 -0
  123. package/lib/observed-network-context.mjs +43 -27
  124. package/lib/outbound-request-bounds.mjs +4 -1
  125. package/lib/page-fingerprints.mjs +69 -133
  126. package/lib/page-role-profile.mjs +12 -8
  127. package/lib/perceptual-hash.mjs +43 -14
  128. package/lib/rdap-bootstrap.mjs +72 -22
  129. package/lib/rdap-capabilities.mjs +1 -1
  130. package/lib/rdap-client.mjs +15 -7
  131. package/lib/rdap-normalization.mjs +33 -18
  132. package/lib/rdap-search-workbench.mjs +1 -1
  133. package/lib/rdap-transport.mjs +47 -20
  134. package/lib/rdap-types.mjs +2 -1
  135. package/lib/rdap.mjs +18 -3
  136. package/lib/registrable-domain.mjs +21 -1
  137. package/lib/registrar-standing-contract.mjs +1 -0
  138. package/lib/registry-capabilities.mjs +5 -1
  139. package/lib/registry-capability-catalogue.mjs +2 -1
  140. package/lib/registry-dates.mjs +2 -190
  141. package/lib/registry-insights.mjs +85 -34
  142. package/lib/response-policy.mjs +86 -48
  143. package/lib/risk-calibration-summary.mjs +18 -6
  144. package/lib/rpki-evidence.mjs +8 -5
  145. package/lib/safe-fetch.mjs +54 -47
  146. package/lib/security-txt.mjs +42 -7
  147. package/lib/service-binding-dns.mjs +8 -0
  148. package/lib/smtp-transport-review.mjs +27 -15
  149. package/lib/sslbl-certificates.generated.mjs +136 -135
  150. package/lib/static-html-analysis.mjs +178 -151
  151. package/lib/structured-data-identity.mjs +1 -0
  152. package/lib/technology-markup.mjs +100 -0
  153. package/lib/threat-intelligence-manifests.mjs +11 -139
  154. package/lib/threat-intelligence-runtime.mjs +24 -52
  155. package/lib/threat-intelligence-targets.mjs +6 -34
  156. package/lib/threat-intelligence-types.mjs +39 -0
  157. package/lib/typosquat-generator.mjs +19 -15
  158. package/lib/urlhaus-intelligence.mjs +8 -3
  159. package/lib/urlscan-intelligence.mjs +1 -1
  160. package/lib/website-security-posture.mjs +30 -6
  161. package/lib/website-technology.mjs +153 -45
  162. package/lib/whois-authority.mjs +14 -2
  163. package/lib/whois-chain.mjs +9 -2
  164. package/lib/whois-common-formats.mjs +5 -5
  165. package/lib/whois-contracts.mjs +2 -1
  166. package/lib/whois-dialects.mjs +2 -2
  167. package/lib/whois-parser-support.mjs +8 -1
  168. package/lib/whois-transport.mjs +23 -7
  169. package/lib/zone-intent-review.mjs +16 -6
  170. package/package.json +2 -1
  171. package/packages/cases/case-attachment-model.mjs +83 -0
  172. package/packages/cases/case-evidence-model.mjs +156 -62
  173. package/packages/cases/case-export-input.mjs +53 -0
  174. package/packages/cases/case-migration-model.mjs +127 -44
  175. package/packages/cases/case-model.mjs +1 -0
  176. package/packages/cases/case-recheck-model.mjs +91 -0
  177. package/packages/cases/case-record-contracts.mjs +5 -20
  178. package/packages/cases/case-record-core.mjs +12 -22
  179. package/packages/cases/case-record-decisions.mjs +72 -0
  180. package/packages/cases/case-record-model.mjs +1 -0
  181. package/packages/cases/case-record-operations.mjs +95 -31
  182. package/packages/cases/case-record-projection.mjs +221 -0
  183. package/packages/cases/case-report.mjs +70 -54
  184. package/packages/cases/case-response-actions.mjs +654 -0
  185. package/packages/cases/case-response-model.mjs +64 -1115
  186. package/packages/cases/case-response-outcomes.mjs +317 -0
  187. package/packages/cases/case-response-packet-render.mjs +153 -0
  188. package/packages/cases/case-response-packet-types.mjs +1 -0
  189. package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
  190. package/packages/cases/case-response-packet.mjs +74 -572
  191. package/packages/cases/case-response-records.mjs +94 -0
  192. package/packages/cases/case-response-review-inputs.mjs +371 -0
  193. package/packages/cases/case-response-values.mjs +85 -0
  194. package/packages/cases/case-review-package.mjs +45 -0
  195. package/packages/cases/case-selection.mjs +46 -0
  196. package/packages/cases/case-storage-model.mjs +31 -2
  197. package/packages/cases/response-route-freshness.mjs +16 -0
  198. package/packages/comparison/capture-context.mjs +61 -0
  199. package/packages/comparison/comparison-ledger-bulk.mjs +13 -11
  200. package/packages/comparison/page-similarity.mjs +5 -2
  201. package/packages/comparison/relationship-evidence.mjs +85 -8
  202. package/packages/comparison/relationship-provenance.mjs +93 -0
  203. package/packages/contracts/analyst-interchange.mjs +216 -0
  204. package/packages/contracts/analyst-review-state-contract.mjs +11 -0
  205. package/packages/contracts/analyst-review-state.mjs +5 -4
  206. package/packages/contracts/case-portability.mjs +366 -83
  207. package/packages/contracts/case-views-contract.mjs +12 -0
  208. package/packages/contracts/case-views.mjs +26 -0
  209. package/packages/contracts/cli-command-semantics.mjs +94 -0
  210. package/packages/contracts/cli-lookup.mjs +0 -1
  211. package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
  212. package/packages/contracts/domain-control-manifest.mjs +127 -73
  213. package/packages/contracts/domain-control-monitor.mjs +48 -45
  214. package/packages/contracts/domain-control-review.mjs +179 -121
  215. package/packages/contracts/domain-name.mjs +21 -0
  216. package/packages/contracts/external-observation-interchange.mjs +2 -1
  217. package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
  218. package/packages/contracts/http-url.mjs +2 -0
  219. package/packages/contracts/investigation-package-limits.mjs +24 -0
  220. package/packages/contracts/investigation-portability.mjs +56 -27
  221. package/packages/contracts/investigation-run.mjs +16 -0
  222. package/packages/contracts/ip-address.mjs +34 -0
  223. package/packages/contracts/lookup-evidence.mjs +35 -0
  224. package/packages/contracts/offline-comparison.mjs +1 -2
  225. package/packages/contracts/page-fingerprints.mjs +15 -0
  226. package/packages/contracts/risk-calibration.mjs +15 -11
  227. package/packages/contracts/schema-compatibility.mjs +1 -1
  228. package/packages/contracts/schema-lifecycle.mjs +83 -153
  229. package/packages/contracts/selected-file-limits.mjs +6 -0
  230. package/packages/contracts/vulnerability-identifiers.mjs +5 -0
  231. package/packages/contracts/web-capture.mjs +10 -0
  232. package/packages/contracts/workspace-portability.mjs +183 -211
  233. package/packages/evidence/artifact-integrity.mjs +8 -2
  234. package/packages/evidence/artifact-structure.mjs +105 -0
  235. package/packages/evidence/decision-fact.mjs +4 -1
  236. package/packages/evidence/domain-control-runtime.mjs +201 -33
  237. package/packages/evidence/domain-posture-context.mjs +79 -0
  238. package/packages/evidence/image-regions.mjs +56 -0
  239. package/packages/evidence/latest-observations.mjs +28 -0
  240. package/packages/evidence/lookup-target.mjs +104 -0
  241. package/packages/evidence/observation.mjs +16 -1
  242. package/packages/evidence/passphrase-encryption.mjs +44 -0
  243. package/packages/evidence/record-identity.mjs +72 -0
  244. package/packages/evidence/registry-dates.mjs +190 -0
  245. package/packages/evidence/retained-file.mjs +69 -0
  246. package/packages/evidence/review-age.mjs +14 -0
  247. package/packages/interchange/bagit.mjs +342 -0
  248. package/packages/interchange/bounded-zip-extraction.mjs +278 -0
  249. package/packages/interchange/crc32.mjs +17 -0
  250. package/packages/interchange/external-findings-import.mjs +92 -51
  251. package/packages/interchange/web-capture-import.mjs +382 -0
  252. package/packages/investigation/bulk-mail-exposure.mjs +2 -1
  253. package/packages/investigation/investigation-bagit.mjs +14 -0
  254. package/packages/investigation/investigation-capsule.mjs +33 -5
  255. package/packages/investigation/investigation-manifest.mjs +200 -0
  256. package/packages/investigation/investigation-package-crypto.mjs +98 -0
  257. package/packages/investigation/investigation-package.mjs +200 -0
  258. package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
  259. package/packages/investigation/lookup-asset-graph.mjs +193 -165
  260. package/packages/investigation/lookup-claim-readiness.mjs +18 -0
  261. package/packages/investigation/lookup-investigation-brief.mjs +3 -3
  262. package/packages/investigation/lookup-task-guidance.mjs +58 -0
  263. package/packages/monitoring/analyst-review-state.mjs +38 -90
  264. package/packages/workspace/brand-profile-model.mjs +276 -22
  265. package/packages/workspace/bulk-columns.mjs +17 -0
  266. package/packages/workspace/bulk-review-model.mjs +23 -11
  267. package/packages/workspace/bulk-session-model.mjs +100 -24
  268. package/packages/workspace/bulk-sort.mjs +1 -0
  269. package/packages/workspace/case-views.mjs +102 -0
  270. package/packages/workspace/detection-rule-model.mjs +70 -22
  271. package/packages/workspace/hostile-input.mjs +29 -8
  272. package/packages/workspace/investigation-template-model.mjs +19 -4
  273. package/packages/workspace/page-baseline.mjs +18 -15
  274. package/packages/workspace/relationship-observation-model.mjs +41 -23
  275. package/packages/workspace/watchlist-history.mjs +2 -2
  276. package/packages/workspace/watchlist-store.mjs +8 -2
  277. package/packages/workspace/website-snapshot-model.mjs +164 -26
  278. package/packages/workspace/workspace-archive-crypto.mjs +41 -61
  279. package/packages/workspace/workspace-archive.mjs +87 -21
  280. package/third-party-notices.txt +29 -2
  281. package/cli/evidence-command-arguments.mjs +0 -156
  282. package/packages/investigation/bulk-review-export.mjs +0 -63
@@ -1,13 +1,18 @@
1
1
  import { WHOISLEUTH_SOURCE_REPOSITORY_URL } from "../lib/project-metadata.mjs";
2
- import { LOOKUP_EVIDENCE_SCHEMA_VERSION, PUBLISHED_V2_LOOKUP_EVIDENCE_SCHEMA_VERSION, V1_PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION, } from "../lib/evidence-export.mjs";
2
+ import { LOOKUP_EVIDENCE_SCHEMA_VERSION, SUPPORTED_LOOKUP_EVIDENCE_SCHEMA_VERSIONS, V1_PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION, } from "../packages/contracts/lookup-evidence.mjs";
3
3
  import { RISK_CALIBRATION_DATASET_SCHEMA, RISK_CALIBRATION_REPORT_SCHEMA, } from "../packages/contracts/risk-calibration.mjs";
4
- import { CASE_SCHEMA_VERSION, SUPPORTED_WORKSPACE_ARCHIVE_VERSIONS, WORKSPACE_ARCHIVE_VERSION, } from "../packages/contracts/case-portability.mjs";
4
+ import { CASE_SCHEMA_VERSION, CLI_CASE_PACK_INPUT_CASE_VERSIONS, MAX_CASE_STORE_BYTES, MAX_EDITABLE_CASE_INPUT_BYTES, SUPPORTED_WORKSPACE_ARCHIVE_VERSIONS, WORKSPACE_ARCHIVE_VERSION, } from "../packages/contracts/case-portability.mjs";
5
+ import { CLI_COMMAND_SEMANTICS, CLI_HELP_GROUP_ORDER, } from "../packages/contracts/cli-command-semantics.mjs";
5
6
  import { CLI_FAIL_POLICIES_BY_COMMAND } from "./fail-policy.mjs";
7
+ import { MAX_INVESTIGATION_MANIFEST_ARTIFACTS, MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES, MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES } from "../packages/investigation/investigation-manifest.mjs";
6
8
  const LEGACY_WORKSPACE_ARCHIVE_VERSIONS = SUPPORTED_WORKSPACE_ARCHIVE_VERSIONS
7
9
  .filter((version) => version !== WORKSPACE_ARCHIVE_VERSION);
8
10
  const LEGACY_WORKSPACE_ARCHIVE_DESCRIPTION = LEGACY_WORKSPACE_ARCHIVE_VERSIONS
9
11
  .map((version) => `version-${version}`)
10
12
  .join(' and ');
13
+ const PUBLISHED_V2_LOOKUP_EVIDENCE_VERSIONS = SUPPORTED_LOOKUP_EVIDENCE_SCHEMA_VERSIONS
14
+ .filter((version) => version > V1_PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION && version !== LOOKUP_EVIDENCE_SCHEMA_VERSION)
15
+ .join(', ');
11
16
  const LEGACY_WORKSPACE_ARCHIVE_SCOPE = LEGACY_WORKSPACE_ARCHIVE_VERSIONS
12
17
  .map((version) => `v${version}`)
13
18
  .join(' and ');
@@ -23,12 +28,8 @@ const INVESTIGATION_PLAN_RECIPES = Object.freeze([
23
28
  'planned-domain-change',
24
29
  'post-change-verification',
25
30
  ]);
26
- const RUNNABLE_INVESTIGATION_PLAN_RECIPES = Object.freeze([
27
- 'domain-triage',
28
- 'lookalike-review',
29
- 'owned-domain-review',
30
- 'historical-comparison',
31
- ]);
31
+ const RUNNABLE_INVESTIGATION_PLAN_RECIPES = INVESTIGATION_PLAN_RECIPES;
32
+ const CLI_CASE_OPERATIONS = ['show', 'open', 'note', 'pin', 'assess', 'recheck'];
32
33
  const CLI_META_ACTIONS = Object.freeze([
33
34
  Object.freeze({
34
35
  id: 'help',
@@ -66,6 +67,8 @@ redirected or unsupported terminals continue to print this help. No request
66
67
  starts until a Lookup plan is shown and the analyst confirms collection.
67
68
  Use --json or --jsonl where supported for machine-readable stdout.
68
69
  Use --output <file> for atomic private file output and --force to replace it.
70
+ Use -- before positional filenames that begin with a hyphen; ./ also makes a
71
+ filename unambiguous, for example: whoisleuth verify-artifact -- ./-evidence.json.
69
72
  Use --palette auto, light, or dark after the command to select a fixed terminal
70
73
  colour palette; --no-color, NO_COLOR, and redirected output still suppress ANSI.
71
74
  Use --config <file> and --profile <name> for explicit versioned safe defaults.
@@ -80,48 +83,13 @@ must be requested explicitly and can disclose a target to additional sources.
80
83
  Copyright 2026 slicedearth. Licensed under AGPL-3.0-only.
81
84
  Source and licence: ${WHOISLEUTH_SOURCE_REPOSITORY_URL}
82
85
  `;
83
- const COMMON_OPTIONS = Object.freeze(['--help', '--output', '--force', '--config', '--profile', '--palette']);
86
+ const COMMON_OPTIONS = Object.freeze([
87
+ '--help', '--output', '--force', '--config', '--profile', '--palette',
88
+ ]);
84
89
  const REGISTRY_SCAFFOLD_COMMON_OPTIONS = Object.freeze(COMMON_OPTIONS.filter((option) => option !== '--config' && option !== '--profile'));
85
90
  function commonOptionsSeedForCommand(command) {
86
91
  return command === 'registry-scaffold' ? REGISTRY_SCAFFOLD_COMMON_OPTIONS : COMMON_OPTIONS;
87
92
  }
88
- const VALUE_OPTIONS = Object.freeze({
89
- '--group': Object.freeze(['investigate', 'respond', 'assure', 'utilities']),
90
- '--mode': Object.freeze(['offline', 'network']),
91
- '--explain': INVESTIGATION_PLAN_RECIPES,
92
- '--preset': Object.freeze(['common', 'impersonation', 'all']),
93
- '--keyboard': Object.freeze(['qwerty', 'azerty', 'qwertz', 'all']),
94
- '--mail-profile': Object.freeze(['standard', 'defensive-no-mail', 'parked']),
95
- '--marking': Object.freeze(['clear', 'green', 'amber', 'amber-strict', 'red']),
96
- '--recipient-scope': Object.freeze(['public', 'community', 'organization', 'named-recipients']),
97
- '--audience': Object.freeze(['internal', 'trusted', 'public']),
98
- '--fail-on': Object.freeze(['source-failure', 'inconclusive', 'danger', 'material-drift']),
99
- '--manifest-entry': Object.freeze(Array.from({ length: 16 }, (_, index) => `artifact-${index + 1}`)),
100
- '--palette': Object.freeze(['auto', 'light', 'dark']),
101
- '--scenario': Object.freeze(['registered', 'not_found', 'inconclusive']),
102
- });
103
- const STANDARD_CONCURRENCY_VALUES = Object.freeze(['1', '2', '3', '4', '5', '6', '7', '8']);
104
- const LIMITED_CONCURRENCY_VALUES = Object.freeze(['1', '2', '3']);
105
- const FILE_OPTIONS = Object.freeze([
106
- '--checkpoint',
107
- '--config',
108
- '--allowlist',
109
- '--dictionary',
110
- '--manifest',
111
- '--mmdb',
112
- '--output',
113
- '--passphrase-file',
114
- '--private-key-file',
115
- '--public-key-file',
116
- '--snapshot',
117
- '--observation-snapshot',
118
- '--previous',
119
- '--save-lookup',
120
- '--trust-anchor',
121
- ]);
122
- const FILE_OPTIONS_BY_COMMAND = Object.freeze({
123
- 'workflow-run': Object.freeze(['--resume']),
124
- });
125
93
  function positional(name, valueKind, minimum, maximum, values = [], inputSource = 'argv', requiredWhenOptions = []) {
126
94
  return Object.freeze({
127
95
  name,
@@ -136,61 +104,145 @@ function positional(name, valueKind, minimum, maximum, values = [], inputSource
136
104
  const NO_POSITIONALS = Object.freeze([]);
137
105
  const OPTIONAL_FILE_POSITIONAL = Object.freeze([positional('source', 'file', 0, 1, [], 'argv_or_stdin')]);
138
106
  const OPTIONAL_TEXT_POSITIONAL = Object.freeze([positional('subject', 'text', 0, 1, [], 'argv_or_stdin')]);
139
- const TEXT_OPTIONS = Object.freeze([
140
- '--families',
141
- '--workflow',
142
- '--configuration-digest',
143
- '--resolver',
144
- '--expect-content-digest',
145
- '--profile',
146
- '--suffix',
147
- '--left-session',
148
- '--right-session',
149
- '--purpose',
150
- '--observer',
151
- '--retired-selectors',
152
- '--search',
153
- '--select',
154
- '--selectors',
155
- '--tlds',
156
- '--vantage',
157
- ]);
158
- const IDEMPOTENT_OPTIONS = Object.freeze([
159
- '--human-reviewed',
160
- '--no-color',
161
- '--personal-data-reviewed',
162
- '--quiet',
163
- '--redactions-confirmed',
164
- ]);
165
- const REPEATABLE_OPTIONS = Object.freeze(['--select']);
166
- const INTEGER_RANGE_SEED = Object.freeze({
167
- bulk: Object.freeze({
168
- '--concurrency': Object.freeze([
169
- Object.freeze({ minimum: 1, maximum: 8, whenOptionPresent: null }),
170
- Object.freeze({ minimum: 1, maximum: 3, whenOptionPresent: '--deep' }),
171
- ]),
172
- }),
173
- 'discover-scan': Object.freeze({
174
- '--scan-limit': Object.freeze([
175
- Object.freeze({ minimum: 1, maximum: 500, whenOptionPresent: null }),
176
- Object.freeze({ minimum: 1, maximum: 50, whenOptionPresent: '--deep' }),
177
- ]),
178
- '--chunk-size': Object.freeze([
179
- Object.freeze({ minimum: 1, maximum: 100, whenOptionPresent: null }),
180
- ]),
181
- '--concurrency': Object.freeze([
182
- Object.freeze({ minimum: 1, maximum: 8, whenOptionPresent: null }),
183
- Object.freeze({ minimum: 1, maximum: 3, whenOptionPresent: '--deep' }),
184
- ]),
185
- }),
186
- 'monitor-once': Object.freeze({
187
- '--limit': Object.freeze([
188
- Object.freeze({ minimum: 1, maximum: 20, whenOptionPresent: null }),
189
- ]),
190
- '--concurrency': Object.freeze([
191
- Object.freeze({ minimum: 1, maximum: 3, whenOptionPresent: null }),
192
- ]),
107
+ const NO_INTEGER_RANGES = () => Object.freeze([]);
108
+ const BASE_INTEGER_RANGE = (minimum, maximum) => (Object.freeze({ minimum, maximum, whenOptionPresent: null }));
109
+ const DEEP_INTEGER_RANGE = (minimum, maximum) => (Object.freeze({ minimum, maximum, whenOptionPresent: '--deep' }));
110
+ function optionDefinition(valueKind, options = {}) {
111
+ const configuredValues = options.values;
112
+ return Object.freeze({
113
+ valueKind: typeof valueKind === 'function' ? valueKind : () => valueKind,
114
+ values: typeof configuredValues === 'function'
115
+ ? configuredValues
116
+ : () => Object.freeze([...(configuredValues ?? [])]),
117
+ occurrence: options.occurrence ?? 'once',
118
+ acceptsOptionLikeValue: options.acceptsOptionLikeValue ?? false,
119
+ metaAction: options.metaAction ?? null,
120
+ integerRanges: options.integerRanges ?? NO_INTEGER_RANGES,
121
+ });
122
+ }
123
+ const flag = (occurrence = 'once') => optionDefinition('flag', { occurrence });
124
+ const file = () => optionDefinition('file');
125
+ const text = (acceptsOptionLikeValue = false) => optionDefinition('text', { acceptsOptionLikeValue });
126
+ const enumeration = (values) => optionDefinition('enum', { values });
127
+ const integer = (ranges) => (optionDefinition('integer', { integerRanges: ranges }));
128
+ const CLI_OPTION_DEFINITIONS = Object.freeze({
129
+ '--help': optionDefinition('flag', { metaAction: 'help' }),
130
+ '--output': file(),
131
+ '--force': flag(),
132
+ '--config': file(),
133
+ '--profile': text(true),
134
+ '--palette': enumeration(['auto', 'light', 'dark']),
135
+ '--network': flag(),
136
+ '--json': flag(),
137
+ '--package': flag(),
138
+ '--folder': file(),
139
+ '--quiet': flag('idempotent'),
140
+ '--no-color': flag('idempotent'),
141
+ '--common': flag(),
142
+ '--group': enumeration(['investigate', 'respond', 'assure', 'utilities']),
143
+ '--mode': enumeration(['offline', 'network']),
144
+ '--workflow': text(true),
145
+ '--configuration-digest': text(true),
146
+ '--junit': flag(),
147
+ '--markdown': flag(),
148
+ '--html': flag(),
149
+ '--no-attribution': flag(),
150
+ '--fast': flag(),
151
+ '--deep': flag(),
152
+ '--exact-url': flag(),
153
+ '--observer': text(),
154
+ '--vantage': text(),
155
+ '--plan': flag(),
156
+ '--summary': flag(),
157
+ '--verbose': flag(),
158
+ '--browse': flag(),
159
+ '--interactive': flag(),
160
+ '--save-lookup': file(),
161
+ '--strict-exit': flag(),
162
+ '--fail-on': optionDefinition('policy_list', {
163
+ values: (command) => CLI_FAIL_POLICIES_BY_COMMAND[command] ?? [],
193
164
  }),
165
+ '--events': flag(),
166
+ '--jsonl': flag(),
167
+ '--csv': flag(),
168
+ '--csv-with-metadata': flag(),
169
+ '--domains': flag(),
170
+ '--queries': flag(),
171
+ '--registered-only': flag(),
172
+ '--inconclusive-only': flag(),
173
+ '--errors-only': flag(),
174
+ '--concurrency': integer((command) => command === 'monitor-once'
175
+ ? Object.freeze([BASE_INTEGER_RANGE(1, 3)])
176
+ : Object.freeze([BASE_INTEGER_RANGE(1, 8), DEEP_INTEGER_RANGE(1, 3)])),
177
+ '--checkpoint': file(),
178
+ '--resume': optionDefinition((command) => command === 'workflow-run' ? 'file' : 'flag'),
179
+ '--tlds': text(true),
180
+ '--preset': enumeration(['common', 'impersonation', 'all']),
181
+ '--families': text(),
182
+ '--keyboard': enumeration(['qwerty', 'azerty', 'qwertz', 'all']),
183
+ '--dictionary': file(),
184
+ '--snapshot': file(),
185
+ '--scan-limit': integer(() => Object.freeze([
186
+ BASE_INTEGER_RANGE(1, 500),
187
+ DEEP_INTEGER_RANGE(1, 50),
188
+ ])),
189
+ '--chunk-size': integer(() => Object.freeze([BASE_INTEGER_RANGE(1, 100)])),
190
+ '--resolver': text(),
191
+ '--allowlist': file(),
192
+ '--observation-snapshot': file(),
193
+ '--acquisition-only': flag(),
194
+ '--suppressed-only': flag(),
195
+ '--selectors': text(true),
196
+ '--retired-selectors': text(true),
197
+ '--mail-profile': enumeration(['standard', 'defensive-no-mail', 'parked']),
198
+ '--sarif': flag(),
199
+ '--owned-domain': flag(),
200
+ '--include-inherited-dns': flag(),
201
+ '--trust-anchor': file(),
202
+ '--owned-or-authorized': flag(),
203
+ '--active-probe': flag(),
204
+ '--suffix': text(true),
205
+ '--scenario': enumeration(['registered', 'not_found', 'inconclusive']),
206
+ '--summary-json': flag(),
207
+ '--passphrase-file': file(),
208
+ '--manifest': file(),
209
+ '--bagit': flag(),
210
+ '--manifest-entry': enumeration(Array.from({ length: MAX_INVESTIGATION_MANIFEST_ARTIFACTS }, (_, index) => `artifact-${index + 1}`)),
211
+ '--search': text(),
212
+ '--require-match': flag(),
213
+ '--reveal': flag(),
214
+ '--expect-content-digest': text(true),
215
+ '--private-key-file': file(),
216
+ '--public-key-file': file(),
217
+ '--trust-store-file': file(),
218
+ '--mmdb': file(),
219
+ '--audience': enumeration(['internal', 'trusted', 'public']),
220
+ '--reviewed': flag(),
221
+ '--previous': file(),
222
+ '--limit': integer(() => Object.freeze([BASE_INTEGER_RANGE(1, 20)])),
223
+ '--marking': enumeration(['clear', 'green', 'amber', 'amber-strict', 'red']),
224
+ '--recipient-scope': enumeration(['public', 'community', 'organization', 'named-recipients']),
225
+ '--purpose': text(),
226
+ '--human-reviewed': flag('idempotent'),
227
+ '--personal-data-reviewed': flag('idempotent'),
228
+ '--redactions-confirmed': flag('idempotent'),
229
+ '--list': flag(),
230
+ '--explain': enumeration(INVESTIGATION_PLAN_RECIPES),
231
+ '--select': optionDefinition('text', { occurrence: 'repeatable', acceptsOptionLikeValue: true }),
232
+ '--use-artifact': optionDefinition('text', { occurrence: 'repeatable' }),
233
+ '--confirm-review': optionDefinition('text', { occurrence: 'repeatable' }),
234
+ '--approve-network': flag(),
235
+ '--left-session': text(true),
236
+ '--right-session': text(true),
237
+ '--compact': flag(),
238
+ '--case-id': text(),
239
+ '--domain': text(),
240
+ '--title': text(),
241
+ '--new-incident': flag(),
242
+ '--text': text(),
243
+ '--note-file': file(),
244
+ '--input': file(),
245
+ '--expect-file-digest': text(),
194
246
  });
195
247
  function constraint(value) {
196
248
  if (value.kind === 'mutually_exclusive') {
@@ -212,37 +264,29 @@ const QUIET_OUTPUT_CONSTRAINT = constraint({
212
264
  kind: 'mutually_exclusive',
213
265
  options: ['--quiet', '--output'],
214
266
  });
215
- const MACHINE_OUTPUT_OPTIONS = Object.freeze([
216
- '--json', '--jsonl', '--junit', '--csv', '--domains', '--queries', '--markdown',
217
- '--html', '--sarif', '--summary-json',
267
+ const PRESENTATION_OPTIONS = Object.freeze([
268
+ ['--json', 'JSON'], ['--jsonl', 'JSON Lines'], ['--junit', 'JUnit XML'],
269
+ ['--csv', 'CSV'],
270
+ ['--csv-with-metadata', 'CSV with evidence metadata'],
271
+ ['--domains', 'domain list'], ['--queries', 'query list'],
272
+ ['--markdown', 'Markdown'], ['--html', 'HTML'], ['--sarif', 'SARIF'],
273
+ ['--summary-json', 'summary JSON'],
218
274
  ]);
219
- function optionValueKind(command, option) {
220
- if (Object.hasOwn(VALUE_OPTIONS, option))
221
- return option === '--fail-on' ? 'policy_list' : 'enum';
222
- if (INTEGER_RANGE_SEED[command]?.[option])
223
- return 'integer';
224
- if (FILE_OPTIONS.includes(option) || FILE_OPTIONS_BY_COMMAND[command]?.includes(option))
225
- return 'file';
226
- if (TEXT_OPTIONS.includes(option))
227
- return 'text';
228
- return 'flag';
229
- }
275
+ const MACHINE_OUTPUT_OPTIONS = Object.freeze(PRESENTATION_OPTIONS.map(([option]) => option));
230
276
  function optionSpec(command, option, scope) {
231
- const valueKind = optionValueKind(command, option);
232
- const values = option === '--fail-on'
233
- ? CLI_FAIL_POLICIES_BY_COMMAND[command] ?? []
234
- : VALUE_OPTIONS[option] ?? [];
277
+ const definition = CLI_OPTION_DEFINITIONS[option];
278
+ const valueKind = definition.valueKind(command);
279
+ const values = definition.values(command);
235
280
  return Object.freeze({
236
281
  option,
237
282
  scope,
238
283
  arity: valueKind === 'flag' ? 0 : 1,
239
284
  valueKind,
240
285
  values: Object.freeze([...values]),
241
- integerRanges: Object.freeze([...(INTEGER_RANGE_SEED[command]?.[option] ?? [])]),
242
- occurrence: REPEATABLE_OPTIONS.includes(option)
243
- ? 'repeatable'
244
- : IDEMPOTENT_OPTIONS.includes(option) ? 'idempotent' : 'once',
245
- metaAction: option === '--help' ? 'help' : null,
286
+ integerRanges: Object.freeze([...definition.integerRanges(command)]),
287
+ occurrence: definition.occurrence,
288
+ acceptsOptionLikeValue: definition.acceptsOptionLikeValue,
289
+ metaAction: definition.metaAction,
246
290
  });
247
291
  }
248
292
  function grammarConstraints(commandOptions, commandConstraints) {
@@ -278,7 +322,6 @@ function commandSeed(seed) {
278
322
  const COMMAND_SEEDS = Object.freeze({
279
323
  completion: commandSeed({
280
324
  reference: {
281
- usage: 'whoisleuth completion <bash|zsh|fish|powershell>',
282
325
  description: 'Print a static shell-completion script for the installed CLI.',
283
326
  example: 'whoisleuth completion zsh > ~/.zfunc/_whoisleuth',
284
327
  boundary: 'Generation is offline and writes only the script to stdout. The command never modifies shell configuration.',
@@ -288,7 +331,6 @@ const COMMAND_SEEDS = Object.freeze({
288
331
  options: [],
289
332
  positionals: Object.freeze([positional('shell', 'enum', 1, 1, ['bash', 'zsh', 'fish', 'powershell'])]),
290
333
  constraints: EMPTY_CONSTRAINTS,
291
- helpGroup: 'utilities',
292
334
  handlerOwner: 'inline',
293
335
  networkEffect: 'offline',
294
336
  common: false,
@@ -300,7 +342,6 @@ const COMMAND_SEEDS = Object.freeze({
300
342
  }),
301
343
  doctor: commandSeed({
302
344
  reference: {
303
- usage: 'whoisleuth doctor [--network] [--json] [--quiet] [--no-color]',
304
345
  description: 'Check the supported runtime and local terminal capabilities.',
305
346
  example: 'whoisleuth doctor --json',
306
347
  boundary: 'The default check is offline. Public DNS and port 43 checks run only when --network is explicitly supplied.',
@@ -310,7 +351,6 @@ const COMMAND_SEEDS = Object.freeze({
310
351
  options: ['--network', '--json', '--quiet', '--no-color'],
311
352
  positionals: NO_POSITIONALS,
312
353
  constraints: EMPTY_CONSTRAINTS,
313
- helpGroup: 'utilities',
314
354
  handlerOwner: 'inline',
315
355
  networkEffect: 'conditional_network',
316
356
  common: true,
@@ -322,7 +362,6 @@ const COMMAND_SEEDS = Object.freeze({
322
362
  }),
323
363
  commands: commandSeed({
324
364
  reference: {
325
- usage: 'whoisleuth commands [--common] [--group <group>] [--mode <offline|network>] [--json] [--quiet] [--no-color]',
326
365
  description: 'List the installed command contracts in terminal or versioned JSON form.',
327
366
  example: 'whoisleuth commands --json',
328
367
  boundary: 'Catalogue generation is offline. It reports declared command modes and limits without executing collection or inspecting local evidence.',
@@ -332,7 +371,6 @@ const COMMAND_SEEDS = Object.freeze({
332
371
  options: ['--common', '--group', '--mode', '--json', '--quiet', '--no-color'],
333
372
  positionals: NO_POSITIONALS,
334
373
  constraints: Object.freeze([]),
335
- helpGroup: 'utilities',
336
374
  handlerOwner: 'inline',
337
375
  networkEffect: 'offline',
338
376
  common: true,
@@ -344,7 +382,6 @@ const COMMAND_SEEDS = Object.freeze({
344
382
  }),
345
383
  manual: commandSeed({
346
384
  reference: {
347
- usage: 'whoisleuth manual',
348
385
  description: 'Print a generated roff manual page for local installation.',
349
386
  example: 'whoisleuth manual | man -l -',
350
387
  boundary: 'Generation is offline and derives from the same command catalogue as focused help.',
@@ -354,7 +391,6 @@ const COMMAND_SEEDS = Object.freeze({
354
391
  options: [],
355
392
  positionals: NO_POSITIONALS,
356
393
  constraints: EMPTY_CONSTRAINTS,
357
- helpGroup: 'utilities',
358
394
  handlerOwner: 'inline',
359
395
  networkEffect: 'offline',
360
396
  common: false,
@@ -366,19 +402,23 @@ const COMMAND_SEEDS = Object.freeze({
366
402
  }),
367
403
  manifest: commandSeed({
368
404
  reference: {
369
- usage: 'whoisleuth manifest <artefact.json> [...] --workflow <label> [--configuration-digest <sha256:digest>] [--json] [--quiet] [--no-color]',
370
- description: 'Record an ordered, path-free manifest for up to 16 local JSON artefacts.',
405
+ description: `Record an ordered, path-free manifest for up to ${MAX_INVESTIGATION_MANIFEST_ARTIFACTS} local files. Use --package --output evidence.zip for a ZIP, add --passphrase-file to encrypt the ordinary package, or use --folder ./evidence for a new unencrypted folder. Add --bagit to ZIP or folder output for BagIt 1.0 with SHA-512 checksums.`,
371
406
  example: 'whoisleuth manifest lookup.json comparison.json --workflow "domain review" --json',
372
- boundary: 'The command records hashes and bounded schema metadata only. It omits source paths and artefact contents and performs no network collection.',
407
+ boundary: 'Ordinary output contains metadata only. ZIP and folder output include unchanged selected bytes and are private until reviewed for sharing. Folders must be new; existing destinations are never replaced and a failed write may leave explicit partial output. BagIt output is unencrypted; its checksums do not authenticate evidence. Filenames ending in .json are parsed as JSON; other files are opaque and never executed. Original paths are omitted. No network request is made.',
373
408
  },
374
- collection: { mode: 'offline', scope: 'Reads 1 to 16 local JSON artefacts capped at 32 MiB in total and retains no source paths.' },
409
+ collection: { mode: 'offline', scope: `Reads 1 to ${MAX_INVESTIGATION_MANIFEST_ARTIFACTS} local files, at most ${MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES / 1024 / 1024} MiB each and ${MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES.toLocaleString('en-AU')} bytes combined; retains no source paths.` },
375
410
  summary: 'Build an evidence manifest offline',
376
- options: ['--workflow', '--configuration-digest', '--json', '--quiet', '--no-color'],
377
- positionals: Object.freeze([positional('artefacts', 'file', 1, 16)]),
411
+ options: ['--workflow', '--configuration-digest', '--package', '--bagit', '--passphrase-file', '--folder', '--json', '--quiet', '--no-color'],
412
+ positionals: Object.freeze([positional('artefacts', 'file', 1, MAX_INVESTIGATION_MANIFEST_ARTIFACTS)]),
378
413
  constraints: Object.freeze([
379
414
  constraint({ kind: 'required', options: ['--workflow'] }),
415
+ constraint({ kind: 'requires_any', option: '--bagit', requiredOptions: ['--package', '--folder'] }),
416
+ constraint({ kind: 'excludes_all', option: '--bagit', excludedOptions: ['--passphrase-file'] }),
417
+ constraint({ kind: 'requires_all', option: '--package', requiredOptions: ['--output'] }),
418
+ constraint({ kind: 'requires_all', option: '--passphrase-file', requiredOptions: ['--package'] }),
419
+ constraint({ kind: 'mutually_exclusive', options: ['--package', '--json'] }),
420
+ constraint({ kind: 'excludes_all', option: '--folder', excludedOptions: ['--package', '--output', '--force'] }),
380
421
  ]),
381
- helpGroup: 'assure',
382
422
  handlerOwner: 'inline',
383
423
  networkEffect: 'offline',
384
424
  common: false,
@@ -390,7 +430,6 @@ const COMMAND_SEEDS = Object.freeze({
390
430
  }),
391
431
  "map-observations": commandSeed({
392
432
  reference: {
393
- usage: 'whoisleuth map-observations [mapping.json] [--json] [--quiet] [--no-color]',
394
433
  description: 'Apply one bounded declarative field-mapping profile to local source observations.',
395
434
  example: 'whoisleuth map-observations mapping.json --json',
396
435
  boundary: 'Profiles select allowlisted dotted fields only. They execute no scripts, make no requests, and emit the browser-compatible external-findings contract.',
@@ -400,7 +439,6 @@ const COMMAND_SEEDS = Object.freeze({
400
439
  options: ['--json', '--quiet', '--no-color'],
401
440
  positionals: OPTIONAL_FILE_POSITIONAL,
402
441
  constraints: EMPTY_CONSTRAINTS,
403
- helpGroup: 'respond',
404
442
  handlerOwner: 'inline',
405
443
  networkEffect: 'offline',
406
444
  common: false,
@@ -412,7 +450,6 @@ const COMMAND_SEEDS = Object.freeze({
412
450
  }),
413
451
  "oam-export": commandSeed({
414
452
  reference: {
415
- usage: 'whoisleuth oam-export [external-findings.json] [--json] [--quiet] [--no-color]',
416
453
  description: 'Project browser-compatible external findings into a bounded Open Asset Model bridge document.',
417
454
  example: 'whoisleuth oam-export external-findings.json --json',
418
455
  boundary: 'The projection is offline, preserves source completeness without inventing confidence, and covers only bounded FQDN, IP address, certificate, and related edge vocabulary.',
@@ -422,7 +459,6 @@ const COMMAND_SEEDS = Object.freeze({
422
459
  options: ['--json', '--quiet', '--no-color'],
423
460
  positionals: OPTIONAL_FILE_POSITIONAL,
424
461
  constraints: EMPTY_CONSTRAINTS,
425
- helpGroup: 'respond',
426
462
  handlerOwner: 'inline',
427
463
  networkEffect: 'offline',
428
464
  common: false,
@@ -434,27 +470,26 @@ const COMMAND_SEEDS = Object.freeze({
434
470
  }),
435
471
  lookup: commandSeed({
436
472
  reference: {
437
- usage: 'whoisleuth lookup [domain|IP|ASN] [--json|--junit|--markdown|--html] [--no-attribution] [--fast|--deep] [--observer <label>] [--vantage <label>] [--plan] [--summary|--verbose|--browse [--save-lookup <file>]] [--palette <auto|light|dark>] [--strict-exit] [--fail-on <policies>] [--events] [--quiet] [--no-color]',
438
473
  description: 'Collect registration evidence for one domain, IP, or ASN.',
439
474
  example: 'whoisleuth lookup example.test --deep --browse',
440
- boundary: 'Fast is the default. An ICANN-recognised public domain, reserved documentation domain, IP, or ASN may occupy command position as shorthand; it delegates to this same parser and URL-like input requires the explicit lookup command. Deep mode adds bounded WHOIS, DNS, HTTP, TLS, technology, posture, and network context where applicable. A full Deep homepage observation can derive fixed publication and delivery/cache summaries from the same response without retaining raw metadata values or making another request. --browse opens before collection, shows aggregate Fast progress or independently settled planned Deep sources, and then navigates allowlisted retained fields in the completed document. Press ? for help and / to search rendered panel text only. Closing during collection cancels without a partial document. --save-lookup writes the exact completed private JSON only after a normal browser close; it can contain normalised evidence omitted from panels and refuses an existing path.',
475
+ boundary: 'Fast is the default. An ICANN-recognised public domain, reserved documentation domain, IP, or ASN may occupy command position as shorthand; it delegates to this same parser and URL-like input requires the explicit lookup command. Deep mode adds bounded WHOIS, DNS, HTTP, TLS, technology, posture, and network context where applicable. --deep --exact-url explicitly sends the input URL path and query to the website, without its fragment; ordinary URL input sends only the hostname. --plan remains offline and omits the selected URL. Retained paths and page-derived text require review before sharing. A full Deep homepage observation can derive fixed publication and delivery/cache summaries from the same response without retaining raw metadata values or making another request. --browse opens before collection, shows aggregate Fast progress or independently settled planned Deep sources, and then navigates allowlisted retained fields in the completed document. Press ? for help and / to search rendered panel text only. Closing during collection cancels without a partial document. --save-lookup writes the exact completed private JSON only after a normal browser close; it can contain normalised evidence omitted from panels and refuses an existing path.',
441
476
  },
442
477
  collection: { mode: 'network', scope: 'Accepts one target. Fast is the default; deep collection must be selected explicitly.' },
443
478
  summary: 'Collect one domain, IP, or ASN',
444
- options: ['--json', '--junit', '--markdown', '--html', '--no-attribution', '--fast', '--deep', '--observer', '--vantage', '--plan', '--summary', '--verbose', '--browse', '--save-lookup', '--strict-exit', '--fail-on', '--events', '--quiet', '--no-color'],
479
+ options: ['--json', '--junit', '--markdown', '--html', '--no-attribution', '--fast', '--deep', '--exact-url', '--observer', '--vantage', '--plan', '--summary', '--verbose', '--browse', '--save-lookup', '--strict-exit', '--fail-on', '--events', '--quiet', '--no-color'],
445
480
  positionals: Object.freeze([positional('target', 'text', 0, 1, [], 'argv_or_stdin', ['--browse'])]),
446
481
  constraints: Object.freeze([
447
482
  constraint({ kind: 'mutually_exclusive', options: ['--json', '--junit', '--markdown', '--html'] }),
448
483
  constraint({ kind: 'mutually_exclusive', options: ['--fast', '--deep'] }),
449
484
  constraint({ kind: 'mutually_exclusive', options: ['--summary', '--verbose'] }),
450
485
  constraint({ kind: 'requires_all', option: '--save-lookup', requiredOptions: ['--browse'] }),
486
+ constraint({ kind: 'requires_all', option: '--exact-url', requiredOptions: ['--deep'] }),
451
487
  constraint({ kind: 'excludes_all', option: '--summary', excludedOptions: ['--json', '--junit', '--markdown', '--html'] }),
452
488
  constraint({ kind: 'excludes_all', option: '--verbose', excludedOptions: ['--json', '--junit', '--markdown', '--html'] }),
453
489
  constraint({ kind: 'excludes_all', option: '--browse', excludedOptions: ['--json', '--junit', '--markdown', '--html', '--summary', '--verbose', '--events', '--plan', '--quiet'] }),
454
490
  constraint({ kind: 'requires_any', option: '--no-attribution', requiredOptions: ['--markdown', '--html'] }),
455
491
  constraint({ kind: 'excludes_all', option: '--plan', excludedOptions: ['--junit', '--markdown', '--html', '--summary', '--verbose', '--strict-exit', '--events', '--quiet', '--fail-on'] }),
456
492
  ]),
457
- helpGroup: 'investigate',
458
493
  handlerOwner: 'lookup',
459
494
  networkEffect: 'conditional_network',
460
495
  common: true,
@@ -466,23 +501,21 @@ const COMMAND_SEEDS = Object.freeze({
466
501
  }),
467
502
  bulk: commandSeed({
468
503
  reference: {
469
- usage: 'whoisleuth bulk [file] [--json|--jsonl|--junit|--csv|--domains|--queries] [--registered-only|--inconclusive-only|--errors-only] [--fast|--deep] [--concurrency <1-8>] [--checkpoint <file> [--resume]] [--events] [--plan] [--fail-on <policies>] [--quiet] [--no-color]',
470
504
  description: 'Triage newline-delimited domains, IPs, or ASNs with bounded concurrency.',
471
505
  example: 'cat domains.txt | whoisleuth bulk --jsonl',
472
- boundary: 'Fast and deep jobs use separate concurrency ceilings. Filters affect output only; collection failures and inconclusive authority states remain explicit in JSON, JSONL, and CSV.',
506
+ boundary: 'Fast and deep jobs use separate concurrency ceilings. Filters affect output only; collection failures and inconclusive authority states remain explicit in JSON, JSONL, and CSV. --csv-with-metadata adds source versions, separate observation and report times, collection origin and diagnostic states; --csv retains the compact columns.',
473
507
  },
474
508
  collection: { mode: 'network', scope: 'Accepts at most 500 fast or 50 deep targets, with concurrency capped at 8 fast or 3 deep.' },
475
509
  summary: 'Run bounded multi-target collection',
476
- options: ['--json', '--jsonl', '--junit', '--csv', '--domains', '--queries', '--registered-only', '--inconclusive-only', '--errors-only', '--fast', '--deep', '--concurrency', '--checkpoint', '--resume', '--events', '--plan', '--fail-on', '--quiet', '--no-color'],
510
+ options: ['--json', '--jsonl', '--junit', '--csv', '--csv-with-metadata', '--domains', '--queries', '--registered-only', '--inconclusive-only', '--errors-only', '--fast', '--deep', '--concurrency', '--checkpoint', '--resume', '--events', '--plan', '--fail-on', '--quiet', '--no-color'],
477
511
  positionals: OPTIONAL_FILE_POSITIONAL,
478
512
  constraints: Object.freeze([
479
- constraint({ kind: 'mutually_exclusive', options: ['--json', '--jsonl', '--junit', '--csv', '--domains', '--queries'] }),
513
+ constraint({ kind: 'mutually_exclusive', options: ['--json', '--jsonl', '--junit', '--csv', '--csv-with-metadata', '--domains', '--queries'] }),
480
514
  constraint({ kind: 'mutually_exclusive', options: ['--registered-only', '--inconclusive-only', '--errors-only'] }),
481
515
  constraint({ kind: 'mutually_exclusive', options: ['--fast', '--deep'] }),
482
516
  constraint({ kind: 'requires_all', option: '--resume', requiredOptions: ['--checkpoint'] }),
483
- constraint({ kind: 'excludes_all', option: '--plan', excludedOptions: ['--jsonl', '--junit', '--csv', '--domains', '--queries', '--events', '--checkpoint', '--resume', '--quiet', '--fail-on'] }),
517
+ constraint({ kind: 'excludes_all', option: '--plan', excludedOptions: ['--jsonl', '--junit', '--csv', '--csv-with-metadata', '--domains', '--queries', '--events', '--checkpoint', '--resume', '--quiet', '--fail-on'] }),
484
518
  ]),
485
- helpGroup: 'investigate',
486
519
  handlerOwner: 'bulk',
487
520
  networkEffect: 'conditional_network',
488
521
  common: true,
@@ -494,7 +527,6 @@ const COMMAND_SEEDS = Object.freeze({
494
527
  }),
495
528
  "ct-search": commandSeed({
496
529
  reference: {
497
- usage: 'whoisleuth ct-search [keyword] [--json] [--quiet] [--no-color]',
498
530
  description: 'Search certificate-transparency observations for one bounded keyword.',
499
531
  example: 'whoisleuth ct-search "example brand" --json',
500
532
  boundary: 'Certificate observations do not prove website activity, registration ownership, or malicious intent.',
@@ -504,7 +536,6 @@ const COMMAND_SEEDS = Object.freeze({
504
536
  options: ['--json', '--quiet', '--no-color'],
505
537
  positionals: Object.freeze([positional('keyword', 'text', 0, 1, [], 'argv_or_stdin')]),
506
538
  constraints: EMPTY_CONSTRAINTS,
507
- helpGroup: 'investigate',
508
539
  handlerOwner: 'network',
509
540
  networkEffect: 'always_network',
510
541
  common: false,
@@ -516,7 +547,6 @@ const COMMAND_SEEDS = Object.freeze({
516
547
  }),
517
548
  "ct-intake": commandSeed({
518
549
  reference: {
519
- usage: 'whoisleuth ct-intake [events.json] [--json] [--quiet] [--no-color]',
520
550
  description: 'Normalise source-qualified local certificate events into browser-compatible findings.',
521
551
  example: 'whoisleuth ct-intake certificate-events.json --json',
522
552
  boundary: 'The command is offline, caps output at 100 findings, and treats every event as a review lead rather than proof of serving or control.',
@@ -526,7 +556,6 @@ const COMMAND_SEEDS = Object.freeze({
526
556
  options: ['--json', '--quiet', '--no-color'],
527
557
  positionals: OPTIONAL_FILE_POSITIONAL,
528
558
  constraints: EMPTY_CONSTRAINTS,
529
- helpGroup: 'investigate',
530
559
  handlerOwner: 'inline',
531
560
  networkEffect: 'offline',
532
561
  common: false,
@@ -538,8 +567,7 @@ const COMMAND_SEEDS = Object.freeze({
538
567
  }),
539
568
  discover: commandSeed({
540
569
  reference: {
541
- usage: 'whoisleuth discover [brand|domain] [--tlds <list>] [--preset <name>|--families <ids>] [--keyboard <layout>] [--dictionary <file>] [--snapshot <file>] [--json|--jsonl|--domains] [--quiet] [--no-color]',
542
- description: 'Generate bounded lookalike-domain candidates from local mutation rules.',
570
+ description: 'Generate bounded lookalike-domain candidates from a brand or registrable domain, including multi-part public suffixes.',
543
571
  example: 'whoisleuth discover example.test --preset common --jsonl',
544
572
  boundary: 'Generation and optional local snapshot comparison are offline. Candidates are leads only and are not resolved, registered, or classified as malicious.',
545
573
  },
@@ -552,7 +580,6 @@ const COMMAND_SEEDS = Object.freeze({
552
580
  constraint({ kind: 'mutually_exclusive', options: ['--preset', '--families'] }),
553
581
  constraint({ kind: 'value_excludes', option: '--preset', value: 'common', excludedOptions: ['--dictionary'] }),
554
582
  ]),
555
- helpGroup: 'investigate',
556
583
  handlerOwner: 'discovery',
557
584
  networkEffect: 'offline',
558
585
  common: true,
@@ -564,25 +591,23 @@ const COMMAND_SEEDS = Object.freeze({
564
591
  }),
565
592
  "discover-scan": commandSeed({
566
593
  reference: {
567
- usage: 'whoisleuth discover-scan [brand|domain] [--tlds <list>] [--preset <name>|--families <ids>] [--keyboard <layout>] [--dictionary <file>] [--fast|--deep] [--scan-limit <n>] [--chunk-size <n>] [--concurrency <n>] [--resolver <IPs>] [--allowlist <file>] [--checkpoint <file> [--resume]] [--observation-snapshot <file>] [--registered-only|--inconclusive-only|--acquisition-only|--suppressed-only] [--events] [--json|--jsonl|--csv|--domains] [--plan] [--fail-on <policies>] [--quiet] [--no-color]',
568
594
  description: 'Generate a bounded candidate set, collect a selected subset, and produce a supervised review queue.',
569
595
  example: 'whoisleuth discover-scan example.test --scan-limit 50 --checkpoint scan.json --json',
570
- boundary: 'This command performs network collection. Fast compact lookup is the default; deep mode is capped at 50 candidates. Allowlisting changes review priority only and shared infrastructure remains a lead, not attribution.',
596
+ boundary: 'This command performs network collection. Fast compact lookup is the default; deep mode is capped at 50 candidates. Allowlisting changes review priority only and shared infrastructure remains a lead, not attribution. --csv-with-metadata adds source versions, separate observation and report times, collection origin and diagnostic states; --csv retains the compact columns.',
571
597
  },
572
598
  collection: { mode: 'network', scope: 'Scans at most 500 fast or 50 deep candidates, with concurrency capped at 8 fast or 3 deep.' },
573
599
  summary: 'Collect a supervised candidate review queue',
574
- options: ['--tlds', '--preset', '--families', '--keyboard', '--dictionary', '--fast', '--deep', '--scan-limit', '--chunk-size', '--concurrency', '--resolver', '--allowlist', '--checkpoint', '--resume', '--observation-snapshot', '--registered-only', '--inconclusive-only', '--acquisition-only', '--suppressed-only', '--events', '--plan', '--fail-on', '--json', '--jsonl', '--csv', '--domains', '--quiet', '--no-color'],
600
+ options: ['--tlds', '--preset', '--families', '--keyboard', '--dictionary', '--fast', '--deep', '--scan-limit', '--chunk-size', '--concurrency', '--resolver', '--allowlist', '--checkpoint', '--resume', '--observation-snapshot', '--registered-only', '--inconclusive-only', '--acquisition-only', '--suppressed-only', '--events', '--plan', '--fail-on', '--json', '--jsonl', '--csv', '--csv-with-metadata', '--domains', '--quiet', '--no-color'],
575
601
  positionals: OPTIONAL_TEXT_POSITIONAL,
576
602
  constraints: Object.freeze([
577
- constraint({ kind: 'mutually_exclusive', options: ['--json', '--jsonl', '--csv', '--domains'] }),
603
+ constraint({ kind: 'mutually_exclusive', options: ['--json', '--jsonl', '--csv', '--csv-with-metadata', '--domains'] }),
578
604
  constraint({ kind: 'mutually_exclusive', options: ['--preset', '--families'] }),
579
605
  constraint({ kind: 'mutually_exclusive', options: ['--fast', '--deep'] }),
580
606
  constraint({ kind: 'mutually_exclusive', options: ['--registered-only', '--inconclusive-only', '--acquisition-only', '--suppressed-only'] }),
581
607
  constraint({ kind: 'requires_all', option: '--resume', requiredOptions: ['--checkpoint'] }),
582
608
  constraint({ kind: 'value_excludes', option: '--preset', value: 'common', excludedOptions: ['--dictionary'] }),
583
- constraint({ kind: 'excludes_all', option: '--plan', excludedOptions: ['--jsonl', '--csv', '--domains', '--events', '--checkpoint', '--resume', '--observation-snapshot', '--quiet', '--fail-on'] }),
609
+ constraint({ kind: 'excludes_all', option: '--plan', excludedOptions: ['--jsonl', '--csv', '--csv-with-metadata', '--domains', '--events', '--checkpoint', '--resume', '--observation-snapshot', '--quiet', '--fail-on'] }),
584
610
  ]),
585
- helpGroup: 'investigate',
586
611
  handlerOwner: 'discovery_scan',
587
612
  networkEffect: 'conditional_network',
588
613
  common: true,
@@ -594,20 +619,18 @@ const COMMAND_SEEDS = Object.freeze({
594
619
  }),
595
620
  posture: commandSeed({
596
621
  reference: {
597
- usage: 'whoisleuth posture [domain] [--selectors <list>] [--retired-selectors <list>] [--mail-profile <profile>] [--json|--sarif --owned-domain] [--quiet] [--no-color]',
598
622
  description: 'Review bounded DNS mail, delegation, and domain-control posture.',
599
623
  example: 'whoisleuth posture example.test --mail-profile standard --json',
600
- boundary: 'Missing or failed DNS observations remain inconclusive and are not reported as absent controls.',
624
+ boundary: 'Missing or failed DNS observations remain inconclusive. --include-inherited-dns explicitly adds a bounded DMARC tree walk and direct parent-delegation sample; records retain their queried owner and source. No message is sent and receiver enforcement is not inferred.',
601
625
  },
602
- collection: { mode: 'network', scope: 'Accepts one domain and performs bounded RDAP, DNS, and conditional MTA-STS HTTPS requests.' },
626
+ collection: { mode: 'network', scope: 'Accepts one domain and performs bounded RDAP, DNS, and conditional MTA-STS HTTPS requests. --include-inherited-dns separately adds ancestor DMARC questions and direct DNS/TCP to sampled parent servers.' },
603
627
  summary: 'Review DNS and mail posture',
604
- options: ['--selectors', '--retired-selectors', '--mail-profile', '--json', '--sarif', '--owned-domain', '--quiet', '--no-color'],
628
+ options: ['--selectors', '--retired-selectors', '--mail-profile', '--include-inherited-dns', '--json', '--sarif', '--owned-domain', '--quiet', '--no-color'],
605
629
  positionals: Object.freeze([positional('domain', 'text', 0, 1, [], 'argv_or_stdin')]),
606
630
  constraints: Object.freeze([
607
631
  constraint({ kind: 'mutually_exclusive', options: ['--json', '--sarif'] }),
608
632
  constraint({ kind: 'requires_all', option: '--sarif', requiredOptions: ['--owned-domain'] }),
609
633
  ]),
610
- helpGroup: 'investigate',
611
634
  handlerOwner: 'network',
612
635
  networkEffect: 'always_network',
613
636
  common: false,
@@ -619,7 +642,6 @@ const COMMAND_SEEDS = Object.freeze({
619
642
  }),
620
643
  http: commandSeed({
621
644
  reference: {
622
- usage: 'whoisleuth http [domain] [--json] [--quiet] [--no-color]',
623
645
  description: 'Inspect one homepage request, redirects, and bounded response metadata.',
624
646
  example: 'whoisleuth http example.test --json',
625
647
  boundary: 'Requests use the shared public-address and redirect guards. Fixed content-coding and cache-policy metadata describes only the selected response, excludes raw header values, and does not prove caching, transfer savings, performance, privacy, or safety. This is not a rendered browser or vulnerability scan.',
@@ -629,7 +651,6 @@ const COMMAND_SEEDS = Object.freeze({
629
651
  options: ['--json', '--quiet', '--no-color'],
630
652
  positionals: Object.freeze([positional('domain', 'text', 0, 1, [], 'argv_or_stdin')]),
631
653
  constraints: EMPTY_CONSTRAINTS,
632
- helpGroup: 'investigate',
633
654
  handlerOwner: 'network',
634
655
  networkEffect: 'always_network',
635
656
  common: false,
@@ -641,7 +662,6 @@ const COMMAND_SEEDS = Object.freeze({
641
662
  }),
642
663
  tls: commandSeed({
643
664
  reference: {
644
- usage: 'whoisleuth tls [hostname] [--json] [--quiet] [--no-color]',
645
665
  description: 'Inspect one hostname certificate through a bounded TLS connection.',
646
666
  example: 'whoisleuth tls example.test --json',
647
667
  boundary: 'One observed connection is point-in-time evidence and does not establish every address, edge, or historical certificate.',
@@ -651,7 +671,6 @@ const COMMAND_SEEDS = Object.freeze({
651
671
  options: ['--json', '--quiet', '--no-color'],
652
672
  positionals: Object.freeze([positional('hostname', 'text', 0, 1, [], 'argv_or_stdin')]),
653
673
  constraints: EMPTY_CONSTRAINTS,
654
- helpGroup: 'investigate',
655
674
  handlerOwner: 'network',
656
675
  networkEffect: 'always_network',
657
676
  common: false,
@@ -663,7 +682,6 @@ const COMMAND_SEEDS = Object.freeze({
663
682
  }),
664
683
  "dnssec-validate": commandSeed({
665
684
  reference: {
666
- usage: 'whoisleuth dnssec-validate <domain> --resolver <public-IP> --trust-anchor <anchor.json> --owned-or-authorized [--json] [--quiet] [--no-color]',
667
685
  description: 'Cryptographically validate one authorised DNSSEC chain from a supplied trust anchor through one selected public resolver.',
668
686
  example: 'whoisleuth dnssec-validate example.test --resolver "$PUBLIC_RESOLVER_IP" --trust-anchor anchor.json --owned-or-authorized --json',
669
687
  boundary: 'This isolated action is never invoked by Lookup, Bulk, monitoring, or recipes. It caps DNS queries, aliases, delegations, bytes, and duration; transport and validation failures remain separate, and secure is not a general safety verdict.',
@@ -675,7 +693,6 @@ const COMMAND_SEEDS = Object.freeze({
675
693
  constraints: Object.freeze([
676
694
  constraint({ kind: 'required', options: ['--resolver', '--trust-anchor', '--owned-or-authorized'] }),
677
695
  ]),
678
- helpGroup: 'assure',
679
696
  handlerOwner: 'network',
680
697
  networkEffect: 'always_network',
681
698
  common: false,
@@ -687,7 +704,6 @@ const COMMAND_SEEDS = Object.freeze({
687
704
  }),
688
705
  "mail-transport": commandSeed({
689
706
  reference: {
690
- usage: 'whoisleuth mail-transport [input.json] --resolver <public-IP> --trust-anchor <anchor.json> --owned-or-authorized --active-probe [--json] [--quiet] [--no-color]',
691
707
  description: 'Review selected authorised MX endpoints, DNSSEC-qualified TLSA evidence, SMTP capabilities, and optional STARTTLS certificates.',
692
708
  example: 'whoisleuth mail-transport selected-mx.json --resolver "$PUBLIC_RESOLVER_IP" --trust-anchor anchor.json --owned-or-authorized --active-probe --json',
693
709
  boundary: 'This isolated action probes at most three selected MX hosts sequentially, reports selection, public revalidation, connection, and address authentication separately, sends only EHLO and optional STARTTLS, never retries, and performs no authentication, relay, recipient, mailbox, catch-all, or message test. If a DANE-TA TLSA usage 2 association is published, active collection retains only the leaf certificate and leaves that comparison partial without certificate-path construction and trust-anchor path validation. SMTP relay PKIX-TA usage 0 and PKIX-EE usage 1 records remain unsupported and cannot complete SMTP DANE assurance; a separate usage 3 match remains eligible.',
@@ -699,7 +715,6 @@ const COMMAND_SEEDS = Object.freeze({
699
715
  constraints: Object.freeze([
700
716
  constraint({ kind: 'required', options: ['--resolver', '--trust-anchor', '--owned-or-authorized', '--active-probe'] }),
701
717
  ]),
702
- helpGroup: 'assure',
703
718
  handlerOwner: 'network',
704
719
  networkEffect: 'always_network',
705
720
  common: false,
@@ -711,7 +726,6 @@ const COMMAND_SEEDS = Object.freeze({
711
726
  }),
712
727
  "registry-support": commandSeed({
713
728
  reference: {
714
- usage: 'whoisleuth registry-support [domain|suffix] [--json] [--quiet] [--no-color]',
715
729
  description: 'Explain the local registry capability profile for one domain or suffix.',
716
730
  example: 'whoisleuth registry-support example.test --json',
717
731
  boundary: 'This command is offline. Catalogue coverage does not test live reachability or decide registration or availability.',
@@ -721,7 +735,6 @@ const COMMAND_SEEDS = Object.freeze({
721
735
  options: ['--json', '--quiet', '--no-color'],
722
736
  positionals: Object.freeze([positional('domain-or-suffix', 'text', 0, 1, [], 'argv_or_stdin')]),
723
737
  constraints: EMPTY_CONSTRAINTS,
724
- helpGroup: 'investigate',
725
738
  handlerOwner: 'inline',
726
739
  networkEffect: 'offline',
727
740
  common: false,
@@ -733,7 +746,6 @@ const COMMAND_SEEDS = Object.freeze({
733
746
  }),
734
747
  "registry-doctor": commandSeed({
735
748
  reference: {
736
- usage: 'whoisleuth registry-doctor [lookup.json] [--json] [--quiet] [--no-color]',
737
749
  description: 'Compare a saved Lookup registry result with the reviewed local capability profile.',
738
750
  example: 'whoisleuth registry-doctor lookup.json --json',
739
751
  boundary: 'The command is offline. It distinguishes expected access constraints from collection results and does not contact a live registry.',
@@ -743,7 +755,6 @@ const COMMAND_SEEDS = Object.freeze({
743
755
  options: ['--json', '--quiet', '--no-color'],
744
756
  positionals: OPTIONAL_FILE_POSITIONAL,
745
757
  constraints: EMPTY_CONSTRAINTS,
746
- helpGroup: 'investigate',
747
758
  handlerOwner: 'inline',
748
759
  networkEffect: 'offline',
749
760
  common: false,
@@ -755,7 +766,6 @@ const COMMAND_SEEDS = Object.freeze({
755
766
  }),
756
767
  "registry-cohort": commandSeed({
757
768
  reference: {
758
- usage: 'whoisleuth registry-cohort [lookups-or-reports.json|jsonl] [--json] [--quiet] [--no-color]',
759
769
  description: 'Build privacy-safe suffix and capability-profile timelines from saved observations or retained cohort reports.',
760
770
  example: 'whoisleuth registry-cohort saved-lookups.jsonl --json',
761
771
  boundary: 'This command is offline and omits domains, queries, and raw evidence. Input families cannot be mixed, and retained samples are never assumed independent.',
@@ -765,7 +775,6 @@ const COMMAND_SEEDS = Object.freeze({
765
775
  options: ['--json', '--quiet', '--no-color'],
766
776
  positionals: OPTIONAL_FILE_POSITIONAL,
767
777
  constraints: EMPTY_CONSTRAINTS,
768
- helpGroup: 'investigate',
769
778
  handlerOwner: 'inline',
770
779
  networkEffect: 'offline',
771
780
  common: false,
@@ -777,7 +786,6 @@ const COMMAND_SEEDS = Object.freeze({
777
786
  }),
778
787
  "registry-scaffold": commandSeed({
779
788
  reference: {
780
- usage: 'whoisleuth registry-scaffold --profile <id> --suffix <suffix> --scenario <registered|not_found|inconclusive>',
781
789
  description: 'Create a bounded synthetic WHOIS fixture scaffold for one existing capability profile.',
782
790
  example: 'whoisleuth registry-scaffold --profile example-profile --suffix test --scenario registered',
783
791
  boundary: 'The output is a sanitised template only. Its command-owned --profile selects fixture capability, --config is rejected, and contributors must not paste live responses or personal registration data into fixtures.',
@@ -789,7 +797,6 @@ const COMMAND_SEEDS = Object.freeze({
789
797
  constraints: Object.freeze([
790
798
  constraint({ kind: 'required', options: ['--profile', '--suffix', '--scenario'] }),
791
799
  ]),
792
- helpGroup: 'utilities',
793
800
  handlerOwner: 'inline',
794
801
  networkEffect: 'offline',
795
802
  common: false,
@@ -801,7 +808,6 @@ const COMMAND_SEEDS = Object.freeze({
801
808
  }),
802
809
  "risk-calibrate": commandSeed({
803
810
  reference: {
804
- usage: 'whoisleuth risk-calibrate [dataset.json] [--json|--summary-json] [--quiet] [--no-color]',
805
811
  description: 'Replay reviewed labels against the current explainable Risk model.',
806
812
  example: 'whoisleuth risk-calibrate calibration.json --summary-json',
807
813
  boundary: 'Calibration is offline and diagnostic. The summary form omits record identifiers, domains, and evidence; neither form trains, tunes, or changes the scoring model automatically.',
@@ -813,7 +819,6 @@ const COMMAND_SEEDS = Object.freeze({
813
819
  constraints: Object.freeze([
814
820
  constraint({ kind: 'mutually_exclusive', options: ['--json', '--summary-json'] }),
815
821
  ]),
816
- helpGroup: 'assure',
817
822
  handlerOwner: 'inline',
818
823
  networkEffect: 'offline',
819
824
  common: false,
@@ -825,7 +830,6 @@ const COMMAND_SEEDS = Object.freeze({
825
830
  }),
826
831
  "lookalike-calibrate": commandSeed({
827
832
  reference: {
828
- usage: 'whoisleuth lookalike-calibrate [dataset.json] [--json] [--quiet] [--no-color]',
829
833
  description: 'Summarise reviewed candidate dispositions by mutation family without retaining domains.',
830
834
  example: 'whoisleuth lookalike-calibrate reviewed-candidates.json --json',
831
835
  boundary: 'Calibration is offline and diagnostic. It omits candidate identifiers, domains, notes, and evidence and never tunes generation or filtering automatically.',
@@ -835,7 +839,6 @@ const COMMAND_SEEDS = Object.freeze({
835
839
  options: ['--json', '--quiet', '--no-color'],
836
840
  positionals: OPTIONAL_FILE_POSITIONAL,
837
841
  constraints: EMPTY_CONSTRAINTS,
838
- helpGroup: 'assure',
839
842
  handlerOwner: 'inline',
840
843
  networkEffect: 'offline',
841
844
  common: false,
@@ -847,20 +850,22 @@ const COMMAND_SEEDS = Object.freeze({
847
850
  }),
848
851
  "verify-artifact": commandSeed({
849
852
  reference: {
850
- usage: 'whoisleuth verify-artifact [artifact.json] [--passphrase-file <file>] [--manifest <manifest.json> --manifest-entry <artifact-N>] [--json] [--strict-exit] [--quiet] [--no-color]',
851
- description: 'Validate a supported archive, claim passport, packet, manifest, saved Lookup, or supported Lookup-evidence export without printing evidence contents.',
853
+ description: 'Validate a supported archive, ordinary Case export, claim passport, packet, manifest, saved Lookup or Lookup-evidence export without printing evidence contents. Use --package for an evidence ZIP or encrypted package, with --passphrase-file to unlock it; use --folder ./evidence for an unencrypted evidence folder. Add --bagit with --package or --folder to verify BagIt 1.0.',
852
854
  example: 'whoisleuth verify-artifact report.json --manifest manifest.json --manifest-entry artifact-2 --json --strict-exit',
853
- boundary: 'Verification is offline and redacted. Encrypted archives require an explicitly supplied passphrase file; --strict-exit returns 4 when only an envelope or legacy projection integrity was verified.',
855
+ boundary: 'Verification is offline and redacted. ZIP and folder entries are reported separately without importing them. Case exports are checked without repairing content; ordinary package review also counts original references with matching bytes. Ordinary folders allow only the declared layout; BagIt allows bounded nested payloads and checks SHA-256/SHA-512 manifests without interpreting payloads. Symbolic links are refused. BagIt fetch.txt is never fetched; missing files, mismatches and unsupported algorithms remain explicit. Package digests describe bytes, not filesystem metadata or authenticity. In scripts, use --strict-exit: incomplete verification returns 4. Default exit 0 means the report was produced, not that its checks passed.',
854
856
  },
855
- collection: { mode: 'offline', scope: 'Reads one selected bounded artefact and, when explicitly supplied, one manifest whose selected entry is compared by exact bytes and canonical identity.' },
857
+ collection: { mode: 'offline', scope: 'Reads one selected bounded artefact, ZIP or explicit evidence folder and, when explicitly supplied, one manifest whose selected entry is compared by exact bytes and canonical identity.' },
856
858
  summary: 'Validate saved evidence offline',
857
- options: ['--passphrase-file', '--manifest', '--manifest-entry', '--json', '--strict-exit', '--quiet', '--no-color'],
858
- positionals: OPTIONAL_FILE_POSITIONAL,
859
+ options: ['--passphrase-file', '--manifest', '--manifest-entry', '--package', '--bagit', '--folder', '--json', '--strict-exit', '--quiet', '--no-color'],
860
+ positionals: Object.freeze([positional('source', 'file', 0, 1, [], 'argv_or_stdin', ['--package'])]),
859
861
  constraints: Object.freeze([
862
+ constraint({ kind: 'requires_any', option: '--bagit', requiredOptions: ['--package', '--folder'] }),
863
+ constraint({ kind: 'excludes_all', option: '--bagit', excludedOptions: ['--passphrase-file', '--manifest', '--manifest-entry'] }),
860
864
  constraint({ kind: 'requires_all', option: '--manifest', requiredOptions: ['--manifest-entry'] }),
861
865
  constraint({ kind: 'requires_all', option: '--manifest-entry', requiredOptions: ['--manifest'] }),
866
+ constraint({ kind: 'excludes_all', option: '--package', excludedOptions: ['--manifest', '--manifest-entry'] }),
867
+ constraint({ kind: 'excludes_all', option: '--folder', excludedOptions: ['--package', '--manifest', '--manifest-entry', '--passphrase-file'] }),
862
868
  ]),
863
- helpGroup: 'assure',
864
869
  handlerOwner: 'inline',
865
870
  networkEffect: 'offline',
866
871
  common: true,
@@ -872,7 +877,6 @@ const COMMAND_SEEDS = Object.freeze({
872
877
  }),
873
878
  "interchange-report": commandSeed({
874
879
  reference: {
875
- usage: 'whoisleuth interchange-report [artifact.json] [--passphrase-file <file>] [--json] [--quiet] [--no-color]',
876
880
  description: 'Report what one recognised portable artefact preserves, excludes, and supports across browser and CLI workflows.',
877
881
  example: 'whoisleuth interchange-report workspace.json --json',
878
882
  boundary: 'The report is offline and metadata-only. It does not echo targets, contacts, notes, passphrases, evidence values, or an unrecognised schema string.',
@@ -882,7 +886,6 @@ const COMMAND_SEEDS = Object.freeze({
882
886
  options: ['--passphrase-file', '--json', '--quiet', '--no-color'],
883
887
  positionals: OPTIONAL_FILE_POSITIONAL,
884
888
  constraints: EMPTY_CONSTRAINTS,
885
- helpGroup: 'assure',
886
889
  handlerOwner: 'inline',
887
890
  networkEffect: 'offline',
888
891
  common: false,
@@ -894,10 +897,9 @@ const COMMAND_SEEDS = Object.freeze({
894
897
  }),
895
898
  "inspect-archive": commandSeed({
896
899
  reference: {
897
- usage: 'whoisleuth inspect-archive [archive.json] [--passphrase-file <file>] [--search <value>] [--require-match] [--reveal] [--expect-content-digest <sha256:digest>] [--json] [--quiet] [--no-color]',
898
900
  description: `Summarise or search one current version-${WORKSPACE_ARCHIVE_VERSION} workspace archive, with exact ${LEGACY_WORKSPACE_ARCHIVE_DESCRIPTION} support and redacted output by default.`,
899
901
  example: 'whoisleuth inspect-archive workspace.json --search example.test --json',
900
- boundary: 'Exact matches require --reveal. Retired and future archive versions are rejected without changing data. The archive is read locally and is never uploaded.',
902
+ boundary: 'Exact values require --reveal. New content comparisons use the reported sorted-json-v2:sha256 identity with --expect-content-digest; bare sha256 hashes retain their legacy locale-sensitive meaning. Retired and future archives are rejected. The archive is read locally and is never uploaded.',
901
903
  },
902
904
  collection: { mode: 'offline', scope: `Reads one selected bounded workspace archive v${WORKSPACE_ARCHIVE_VERSION}, retains exact ${LEGACY_WORKSPACE_ARCHIVE_SCOPE} compatibility, and redacts output by default.` },
903
905
  summary: 'Inspect an archive locally',
@@ -907,7 +909,6 @@ const COMMAND_SEEDS = Object.freeze({
907
909
  constraint({ kind: 'requires_all', option: '--reveal', requiredOptions: ['--search'] }),
908
910
  constraint({ kind: 'requires_all', option: '--require-match', requiredOptions: ['--search'] }),
909
911
  ]),
910
- helpGroup: 'assure',
911
912
  handlerOwner: 'evidence',
912
913
  networkEffect: 'offline',
913
914
  common: false,
@@ -919,7 +920,6 @@ const COMMAND_SEEDS = Object.freeze({
919
920
  }),
920
921
  "sign-artifact": commandSeed({
921
922
  reference: {
922
- usage: 'whoisleuth sign-artifact [artifact.json] --private-key-file <file>',
923
923
  description: 'Sign one reviewed response packet or supported manifest with a local private key.',
924
924
  example: 'whoisleuth sign-artifact packet.json --private-key-file analyst-private.pem',
925
925
  boundary: 'The command never creates, stores, or transmits keys. Key custody and signer identity remain the operator\'s responsibility.',
@@ -931,7 +931,6 @@ const COMMAND_SEEDS = Object.freeze({
931
931
  constraints: Object.freeze([
932
932
  constraint({ kind: 'required', options: ['--private-key-file'] }),
933
933
  ]),
934
- helpGroup: 'assure',
935
934
  handlerOwner: 'evidence',
936
935
  networkEffect: 'offline',
937
936
  common: false,
@@ -943,21 +942,19 @@ const COMMAND_SEEDS = Object.freeze({
943
942
  }),
944
943
  "verify-signature": commandSeed({
945
944
  reference: {
946
- usage: 'whoisleuth verify-signature [package.json] [--public-key-file <file>] [--json] [--quiet] [--no-color]',
947
- description: 'Verify the cryptographic signature of one signed evidence package and report embedded-artefact assurance separately.',
948
- example: 'whoisleuth verify-signature packet.signed.json --json',
949
- boundary: 'A valid signature proves package consistency for the embedded key. It does not upgrade failed or unsupported embedded-artefact assurance or establish the holder\'s real-world identity or authority.',
945
+ description: 'Verify one signed package and report embedded-artefact assurance separately. --trust-store-file also checks an explicit local fingerprint policy and emits a signer-trust report; unknown, retired, revoked or future-reviewed entries exit 4.',
946
+ example: 'whoisleuth verify-signature packet.signed.json --trust-store-file trust.json --json',
947
+ boundary: 'A valid signature proves package consistency for the embedded key, not identity, authority or evidence accuracy. With --trust-store-file, unknown, retired, revoked or future-reviewed entries exit 4 even if --public-key-file matches. Replacement fingerprints need their own trusted entry; no signing date overrides current revocation.',
950
948
  },
951
- collection: { mode: 'offline', scope: 'Reads one selected signed package and optional local public key.' },
949
+ collection: { mode: 'offline', scope: 'Reads one selected signed package, optional local public key and explicit fingerprint trust file. No automatic trust discovery or network requests.' },
952
950
  summary: 'Verify a signed evidence package',
953
- options: ['--public-key-file', '--json', '--quiet', '--no-color'],
951
+ options: ['--public-key-file', '--trust-store-file', '--json', '--quiet', '--no-color'],
954
952
  positionals: OPTIONAL_FILE_POSITIONAL,
955
953
  constraints: EMPTY_CONSTRAINTS,
956
- helpGroup: 'assure',
957
954
  handlerOwner: 'evidence',
958
955
  networkEffect: 'offline',
959
956
  common: false,
960
- schemaIdentifiers: Object.freeze(['whoisleuth.evidence-signature-verification']),
957
+ schemaIdentifiers: Object.freeze(['whoisleuth.evidence-signature-verification', 'whoisleuth.evidence-signer-trust-store', 'whoisleuth.evidence-signer-trust-report']),
961
958
  primaryArtefacts: Object.freeze([]),
962
959
  planSupport: false,
963
960
  additionalOutputFormats: Object.freeze([]),
@@ -965,7 +962,6 @@ const COMMAND_SEEDS = Object.freeze({
965
962
  }),
966
963
  "source-report": commandSeed({
967
964
  reference: {
968
- usage: 'whoisleuth source-report [lookup.json] [--json] [--quiet] [--no-color]',
969
965
  description: 'Create a target-free reliability summary from a saved lookup.',
970
966
  example: 'whoisleuth source-report lookup.json --json',
971
967
  boundary: 'The report retains source states and timings but excludes targets, queries, endpoints, and raw evidence.',
@@ -975,7 +971,6 @@ const COMMAND_SEEDS = Object.freeze({
975
971
  options: ['--json', '--quiet', '--no-color'],
976
972
  positionals: OPTIONAL_FILE_POSITIONAL,
977
973
  constraints: EMPTY_CONSTRAINTS,
978
- helpGroup: 'investigate',
979
974
  handlerOwner: 'inline',
980
975
  networkEffect: 'offline',
981
976
  common: false,
@@ -987,7 +982,6 @@ const COMMAND_SEEDS = Object.freeze({
987
982
  }),
988
983
  compare: commandSeed({
989
984
  reference: {
990
- usage: 'whoisleuth compare [lookup.json] [--json] [--quiet] [--no-color]',
991
985
  description: 'Compare separately attributed registry publications in a saved lookup.',
992
986
  example: 'whoisleuth compare lookup.json --json',
993
987
  boundary: 'Comparison is offline. Differences are review context and do not by themselves prove which publication is current.',
@@ -997,7 +991,6 @@ const COMMAND_SEEDS = Object.freeze({
997
991
  options: ['--json', '--quiet', '--no-color'],
998
992
  positionals: OPTIONAL_FILE_POSITIONAL,
999
993
  constraints: EMPTY_CONSTRAINTS,
1000
- helpGroup: 'investigate',
1001
994
  handlerOwner: 'inline',
1002
995
  networkEffect: 'offline',
1003
996
  common: false,
@@ -1009,7 +1002,6 @@ const COMMAND_SEEDS = Object.freeze({
1009
1002
  }),
1010
1003
  "page-compare": commandSeed({
1011
1004
  reference: {
1012
- usage: 'whoisleuth page-compare <left.json> <right.json> [--json] [--quiet] [--no-color]',
1013
1005
  description: 'Compare static page identity, favicon, technology, and TLS evidence in two saved deep lookups.',
1014
1006
  example: 'whoisleuth page-compare official.json candidate.json --json',
1015
1007
  boundary: 'Comparison is offline and component-based. It executes no page code and produces no aggregate similarity or maliciousness score.',
@@ -1019,7 +1011,6 @@ const COMMAND_SEEDS = Object.freeze({
1019
1011
  options: ['--json', '--quiet', '--no-color'],
1020
1012
  positionals: Object.freeze([positional('sources', 'file', 2, 2)]),
1021
1013
  constraints: EMPTY_CONSTRAINTS,
1022
- helpGroup: 'investigate',
1023
1014
  handlerOwner: 'inline',
1024
1015
  networkEffect: 'offline',
1025
1016
  common: false,
@@ -1031,7 +1022,6 @@ const COMMAND_SEEDS = Object.freeze({
1031
1022
  }),
1032
1023
  "mail-review": commandSeed({
1033
1024
  reference: {
1034
- usage: 'whoisleuth mail-review [bulk.json|bulk.jsonl] [--json] [--quiet] [--no-color]',
1035
1025
  description: 'Review passive MX, null MX, SPF, DMARC, and shared mail-provider evidence from saved Bulk results.',
1036
1026
  example: 'whoisleuth mail-review candidates.json --json',
1037
1027
  boundary: 'Review is offline and sends no SMTP traffic. Missing or partial DNS evidence remains inconclusive.',
@@ -1041,7 +1031,6 @@ const COMMAND_SEEDS = Object.freeze({
1041
1031
  options: ['--json', '--quiet', '--no-color'],
1042
1032
  positionals: OPTIONAL_FILE_POSITIONAL,
1043
1033
  constraints: EMPTY_CONSTRAINTS,
1044
- helpGroup: 'investigate',
1045
1034
  handlerOwner: 'inline',
1046
1035
  networkEffect: 'offline',
1047
1036
  common: false,
@@ -1051,9 +1040,28 @@ const COMMAND_SEEDS = Object.freeze({
1051
1040
  additionalOutputFormats: Object.freeze([]),
1052
1041
  bootstrapProfile: 'allowed',
1053
1042
  }),
1043
+ "mail-headers": commandSeed({
1044
+ reference: {
1045
+ description: 'Review identity, reported authentication, domain alignment, and Received routing from selected message headers.',
1046
+ example: 'whoisleuth mail-headers message.eml --json',
1047
+ boundary: 'Review is offline. It makes no DNS, SMTP, HTTP, registry, or provider request, and does not retain address local parts, display names, subjects, message bodies, attachments, or raw header values. Reported authentication is not independently validated.',
1048
+ },
1049
+ collection: { mode: 'offline', scope: 'Parses only the bounded header block from one selected message or standard input.' },
1050
+ summary: 'Review message headers offline',
1051
+ options: ['--json', '--quiet', '--no-color'],
1052
+ positionals: OPTIONAL_FILE_POSITIONAL,
1053
+ constraints: EMPTY_CONSTRAINTS,
1054
+ handlerOwner: 'inline',
1055
+ networkEffect: 'offline',
1056
+ common: false,
1057
+ schemaIdentifiers: Object.freeze(['whoisleuth.cli.mail-header-review']),
1058
+ primaryArtefacts: Object.freeze([]),
1059
+ planSupport: false,
1060
+ additionalOutputFormats: Object.freeze([]),
1061
+ bootstrapProfile: 'allowed',
1062
+ }),
1054
1063
  "review-evidence": commandSeed({
1055
1064
  reference: {
1056
- usage: 'whoisleuth review-evidence [evidence.json] [--mmdb <database-file>] [--json] [--strict-exit] [--quiet] [--no-color]',
1057
1065
  description: 'Review one versioned DNS, domain-change, routing, GeoIP, RDAP, or trust-store document offline.',
1058
1066
  example: 'whoisleuth review-evidence domain-change.json --json --strict-exit',
1059
1067
  boundary: 'The command reads only the supplied document. It performs no DNS, RDAP, BGP, GeoIP-provider, TLS, HTTP, certificate-authority, or SMTP request.',
@@ -1063,7 +1071,6 @@ const COMMAND_SEEDS = Object.freeze({
1063
1071
  options: ['--mmdb', '--json', '--strict-exit', '--quiet', '--no-color'],
1064
1072
  positionals: OPTIONAL_FILE_POSITIONAL,
1065
1073
  constraints: EMPTY_CONSTRAINTS,
1066
- helpGroup: 'investigate',
1067
1074
  handlerOwner: 'inline',
1068
1075
  networkEffect: 'offline',
1069
1076
  common: true,
@@ -1083,7 +1090,6 @@ const COMMAND_SEEDS = Object.freeze({
1083
1090
  }),
1084
1091
  brief: commandSeed({
1085
1092
  reference: {
1086
- usage: 'whoisleuth brief [lookup.json] [--json] [--quiet] [--no-color]',
1087
1093
  description: 'Turn one saved Lookup into a compact decision brief with facts, unknowns, contradictions, and next actions.',
1088
1094
  example: 'whoisleuth brief lookup.json --json',
1089
1095
  boundary: 'The command is offline, excludes raw upstream payloads, and does not create an analyst assertion or claim that the saved observation is current.',
@@ -1093,7 +1099,6 @@ const COMMAND_SEEDS = Object.freeze({
1093
1099
  options: ['--json', '--quiet', '--no-color'],
1094
1100
  positionals: OPTIONAL_FILE_POSITIONAL,
1095
1101
  constraints: EMPTY_CONSTRAINTS,
1096
- helpGroup: 'investigate',
1097
1102
  handlerOwner: 'inline',
1098
1103
  networkEffect: 'offline',
1099
1104
  common: false,
@@ -1103,21 +1108,35 @@ const COMMAND_SEEDS = Object.freeze({
1103
1108
  additionalOutputFormats: Object.freeze([]),
1104
1109
  bootstrapProfile: 'allowed',
1105
1110
  }),
1111
+ case: commandSeed({
1112
+ reference: {
1113
+ description: 'Show or open a local Case, append a note or evidence pin, record an assessment, or retain an offline recheck. Use --input for pin, assessment and recheck JSON; --text or --note-file for a note. Mutations require --output and always write the complete current Case export.',
1114
+ example: 'whoisleuth case open --domain example.test --output cases.json\n whoisleuth case show cases.json\n whoisleuth case note cases.json --text "Review the retained observation" --output cases.json --force',
1115
+ boundary: 'No database, browser launch, request or external report is created. Select --case-id when a file contains multiple Cases. Existing files require --force; --expect-file-digest sha256:<digest> additionally checks the exact file reviewed earlier. Source and output leases reject concurrent changes. Interrupted .workflow.lock files require deliberate inspection. Recheck records supplied observations; it does not collect them. Not reproduced requires an existing saved question, a complete observation and comparable conditions. Working exports include private analyst content and file references, not attached file bytes.',
1116
+ },
1117
+ collection: { mode: 'offline', scope: `Reads exact Case schemas ${CLI_CASE_PACK_INPUT_CASE_VERSIONS.join(' or ')}. Input is bounded to ${MAX_EDITABLE_CASE_INPUT_BYTES / 1024 / 1024} MiB including formatting; the complete canonical Case store must fit ${MAX_CASE_STORE_BYTES / 1024 / 1024} MiB without pruning. Writes current schema ${CASE_SCHEMA_VERSION}.` },
1118
+ summary: 'Review and update ordinary local Case files',
1119
+ options: ['--case-id', '--domain', '--title', '--new-incident', '--text', '--note-file', '--input', '--expect-file-digest', '--json', '--no-color'],
1120
+ positionals: Object.freeze([positional('operation', 'enum', 1, 1, CLI_CASE_OPERATIONS), positional('source', 'file', 0, 1)]),
1121
+ constraints: Object.freeze([constraint({ kind: 'mutually_exclusive', options: ['--text', '--note-file'] })]),
1122
+ handlerOwner: 'inline', networkEffect: 'offline', common: true,
1123
+ schemaIdentifiers: Object.freeze(['whoisleuth.case-export']),
1124
+ primaryArtefacts: Object.freeze(['Case export']), planSupport: false,
1125
+ additionalOutputFormats: Object.freeze([]), bootstrapProfile: 'allowed',
1126
+ }),
1106
1127
  "case-pack": commandSeed({
1107
1128
  reference: {
1108
- usage: 'whoisleuth case-pack [cases.json] --audience <internal|trusted|public> --reviewed [--json] [--quiet] [--no-color]',
1109
- description: `Package browser-created Case-schema-${CASE_SCHEMA_VERSION} records as a reviewed, audience-specific Case-pack v2.`,
1129
+ description: `Package browser-created Case records from schemas ${CLI_CASE_PACK_INPUT_CASE_VERSIONS.join(' or ')} as a reviewed, audience-specific Case-pack v2 with current schema ${CASE_SCHEMA_VERSION}.`,
1110
1130
  example: 'whoisleuth case-pack cases.json --audience trusted --reviewed --json',
1111
1131
  boundary: 'The command is an offline handoff from the browser Case workflow: it creates a new package, never creates or mutates a durable Case, never mutates the source archive, and requires an explicit review acknowledgement.',
1112
1132
  },
1113
- collection: { mode: 'offline', scope: `Reads one bounded Case-schema-${CASE_SCHEMA_VERSION} browser export and writes a separate audience-specific Case-pack v2.` },
1133
+ collection: { mode: 'offline', scope: `Reads one bounded Case export from schemas ${CLI_CASE_PACK_INPUT_CASE_VERSIONS.join(' or ')} and writes a separate audience-specific Case-pack v2.` },
1114
1134
  summary: 'Build a reviewed case package',
1115
1135
  options: ['--audience', '--reviewed', '--json', '--quiet', '--no-color'],
1116
1136
  positionals: OPTIONAL_FILE_POSITIONAL,
1117
1137
  constraints: Object.freeze([
1118
1138
  constraint({ kind: 'required', options: ['--audience', '--reviewed'] }),
1119
1139
  ]),
1120
- helpGroup: 'respond',
1121
1140
  handlerOwner: 'inline',
1122
1141
  networkEffect: 'offline',
1123
1142
  common: true,
@@ -1129,17 +1148,15 @@ const COMMAND_SEEDS = Object.freeze({
1129
1148
  }),
1130
1149
  "domain-control": commandSeed({
1131
1150
  reference: {
1132
- usage: 'whoisleuth domain-control [manifest-input.json|review-input.json] [--json] [--quiet] [--no-color]',
1133
1151
  description: 'Build an integrity-protected desired-state manifest or compare one with supplied observations.',
1134
1152
  example: 'whoisleuth domain-control domain-control-input.json --json',
1135
- boundary: 'The command is offline and changes no registrar, DNS, mail, or certificate configuration. Only complete supplied observations can produce drift.',
1153
+ boundary: 'The command is offline and changes no registrar, DNS, mail, or certificate configuration. Only complete, recent source observations can establish drift or expected absence.',
1136
1154
  },
1137
1155
  collection: { mode: 'offline', scope: 'Reads one bounded desired-state or review document and performs no collection or configuration change.' },
1138
1156
  summary: 'Build or review a domain control manifest',
1139
1157
  options: ['--json', '--quiet', '--no-color'],
1140
1158
  positionals: OPTIONAL_FILE_POSITIONAL,
1141
1159
  constraints: EMPTY_CONSTRAINTS,
1142
- helpGroup: 'assure',
1143
1160
  handlerOwner: 'inline',
1144
1161
  networkEffect: 'offline',
1145
1162
  common: false,
@@ -1151,7 +1168,6 @@ const COMMAND_SEEDS = Object.freeze({
1151
1168
  }),
1152
1169
  "monitor-once": commandSeed({
1153
1170
  reference: {
1154
- usage: 'whoisleuth monitor-once [manifest.json] [--previous <snapshot.json>] [--limit <1-20>] [--concurrency <1-3>] [--fail-on <policies>] [--json|--junit] [--quiet] [--no-color]',
1155
1171
  description: 'Collect one bounded owned-domain review and compare it with an optional prior checkpoint.',
1156
1172
  example: 'whoisleuth monitor-once manifest.json --previous previous.json --json --output next.json',
1157
1173
  boundary: 'This is an operator-scheduled one-shot collection, not a daemon. It caps targets and concurrency, retains normalised observations, and never changes domain configuration.',
@@ -1163,7 +1179,6 @@ const COMMAND_SEEDS = Object.freeze({
1163
1179
  constraints: Object.freeze([
1164
1180
  constraint({ kind: 'mutually_exclusive', options: ['--json', '--junit'] }),
1165
1181
  ]),
1166
- helpGroup: 'assure',
1167
1182
  handlerOwner: 'inline',
1168
1183
  networkEffect: 'always_network',
1169
1184
  common: false,
@@ -1175,7 +1190,6 @@ const COMMAND_SEEDS = Object.freeze({
1175
1190
  }),
1176
1191
  assurance: commandSeed({
1177
1192
  reference: {
1178
- usage: 'whoisleuth assurance [assurance-input.json] [--json] [--quiet] [--no-color]',
1179
1193
  description: 'Review a versioned domain change, recovery-dependency, or retirement plan.',
1180
1194
  example: 'whoisleuth assurance domain-assurance.json --json',
1181
1195
  boundary: 'The command is offline and treats every provider label, readiness state, and evidence reference as analyst-authored input. It changes no configuration.',
@@ -1185,7 +1199,6 @@ const COMMAND_SEEDS = Object.freeze({
1185
1199
  options: ['--json', '--quiet', '--no-color'],
1186
1200
  positionals: OPTIONAL_FILE_POSITIONAL,
1187
1201
  constraints: EMPTY_CONSTRAINTS,
1188
- helpGroup: 'assure',
1189
1202
  handlerOwner: 'inline',
1190
1203
  networkEffect: 'offline',
1191
1204
  common: false,
@@ -1197,7 +1210,6 @@ const COMMAND_SEEDS = Object.freeze({
1197
1210
  }),
1198
1211
  "change-packet": commandSeed({
1199
1212
  reference: {
1200
- usage: 'whoisleuth change-packet [change-packet-input.json] [--json] [--quiet] [--no-color]',
1201
1213
  description: 'Assemble pre-change, post-change, and planning evidence into one integrity-protected packet.',
1202
1214
  example: 'whoisleuth change-packet change-review.json --json',
1203
1215
  boundary: 'Assembly is offline. Readiness reflects only the supplied bounded evidence and does not authorise or perform a domain change.',
@@ -1207,7 +1219,6 @@ const COMMAND_SEEDS = Object.freeze({
1207
1219
  options: ['--json', '--quiet', '--no-color'],
1208
1220
  positionals: OPTIONAL_FILE_POSITIONAL,
1209
1221
  constraints: EMPTY_CONSTRAINTS,
1210
- helpGroup: 'respond',
1211
1222
  handlerOwner: 'inline',
1212
1223
  networkEffect: 'offline',
1213
1224
  common: false,
@@ -1219,19 +1230,17 @@ const COMMAND_SEEDS = Object.freeze({
1219
1230
  }),
1220
1231
  "sharing-review": commandSeed({
1221
1232
  reference: {
1222
- usage: 'whoisleuth sharing-review [artifact.json] --marking <level> --recipient-scope <scope> --purpose <text> [--human-reviewed] [--personal-data-reviewed] [--redactions-confirmed] [--json] [--quiet] [--no-color]',
1223
1233
  description: 'Lint one reviewed artefact against local integrity, marking, recipient, personal-data, and redaction controls.',
1224
1234
  example: 'whoisleuth sharing-review packet.json --marking amber --recipient-scope organization --purpose "Reviewed incident handoff" --human-reviewed --personal-data-reviewed --redactions-confirmed --json',
1225
1235
  boundary: 'The command is offline and emits only bounded schema/version metadata, no content values, and no raw evidence. Its result is a review aid, not legal advice or recipient authorisation.',
1226
1236
  },
1227
- collection: { mode: 'offline', scope: 'Reads one artefact capped at 15 MiB, emits only bounded schema/version metadata and no content values, and performs no transmission.' },
1237
+ collection: { mode: 'offline', scope: 'Reads one bounded artefact, emits only schema/version metadata and no content values, and performs no transmission.' },
1228
1238
  summary: 'Lint an artefact before deliberate sharing',
1229
1239
  options: ['--marking', '--recipient-scope', '--purpose', '--human-reviewed', '--personal-data-reviewed', '--redactions-confirmed', '--json', '--quiet', '--no-color'],
1230
1240
  positionals: OPTIONAL_FILE_POSITIONAL,
1231
1241
  constraints: Object.freeze([
1232
1242
  constraint({ kind: 'required', options: ['--marking', '--recipient-scope', '--purpose'] }),
1233
1243
  ]),
1234
- helpGroup: 'respond',
1235
1244
  handlerOwner: 'inline',
1236
1245
  networkEffect: 'offline',
1237
1246
  common: false,
@@ -1243,7 +1252,6 @@ const COMMAND_SEEDS = Object.freeze({
1243
1252
  }),
1244
1253
  "workflow-plan": commandSeed({
1245
1254
  reference: {
1246
- usage: 'whoisleuth workflow-plan <recipe> <domain|brand> | --list | --explain <recipe> [--json] [--quiet] [--no-color]',
1247
1255
  description: 'Build a fixed domain-investigation plan from existing bounded CLI commands.',
1248
1256
  example: 'whoisleuth workflow-plan domain-triage example.test --json',
1249
1257
  boundary: 'Planning is offline and plan-only. It does not execute commands, expand placeholders, read files, make requests, or submit evidence.',
@@ -1258,7 +1266,6 @@ const COMMAND_SEEDS = Object.freeze({
1258
1266
  constraints: Object.freeze([
1259
1267
  constraint({ kind: 'mutually_exclusive', options: ['--list', '--explain'] }),
1260
1268
  ]),
1261
- helpGroup: 'assure',
1262
1269
  handlerOwner: 'inline',
1263
1270
  networkEffect: 'offline',
1264
1271
  common: true,
@@ -1270,20 +1277,18 @@ const COMMAND_SEEDS = Object.freeze({
1270
1277
  }),
1271
1278
  "workflow-run": commandSeed({
1272
1279
  reference: {
1273
- usage: 'whoisleuth workflow-run <recipe> <domain|brand> [--select <step-id>=<path-or-value>]... [--approve-network] [--resume <state.json>] [--json] [--quiet] [--no-color]',
1274
1280
  description: 'Execute approved steps from a fixed investigation recipe and emit a resumable checkpoint.',
1275
- example: 'whoisleuth workflow-run domain-triage example.test --resume run.json --select export=saved-lookup.json --json --output run-next.json',
1276
- boundary: 'Only installed recipe commands can run. Network steps require explicit approval for each invocation. Repeat --select in placeholder order for one step; each bounded value replaces one exact placeholder and cannot start with a hyphen, become an option, or invoke a shell.',
1281
+ example: 'whoisleuth workflow-run domain-triage example.test --approve-network --json --output run.json',
1282
+ boundary: 'Only installed recipe commands can run. Network steps require explicit approval for each invocation. New runs connect compatible earlier outputs using the recipe defaults. Use --use-artifact <step-id>:<input-number>=<earlier-step-id> to override a connection; input numbers start at 1. Repeat --select for remaining placeholders in order, or supply every input for a step to replace its connections with files. Values stay literal and cannot start with a hyphen or invoke a shell. Optional --interactive prompts on terminal stderr for missing inputs; a blank answer pauses. It grants neither network approval nor human-review confirmation. A step declaring human review still requires --confirm-review <step-id> for that invocation. Checkpoints do not grant later approvals. Resumes preserve recorded connections. Content digests identify retained output, not authenticity or freshness. Partial collections pause for review and are not recollected on resume; failed validation or export steps remain retryable. Diagnostics go to stderr. File output holds exclusive adjacent locks and refuses concurrently changed state files.',
1277
1283
  },
1278
1284
  collection: { mode: 'network', scope: 'Runs only fixed-recipe steps; network collection requires --approve-network and unresolved analyst selections pause.' },
1279
1285
  summary: 'Execute approved fixed-recipe steps',
1280
- options: ['--select', '--approve-network', '--resume', '--json', '--quiet', '--no-color'],
1286
+ options: ['--select', '--use-artifact', '--confirm-review', '--approve-network', '--resume', '--interactive', '--json', '--quiet', '--no-color'],
1281
1287
  positionals: Object.freeze([
1282
1288
  positional('recipe', 'enum', 1, 1, RUNNABLE_INVESTIGATION_PLAN_RECIPES),
1283
1289
  positional('subject', 'text', 1, 1),
1284
1290
  ]),
1285
1291
  constraints: EMPTY_CONSTRAINTS,
1286
- helpGroup: 'assure',
1287
1292
  handlerOwner: 'inline',
1288
1293
  networkEffect: 'conditional_network',
1289
1294
  common: false,
@@ -1295,17 +1300,15 @@ const COMMAND_SEEDS = Object.freeze({
1295
1300
  }),
1296
1301
  diff: commandSeed({
1297
1302
  reference: {
1298
- usage: 'whoisleuth diff <left.json> <right.json> [--left-session <id> --right-session <id>] [--json] [--quiet] [--no-color]',
1299
1303
  description: 'Compare an earlier and later artefact from the same retained Lookup, Bulk-session, or domain-portfolio family.',
1300
1304
  example: 'whoisleuth diff earlier.json later.json --json',
1301
- boundary: 'Comparison is offline: the left input is earlier and the right input is later. Inputs must belong to the same supported family. For a multi-session Bulk export, --left-session selects a session from the left file and --right-session selects one from the right; missing, unavailable, equal, and different evidence remain separate states.',
1305
+ boundary: 'Comparison is offline: the left input is earlier and the right input is later. Inputs must belong to the same supported family. Saved Lookups can describe the same or different domains; same-domain comparisons preserve observation times and collection uncertainty. For a multi-session Bulk export, --left-session selects a session from the left file and --right-session selects one from the right; missing, unavailable, equal, and different evidence remain separate states.',
1302
1306
  },
1303
1307
  collection: { mode: 'offline', scope: 'Reads two compatible retained artefacts capped at 8 MiB each and retains no source paths.' },
1304
1308
  summary: 'Compare two compatible retained artefacts',
1305
1309
  options: ['--left-session', '--right-session', '--json', '--quiet', '--no-color'],
1306
1310
  positionals: Object.freeze([positional('sources', 'file', 2, 2)]),
1307
1311
  constraints: EMPTY_CONSTRAINTS,
1308
- helpGroup: 'assure',
1309
1312
  handlerOwner: 'inline',
1310
1313
  networkEffect: 'offline',
1311
1314
  common: true,
@@ -1317,7 +1320,6 @@ const COMMAND_SEEDS = Object.freeze({
1317
1320
  }),
1318
1321
  reconcile: commandSeed({
1319
1322
  reference: {
1320
- usage: 'whoisleuth reconcile <observation.json> <observation.json> [...] [--json] [--quiet] [--no-color]',
1321
1323
  description: 'Reconcile bounded values across independently labelled observations of one domain.',
1322
1324
  example: 'whoisleuth reconcile office.json mobile.json external.json --json',
1323
1325
  boundary: 'The command is offline, accepts 2 to 5 saved observations for one domain, and never treats labels as proof of network independence or majority agreement as truth.',
@@ -1327,7 +1329,6 @@ const COMMAND_SEEDS = Object.freeze({
1327
1329
  options: ['--json', '--quiet', '--no-color'],
1328
1330
  positionals: Object.freeze([positional('sources', 'file', 2, 5)]),
1329
1331
  constraints: EMPTY_CONSTRAINTS,
1330
- helpGroup: 'assure',
1331
1332
  handlerOwner: 'inline',
1332
1333
  networkEffect: 'offline',
1333
1334
  common: false,
@@ -1339,7 +1340,6 @@ const COMMAND_SEEDS = Object.freeze({
1339
1340
  }),
1340
1341
  timeline: commandSeed({
1341
1342
  reference: {
1342
- usage: 'whoisleuth timeline <observation.json> <observation.json> [...] [--json] [--quiet] [--no-color]',
1343
1343
  description: 'Build an ordered same-domain history from saved Lookup observations.',
1344
1344
  example: 'whoisleuth timeline first.json second.json latest.json --json',
1345
1345
  boundary: 'The command is offline, accepts 2 to 20 bounded inputs for one domain, retains no filenames or raw registry payloads, and does not treat changed collection conditions as a domain change.',
@@ -1349,7 +1349,6 @@ const COMMAND_SEEDS = Object.freeze({
1349
1349
  options: ['--json', '--quiet', '--no-color'],
1350
1350
  positionals: Object.freeze([positional('sources', 'file', 2, 20)]),
1351
1351
  constraints: EMPTY_CONSTRAINTS,
1352
- helpGroup: 'assure',
1353
1352
  handlerOwner: 'inline',
1354
1353
  networkEffect: 'offline',
1355
1354
  common: false,
@@ -1361,10 +1360,9 @@ const COMMAND_SEEDS = Object.freeze({
1361
1360
  }),
1362
1361
  export: commandSeed({
1363
1362
  reference: {
1364
- usage: 'whoisleuth export [lookup.json] [--markdown|--html|--compact] [--no-attribution]',
1365
1363
  description: 'Convert one saved lookup into a versioned evidence report.',
1366
1364
  example: 'whoisleuth export lookup.json --markdown',
1367
- boundary: `Saved Lookup versions 1 and 2 are capped at 8 MiB and scanned for duplicate keys, the prototype-sensitive __proto__ key, and bounded nesting, key, value, and per-container counts before parsing. Current schema-${LOOKUP_EVIDENCE_SCHEMA_VERSION} exports preserve evidence-source attribution and limitations; published v2 schema ${PUBLISHED_V2_LOOKUP_EVIDENCE_SCHEMA_VERSION} and exact v1 schema ${V1_PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION} remain readable, while other historical and unreleased shapes are unsupported. Markdown and HTML include a presentation-only generator footer unless --no-attribution is selected; JSON retains bounded generator provenance. Compact output intentionally omits raw registry payloads.`,
1365
+ boundary: `Saved Lookup versions 1 and 2 are capped at 8 MiB and scanned for duplicate keys, the prototype-sensitive __proto__ key, and bounded nesting, key, value, and per-container counts before parsing. Current schema-${LOOKUP_EVIDENCE_SCHEMA_VERSION} exports preserve evidence-source attribution and limitations; published v2 schemas ${PUBLISHED_V2_LOOKUP_EVIDENCE_VERSIONS} and exact v1 schema ${V1_PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION} remain readable, while other historical and unreleased shapes are unsupported. Markdown and HTML include a presentation-only generator footer unless --no-attribution is selected; JSON retains bounded generator provenance. Compact output intentionally omits raw registry payloads.`,
1368
1366
  },
1369
1367
  collection: { mode: 'offline', scope: 'Reads one saved Lookup and writes one bounded report.' },
1370
1368
  summary: 'Convert a lookup to an evidence report',
@@ -1375,7 +1373,6 @@ const COMMAND_SEEDS = Object.freeze({
1375
1373
  constraint({ kind: 'excludes_all', option: '--compact', excludedOptions: ['--markdown', '--html'] }),
1376
1374
  constraint({ kind: 'requires_any', option: '--no-attribution', requiredOptions: ['--markdown', '--html'] }),
1377
1375
  ]),
1378
- helpGroup: 'respond',
1379
1376
  handlerOwner: 'inline',
1380
1377
  networkEffect: 'offline',
1381
1378
  common: true,
@@ -1387,24 +1384,18 @@ const COMMAND_SEEDS = Object.freeze({
1387
1384
  }),
1388
1385
  });
1389
1386
  const COMMAND_ORDER = Object.freeze(Object.keys(COMMAND_SEEDS));
1390
- const HELP_GROUP_ORDER = Object.freeze(['investigate', 'respond', 'assure', 'utilities']);
1391
- const HELP_COMMANDS_BY_GROUP = Object.freeze(Object.fromEntries(HELP_GROUP_ORDER.map((group) => [
1387
+ const HELP_COMMANDS_BY_GROUP = Object.freeze(Object.fromEntries(CLI_HELP_GROUP_ORDER.map((group) => [
1392
1388
  group,
1393
- Object.freeze(COMMAND_ORDER.filter((command) => COMMAND_SEEDS[command].helpGroup === group)),
1389
+ Object.freeze(COMMAND_ORDER.filter((command) => CLI_COMMAND_SEMANTICS[command].group === group)),
1394
1390
  ])));
1395
1391
  function documentationMetadata(seed, commonOptions, commandOptions, positionals) {
1396
1392
  const explicitAuthorisationRequired = commandOptions.some((option) => (option === '--owned-or-authorized' || option === '--active-probe' || option === '--approve-network'));
1397
- const outputOptionFormats = [
1398
- ['--json', 'JSON'], ['--jsonl', 'JSON Lines'], ['--junit', 'JUnit XML'],
1399
- ['--csv', 'CSV'], ['--domains', 'domain list'], ['--queries', 'query list'],
1400
- ['--markdown', 'Markdown'], ['--html', 'HTML'], ['--sarif', 'SARIF'],
1401
- ['--summary-json', 'summary JSON'],
1402
- ];
1393
+ const presentationOptions = Object.freeze(PRESENTATION_OPTIONS
1394
+ .filter(([option]) => commandOptions.includes(option))
1395
+ .map(([option, format]) => Object.freeze({ option, format })));
1403
1396
  const outputFormats = new Set(['terminal']);
1404
- for (const [option, label] of outputOptionFormats) {
1405
- if (commandOptions.includes(option))
1406
- outputFormats.add(label);
1407
- }
1397
+ for (const { format } of presentationOptions)
1398
+ outputFormats.add(format);
1408
1399
  for (const format of seed.additionalOutputFormats)
1409
1400
  outputFormats.add(format);
1410
1401
  const positionalLimits = positionals.map((item) => (`${item.name}: ${item.minimum}-${item.maximum} ${item.valueKind} value${item.maximum === 1 ? '' : 's'}`));
@@ -1429,9 +1420,53 @@ function documentationMetadata(seed, commonOptions, commandOptions, positionals)
1429
1420
  : []),
1430
1421
  ]),
1431
1422
  outputFormats: Object.freeze([...outputFormats]),
1423
+ presentationOptions,
1424
+ fileOutput: commonOptions.includes('--output'),
1432
1425
  primaryEvidenceArtefacts: seed.primaryArtefacts,
1433
1426
  });
1434
1427
  }
1428
+ function expandedOptionValues(specification) {
1429
+ return specification.valueKind === 'enum' && specification.values.join('|').length > 60;
1430
+ }
1431
+ function optionUsage(specification) {
1432
+ if (specification.arity === 0)
1433
+ return specification.option;
1434
+ if (specification.valueKind === 'enum') {
1435
+ return `${specification.option} <${expandedOptionValues(specification) ? specification.option.slice(2) : specification.values.join('|')}>`;
1436
+ }
1437
+ if (specification.valueKind === 'policy_list')
1438
+ return `${specification.option} <policy[,policy...]>`;
1439
+ if (specification.valueKind === 'integer')
1440
+ return `${specification.option} <integer>`;
1441
+ if (specification.valueKind === 'file')
1442
+ return `${specification.option} <file>`;
1443
+ return `${specification.option} <value>`;
1444
+ }
1445
+ function positionalUsage(specification) {
1446
+ const value = specification.valueKind === 'enum'
1447
+ ? `<${specification.values.join('|')}>`
1448
+ : `<${specification.name}${specification.maximum > 1 ? '...' : ''}>`;
1449
+ return specification.minimum === 0 ? `[${value}]` : value;
1450
+ }
1451
+ function generatedCommandUsage(command, options, positionals, constraints) {
1452
+ const commandOptions = options.filter((option) => option.scope === 'command');
1453
+ const required = new Set(constraints
1454
+ .filter((item) => item.kind === 'required')
1455
+ .flatMap((item) => item.options));
1456
+ const mutuallyExclusive = constraints
1457
+ .filter((item) => item.kind === 'mutually_exclusive')
1458
+ .map((item) => item.options.filter((option) => commandOptions.some((candidate) => candidate.option === option)))
1459
+ .filter((group) => group.length > 1);
1460
+ const grouped = new Set(mutuallyExclusive.flat());
1461
+ const renderedGroups = mutuallyExclusive.map((group) => `[${group.map((option) => (optionUsage(commandOptions.find((candidate) => candidate.option === option)))).join('|')}]`);
1462
+ const renderedOptions = commandOptions
1463
+ .filter((option) => !grouped.has(option.option))
1464
+ .map((option) => {
1465
+ const rendered = optionUsage(option);
1466
+ return required.has(option.option) ? rendered : `[${rendered}]`;
1467
+ });
1468
+ return ['whoisleuth', command, ...positionals.map(positionalUsage), ...renderedGroups, ...renderedOptions].join(' ');
1469
+ }
1435
1470
  const CLI_COMMAND_REGISTRY = Object.freeze(COMMAND_ORDER.map((command, order) => {
1436
1471
  const seed = COMMAND_SEEDS[command];
1437
1472
  const commonOptions = Object.freeze([...commonOptionsSeedForCommand(command)]);
@@ -1440,10 +1475,14 @@ const CLI_COMMAND_REGISTRY = Object.freeze(COMMAND_ORDER.map((command, order) =>
1440
1475
  ...commonOptions.map((option) => optionSpec(command, option, 'common')),
1441
1476
  ...commandOptions.map((option) => optionSpec(command, option, 'command')),
1442
1477
  ]);
1478
+ const constraints = grammarConstraints(commandOptions, seed.constraints);
1443
1479
  return Object.freeze({
1444
1480
  command,
1445
1481
  order,
1446
- reference: seed.reference,
1482
+ reference: Object.freeze({
1483
+ ...seed.reference,
1484
+ usage: generatedCommandUsage(command, grammarOptions, seed.positionals, constraints),
1485
+ }),
1447
1486
  collection: seed.collection,
1448
1487
  completion: Object.freeze({
1449
1488
  description: seed.summary,
@@ -1455,7 +1494,7 @@ const CLI_COMMAND_REGISTRY = Object.freeze(COMMAND_ORDER.map((command, order) =>
1455
1494
  bootstrapProfile: seed.bootstrapProfile,
1456
1495
  options: grammarOptions,
1457
1496
  positionals: seed.positionals,
1458
- constraints: grammarConstraints(commandOptions, seed.constraints),
1497
+ constraints,
1459
1498
  metaActions: Object.freeze(['help']),
1460
1499
  }),
1461
1500
  execution: Object.freeze({
@@ -1463,7 +1502,7 @@ const CLI_COMMAND_REGISTRY = Object.freeze(COMMAND_ORDER.map((command, order) =>
1463
1502
  networkEffect: seed.networkEffect,
1464
1503
  }),
1465
1504
  help: Object.freeze({
1466
- group: seed.helpGroup,
1505
+ group: CLI_COMMAND_SEMANTICS[command].group,
1467
1506
  summary: seed.summary,
1468
1507
  }),
1469
1508
  documentation: documentationMetadata(seed, commonOptions, commandOptions, seed.positionals),
@@ -1483,11 +1522,34 @@ function commandDefinition(command) {
1483
1522
  function metaActionDefinition(id) {
1484
1523
  return CLI_META_ACTION_BY_ID[id];
1485
1524
  }
1525
+ function cliInvocationOptionIndices(argv) {
1526
+ const namedCommand = isCliCommand(argv[0]) ? argv[0] : null;
1527
+ const options = new Map(commandDefinition(namedCommand ?? 'lookup').grammar.options
1528
+ .map((option) => [option.option, option]));
1529
+ const indices = new Set();
1530
+ for (let index = namedCommand ? 1 : 0; index < argv.length; index += 1) {
1531
+ const argument = argv[index];
1532
+ if (argument === '--')
1533
+ break;
1534
+ if (!argument.startsWith('-'))
1535
+ continue;
1536
+ indices.add(index);
1537
+ const option = options.get(argument);
1538
+ const value = argv[index + 1];
1539
+ if (option?.arity === 1 && value !== undefined) {
1540
+ if (value === '--' && !option.acceptsOptionLikeValue)
1541
+ break;
1542
+ index += 1;
1543
+ }
1544
+ }
1545
+ return indices;
1546
+ }
1486
1547
  function cliMetaActionForInvocation(argv) {
1548
+ const optionIndices = cliInvocationOptionIndices(argv);
1487
1549
  for (const action of CLI_META_ACTIONS) {
1488
1550
  const matches = action.scope === 'root_only'
1489
1551
  ? action.aliases.includes(argv[0] ?? '')
1490
- : argv.some((argument) => action.aliases.includes(argument));
1552
+ : argv.some((argument, index) => optionIndices.has(index) && action.aliases.includes(argument));
1491
1553
  if (matches)
1492
1554
  return action;
1493
1555
  }
@@ -1535,15 +1597,8 @@ function commandOwnsOption(command, option) {
1535
1597
  return commandDefinition(command).grammar.options.some((candidate) => candidate.option === option);
1536
1598
  }
1537
1599
  function invocationHasFlag(command, args, flag) {
1538
- for (let index = 0; index < args.length; index += 1) {
1539
- const argument = args[index];
1540
- if (argument === flag)
1541
- return true;
1542
- const specification = argument ? commandOptionSpec(command, argument) : null;
1543
- if (specification?.arity === 1)
1544
- index += 1;
1545
- }
1546
- return false;
1600
+ const argv = [command, ...args];
1601
+ return [...cliInvocationOptionIndices(argv)].some((index) => argv[index] === flag);
1547
1602
  }
1548
1603
  function cliInvocationNetworkEffect(command, args) {
1549
1604
  const effect = commandDefinition(command).execution.networkEffect;
@@ -1564,6 +1619,8 @@ function cliInvocationNetworkEffect(command, args) {
1564
1619
  function commandHelp(command) {
1565
1620
  const detail = COMMAND_DETAILS[command];
1566
1621
  const collection = COMMAND_COLLECTION[command];
1567
- return `WHOISleuth ${command}\n${detail.description}\n\nUsage:\n ${COMMAND_USAGE[command]}\n\nExample:\n ${detail.example}\n\nCollection:\n ${collection.mode === 'offline' ? 'Offline' : 'Network'}: ${collection.scope}\n\nBoundary:\n ${detail.boundary}\n\nRun "whoisleuth --help" to see the grouped command list.\n`;
1622
+ const values = commandDefinition(command).grammar.options.filter(expandedOptionValues)
1623
+ .map(option => `\n${option.option} values:\n${option.values.map(value => ` ${value}`).join('\n')}\n`).join('');
1624
+ return `WHOISleuth ${command}\n${detail.description}\n\nUsage:\n ${COMMAND_USAGE[command]}\n\nExample:\n ${detail.example}\n\nCollection:\n ${collection.mode === 'offline' ? 'Offline' : 'Network'}: ${collection.scope}\n\nBoundary:\n ${detail.boundary}\n${values}\nRun "whoisleuth --help" to see the grouped command list.\n`;
1568
1625
  }
1569
- export { CLI_COMMAND_REGISTRY, CLI_COMMANDS, CLI_META_ACTIONS, COMMAND_COLLECTION, COMMAND_DESCRIPTIONS, COMMAND_DETAILS, COMMAND_USAGE, COMMON_OPTIONS, FILE_OPTIONS, FILE_OPTIONS_BY_COMMAND, FILE_POSITIONAL_COMMANDS, HELP, HELP_COMMANDS_BY_GROUP, INVESTIGATION_PLAN_RECIPES, RUNNABLE_INVESTIGATION_PLAN_RECIPES, LIMITED_CONCURRENCY_VALUES, OPTIONS_BY_COMMAND, STANDARD_CONCURRENCY_VALUES, TEXT_OPTIONS, VALUE_OPTIONS, cliMetaActionForInvocation, cliInvocationNetworkEffect, commandOptionSpec, commandOwnsOption, commandPositionalSpecs, commonOptionsForCommand, commandDefinition, commandHelp, isCliCommand, metaActionDefinition, };
1626
+ export { CLI_COMMAND_REGISTRY, CLI_CASE_OPERATIONS, CLI_COMMANDS, CLI_META_ACTIONS, COMMAND_COLLECTION, COMMAND_DESCRIPTIONS, COMMAND_DETAILS, COMMAND_USAGE, FILE_POSITIONAL_COMMANDS, HELP, HELP_COMMANDS_BY_GROUP, INVESTIGATION_PLAN_RECIPES, RUNNABLE_INVESTIGATION_PLAN_RECIPES, OPTIONS_BY_COMMAND, cliMetaActionForInvocation, cliInvocationOptionIndices, cliInvocationNetworkEffect, commandOptionSpec, commandOwnsOption, commandPositionalSpecs, commonOptionsForCommand, commandDefinition, commandHelp, isCliCommand, metaActionDefinition, };