@slicedearth/whoisleuth-cli 1.32.0 → 1.34.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.
package/DISCLOSURE ADDED
@@ -0,0 +1,18 @@
1
+ WHOISleuth is a defensive domain investigation and brand-protection tool. Its
2
+ command-line package can collect bounded WHOIS, RDAP, DNS, HTTP, TLS,
3
+ certificate-transparency, website, and related infrastructure evidence. It can
4
+ also generate and review lookalike-domain candidates, compare saved evidence,
5
+ and prepare analyst-controlled reports. These security-relevant capabilities
6
+ can have both legitimate and potentially harmful uses.
7
+
8
+ WHOISleuth is intended for authorised defensive investigation, abuse response,
9
+ threat research, domain acquisition due diligence, asset review, and education.
10
+ Network collection is initiated explicitly by the operator, uses bounded
11
+ requests, and keeps source provenance and incomplete results visible. The tool
12
+ does not exploit targets, bypass access controls, submit credentials, execute
13
+ remote page scripts, or automate enforcement actions.
14
+
15
+ Operators are responsible for using WHOISleuth only on systems and data they
16
+ are authorised to examine and in accordance with applicable laws, provider
17
+ terms, and organisational policies. Observations and heuristic signals are
18
+ investigative leads, not proof of ownership, intent, safety, or maliciousness.
package/README.md CHANGED
@@ -11,19 +11,35 @@ WHOISleuth requires Node.js 24 or later. Run the reviewed public package without
11
11
  a global installation:
12
12
 
13
13
  ```bash
14
- npm exec --yes --package=@slicedearth/whoisleuth-cli -- whoisleuth --help
14
+ npm exec --yes --ignore-scripts --package=@slicedearth/whoisleuth-cli -- whoisleuth --help
15
15
  ```
16
16
 
17
17
  Or install the `whoisleuth` command globally:
18
18
 
19
19
  ```bash
20
- npm install --global @slicedearth/whoisleuth-cli
20
+ npm install --global --ignore-scripts @slicedearth/whoisleuth-cli
21
21
  whoisleuth doctor
22
22
  ```
23
23
 
24
+ Update an existing global installation and confirm the installed release with:
25
+
26
+ ```bash
27
+ npm install --global --ignore-scripts @slicedearth/whoisleuth-cli@latest
28
+ whoisleuth --version
29
+ ```
30
+
24
31
  The package version follows the application version. Each release is assembled
25
32
  from the executable's bounded dependency closure and retains the project
26
33
  licence, notices, trademark terms, source location, and provenance metadata.
34
+ The generated package manifest pins each direct runtime dependency to the exact
35
+ version exercised by the reviewed repository lockfile.
36
+
37
+ The package has no install, postinstall, or other lifecycle scripts. Network
38
+ access begins only when an analyst runs a networked command. Offline commands
39
+ may read deliberately selected local evidence or configuration files, but do
40
+ not upload them or invoke a hosted WHOISleuth deployment. The installation
41
+ examples disable lifecycle scripts for the package and its dependency tree;
42
+ WHOISleuth does not require them.
27
43
 
28
44
  ## Repository checkout
29
45
 
@@ -33,17 +49,22 @@ Contributors can run the same source command without installing the package:
33
49
  node bin/whoisleuth.mts --help
34
50
  node bin/whoisleuth.mts lookup example.test
35
51
  node bin/whoisleuth.mts lookup example.test --deep --summary
52
+ node bin/whoisleuth.mts lookup example.test --deep --plan --json
36
53
  node bin/whoisleuth.mts lookup example.test --deep --markdown --output lookup.md
37
54
  node bin/whoisleuth.mts bulk domains.txt --deep --checkpoint bulk-checkpoint.json
38
55
  node bin/whoisleuth.mts bulk domains.txt --csv --registered-only
56
+ node bin/whoisleuth.mts bulk domains.txt --queries --errors-only
39
57
  node bin/whoisleuth.mts discover example.test --dictionary private-terms.txt --snapshot discovery-state.json --json
40
58
  node bin/whoisleuth.mts discover-scan example.test --scan-limit 50 --checkpoint candidate-scan.json --json
41
59
  node bin/whoisleuth.mts page-compare official.json candidate.json --json
42
60
  node bin/whoisleuth.mts mail-review bulk.json --json
43
61
  node bin/whoisleuth.mts diff first-lookup.json second-lookup.json --json
62
+ node bin/whoisleuth.mts timeline first-observation.json second-observation.json latest-observation.json --json
44
63
  node bin/whoisleuth.mts registry-support example.test --json
45
64
  node bin/whoisleuth.mts doctor
65
+ node bin/whoisleuth.mts commands --json
46
66
  node bin/whoisleuth.mts completion zsh
67
+ node bin/whoisleuth.mts completion powershell
47
68
  node bin/whoisleuth.mts manual | man -l -
48
69
  ```
49
70
 
@@ -51,6 +72,13 @@ Fast lookup is the default. Deep collection must be requested explicitly and
51
72
  can disclose the target to additional authoritative or first-party network
52
73
  sources. Missing, partial, rate-limited, and unsupported sources remain
53
74
  explicit and are never interpreted as evidence of safety or absence.
75
+ `lookup --plan` classifies a target and explains the selected source families
76
+ and disclosures without making a network request. `commands --json` exposes the
77
+ installed command contracts for local wrappers without running collection.
78
+ Focused command help and the generated manual label every operation as offline
79
+ or networked and state its target, input, and concurrency boundaries. The
80
+ packaged [dual-use disclosure](DISCLOSURE) defines the supported defensive use
81
+ of those capabilities.
54
82
 
55
83
  Interactive output uses restrained semantic colour, width-aware wrapping, and
56
84
  stderr-only progress for slower collection. Redirected and machine-readable
@@ -65,8 +93,15 @@ the partial-result exit code. Ctrl-C suppresses partial final output and
65
93
  returns exit code 130; a second interrupt performs best-effort temporary-file
66
94
  cleanup before exiting immediately.
67
95
 
68
- Bulk also supports fixed-column CSV, domain-only output, registered and
69
- inconclusive output filters, and bounded A, AAAA, NS, and MX summaries. Discover
96
+ The offline `timeline` command orders 2 to 20 saved Lookup documents for one
97
+ domain and compares each adjacent observation. It retains observation times and
98
+ normalized field states but no input filenames or raw registry payloads.
99
+ Observed differences remain separate from missing, unavailable, and changed
100
+ collection conditions.
101
+
102
+ Bulk also supports fixed-column CSV, domain-only or exact-query output,
103
+ registered, inconclusive, and hard-failure output filters, and bounded A, AAAA,
104
+ NS, and MX summaries. Discover
70
105
  supports local custom dictionaries and private snapshot comparison suitable
71
106
  for an operator-managed scheduler. The separate networked `discover-scan`
72
107
  command can collect a deterministic bounded subset in chunks, retain mutation
@@ -79,6 +114,7 @@ favicon, technology, and TLS evidence, while saved Bulk output can be reviewed
79
114
  for passive MX, null MX, SPF, DMARC, and shared-provider context.
80
115
 
81
116
  See `docs/cli.md` in the package for the complete command, privacy, output, and
82
- evidence-contract reference.
117
+ evidence-contract reference. Report suspected vulnerabilities through the
118
+ private channel described in `SECURITY.md`.
83
119
 
84
120
  Copyright 2026 slicedearth. Licensed under AGPL-3.0-only.
package/SECURITY.md ADDED
@@ -0,0 +1,35 @@
1
+ # Security policy
2
+
3
+ ## Supported versions
4
+
5
+ Security fixes are applied to the latest released WHOISleuth application and
6
+ CLI version. Older releases are not maintained as separate security branches.
7
+ Upgrade to the latest release before reporting a problem that may already have
8
+ been corrected.
9
+
10
+ ## Report a vulnerability privately
11
+
12
+ Use [GitHub private vulnerability reporting](https://github.com/slicedearth/whoisleuth/security/advisories/new)
13
+ for suspected vulnerabilities in the application, CLI, release process, or
14
+ deployment defaults. Do not open a public issue for an undisclosed security
15
+ problem.
16
+
17
+ Include the affected version, the smallest reproducible example, expected and
18
+ observed behaviour, and the likely impact. Use reserved or synthetic domains
19
+ and remove credentials, session material, personal information, private case
20
+ data, and raw registration records before submitting evidence.
21
+
22
+ Ordinary defects, feature requests, and questions that do not expose a security
23
+ weakness can use the public issue tracker.
24
+
25
+ ## Scope and response
26
+
27
+ Reports are assessed against WHOISleuth's documented trust boundaries,
28
+ including its local-first workspace, bounded network collection, shared
29
+ deployment password, and optional hosted monitoring. A source outage, partial
30
+ registry response, or heuristic disagreement is not by itself a security
31
+ vulnerability, but unsafe handling of those states may be.
32
+
33
+ Confirmed reports will be handled through a private advisory until a fix and
34
+ coordinated disclosure are ready. No bounty or guaranteed response time is
35
+ offered.
package/cli/arguments.mjs CHANGED
@@ -5,6 +5,7 @@ const MAX_CLI_ARGUMENT_LENGTH = 1024;
5
5
  const CLI_COMMANDS = [
6
6
  'completion',
7
7
  'doctor',
8
+ 'commands',
8
9
  'manual',
9
10
  'lookup',
10
11
  'bulk',
@@ -25,6 +26,7 @@ const CLI_COMMANDS = [
25
26
  'page-compare',
26
27
  'mail-review',
27
28
  'diff',
29
+ 'timeline',
28
30
  'export',
29
31
  ];
30
32
  function boundedArgument(value) {
@@ -108,6 +110,8 @@ function parseCliArgumentsCore(argv) {
108
110
  return parseCompletionArguments(argv.slice(1));
109
111
  if (command === 'doctor')
110
112
  return parseDoctorArguments(argv.slice(1));
113
+ if (command === 'commands')
114
+ return parseCommandsArguments(argv.slice(1));
111
115
  if (command === 'manual')
112
116
  return parseManualArguments(argv.slice(1));
113
117
  if (command === 'ct-search')
@@ -144,6 +148,8 @@ function parseCliArgumentsCore(argv) {
144
148
  return parseMailReviewArguments(argv.slice(1));
145
149
  if (command === 'diff')
146
150
  return parseDiffArguments(argv.slice(1));
151
+ if (command === 'timeline')
152
+ return parseTimelineArguments(argv.slice(1));
147
153
  if (command === 'export')
148
154
  return parseExportArguments(argv.slice(1));
149
155
  let query = null;
@@ -156,6 +162,7 @@ function parseCliArgumentsCore(argv) {
156
162
  let detailSet = false;
157
163
  let strictExit = false;
158
164
  let events = false;
165
+ let plan = false;
159
166
  for (const argument of argv.slice(1)) {
160
167
  if (argument === '--json') {
161
168
  if (output !== 'terminal')
@@ -195,6 +202,11 @@ function parseCliArgumentsCore(argv) {
195
202
  throw new CliUsageError('--events may be supplied only once.');
196
203
  events = true;
197
204
  }
205
+ else if (argument === '--plan') {
206
+ if (plan)
207
+ throw new CliUsageError('--plan may be supplied only once.');
208
+ plan = true;
209
+ }
198
210
  else if (argument === '--quiet')
199
211
  quiet = true;
200
212
  else if (argument === '--no-color')
@@ -210,11 +222,16 @@ function parseCliArgumentsCore(argv) {
210
222
  throw new CliUsageError('--quiet cannot be combined with machine-readable output.');
211
223
  if (detailSet && output !== 'terminal')
212
224
  throw new CliUsageError('--summary and --verbose apply only to terminal output.');
213
- return { action: 'lookup', query, output, deep, detail, strictExit, events, quiet, color };
225
+ if (plan && (output === 'markdown' || output === 'html'))
226
+ throw new CliUsageError('--plan supports terminal or JSON output only.');
227
+ if (plan && (detailSet || strictExit || events || quiet)) {
228
+ throw new CliUsageError('--plan cannot be combined with detail, strict-exit, event, or quiet options.');
229
+ }
230
+ return { action: 'lookup', query, output, deep, detail, strictExit, events, plan, quiet, color };
214
231
  }
215
232
  function parseCompletionArguments(argv) {
216
- if (argv.length !== 1 || !['bash', 'zsh', 'fish'].includes(argv[0] || '')) {
217
- throw new CliUsageError('completion requires exactly one shell: bash, zsh, or fish.');
233
+ if (argv.length !== 1 || !['bash', 'zsh', 'fish', 'powershell'].includes(argv[0] || '')) {
234
+ throw new CliUsageError('completion requires exactly one shell: bash, zsh, fish, or powershell.');
218
235
  }
219
236
  return { action: 'completion', shell: argv[0] };
220
237
  }
@@ -223,6 +240,27 @@ function parseManualArguments(argv) {
223
240
  throw new CliUsageError('manual does not accept command arguments.');
224
241
  return { action: 'manual' };
225
242
  }
243
+ function parseCommandsArguments(argv) {
244
+ let output = 'terminal';
245
+ let quiet = false;
246
+ let color = true;
247
+ for (const argument of argv) {
248
+ if (argument === '--json') {
249
+ if (output !== 'terminal')
250
+ throw new CliUsageError('--json may be supplied only once.');
251
+ output = 'json';
252
+ }
253
+ else if (argument === '--quiet')
254
+ quiet = true;
255
+ else if (argument === '--no-color')
256
+ color = false;
257
+ else
258
+ throw new CliUsageError(`Unknown option "${argument}".`);
259
+ }
260
+ if (quiet && output !== 'terminal')
261
+ throw new CliUsageError('--quiet cannot be combined with machine-readable output.');
262
+ return { action: 'commands', output, quiet, color };
263
+ }
226
264
  function parseDoctorArguments(argv) {
227
265
  let network = false;
228
266
  let output = 'terminal';
@@ -266,10 +304,18 @@ function parseBulkArguments(argv) {
266
304
  const argument = argv[index];
267
305
  if (argument === undefined)
268
306
  break;
269
- if (argument === '--json' || argument === '--jsonl' || argument === '--csv' || argument === '--domains') {
307
+ if (argument === '--json' || argument === '--jsonl' || argument === '--csv' || argument === '--domains' || argument === '--queries') {
270
308
  if (output !== 'terminal')
271
309
  throw new CliUsageError('Choose only one output format.');
272
- output = argument === '--json' ? 'json' : argument === '--jsonl' ? 'jsonl' : argument === '--csv' ? 'csv' : 'domains';
310
+ output = argument === '--json'
311
+ ? 'json'
312
+ : argument === '--jsonl'
313
+ ? 'jsonl'
314
+ : argument === '--csv'
315
+ ? 'csv'
316
+ : argument === '--domains'
317
+ ? 'domains'
318
+ : 'queries';
273
319
  }
274
320
  else if (argument === '--deep' || argument === '--fast') {
275
321
  if (scanMode)
@@ -305,10 +351,14 @@ function parseBulkArguments(argv) {
305
351
  throw new CliUsageError('--events may be supplied only once.');
306
352
  events = true;
307
353
  }
308
- else if (argument === '--registered-only' || argument === '--inconclusive-only') {
354
+ else if (argument === '--registered-only' || argument === '--inconclusive-only' || argument === '--errors-only') {
309
355
  if (filter !== 'all')
310
- throw new CliUsageError('--registered-only and --inconclusive-only are mutually exclusive and may be supplied only once.');
311
- filter = argument === '--registered-only' ? 'registered' : 'inconclusive';
356
+ throw new CliUsageError('Bulk output filters are mutually exclusive and may be supplied only once.');
357
+ filter = argument === '--registered-only'
358
+ ? 'registered'
359
+ : argument === '--inconclusive-only'
360
+ ? 'inconclusive'
361
+ : 'errors';
312
362
  }
313
363
  else if (argument === '--quiet')
314
364
  quiet = true;
@@ -876,6 +926,36 @@ function parseDiffArguments(argv) {
876
926
  throw new CliUsageError('--quiet cannot be combined with machine-readable output.');
877
927
  return { action: 'diff', leftSource, rightSource, output, quiet, color };
878
928
  }
929
+ function parseTimelineArguments(argv) {
930
+ const sources = [];
931
+ let output = 'terminal';
932
+ let quiet = false;
933
+ let color = true;
934
+ for (const argument of argv) {
935
+ if (argument === '--json') {
936
+ if (output !== 'terminal')
937
+ throw new CliUsageError('--json may be supplied only once.');
938
+ output = 'json';
939
+ }
940
+ else if (argument === '--quiet')
941
+ quiet = true;
942
+ else if (argument === '--no-color')
943
+ color = false;
944
+ else if (argument.startsWith('-'))
945
+ throw new CliUsageError(`Unknown option "${argument}".`);
946
+ else
947
+ sources.push(argument);
948
+ }
949
+ if (sources.length < 2 || sources.length > 20) {
950
+ throw new CliUsageError('timeline requires from 2 to 20 saved lookup JSON files.');
951
+ }
952
+ if (new Set(sources).size !== sources.length) {
953
+ throw new CliUsageError('timeline input files must be different.');
954
+ }
955
+ if (quiet && output !== 'terminal')
956
+ throw new CliUsageError('--quiet cannot be combined with machine-readable output.');
957
+ return { action: 'timeline', sources, output, quiet, color };
958
+ }
879
959
  function parseRegistrySupportArguments(argv) {
880
960
  let target = null;
881
961
  let output = 'terminal';
@@ -7,6 +7,7 @@ import { BULK_REVIEW_MANIFEST_SCHEMA, BULK_REVIEW_MANIFEST_VERSION, } from "../f
7
7
  import { decryptWorkspaceArchive, inspectEncryptedWorkspaceArchive, isEncryptedWorkspaceArchive, } from "../frontend/src/lib/analysis/workspace-archive-crypto.js";
8
8
  import { WORKSPACE_ARCHIVE_SCHEMA, readWorkspaceArchive, } from "../frontend/src/lib/analysis/workspace-archive.js";
9
9
  import { sha256ArtifactDigest } from "../frontend/src/lib/analysis/artifact-integrity.js";
10
+ import { SAVED_LOOKUP_SCHEMA, SAVED_LOOKUP_SCHEMA_VERSION, parseSavedLookupDocument, } from "./saved-lookup.mjs";
10
11
  export const OFFLINE_ARTIFACT_VERIFICATION_SCHEMA = 'whoisleuth.offline-artifact-verification';
11
12
  export const OFFLINE_ARTIFACT_VERIFICATION_VERSION = 1;
12
13
  export const MAX_OFFLINE_ARTIFACT_BYTES = 15 * 1024 * 1024;
@@ -186,6 +187,33 @@ export async function verifyOfflineArtifact(raw, options = {}) {
186
187
  ]),
187
188
  });
188
189
  }
190
+ if (schema === SAVED_LOOKUP_SCHEMA) {
191
+ if (version !== SAVED_LOOKUP_SCHEMA_VERSION) {
192
+ throw new TypeError('This saved Lookup document version is not supported.');
193
+ }
194
+ const document = parseSavedLookupDocument(raw, { label: 'Saved Lookup artifact' });
195
+ return Object.freeze({
196
+ schema: OFFLINE_ARTIFACT_VERIFICATION_SCHEMA,
197
+ version: OFFLINE_ARTIFACT_VERIFICATION_VERSION,
198
+ artifact: Object.freeze({ kind: 'saved_lookup', schema, version }),
199
+ state: 'structure_valid',
200
+ valid: true,
201
+ checks: Object.freeze({
202
+ structure: 'verified',
203
+ contentIntegrity: 'not_checked',
204
+ authenticatedEncryption: 'not_applicable',
205
+ }),
206
+ summary: Object.freeze({
207
+ inputBytes: inputBytes(raw),
208
+ sectionCount: null,
209
+ recordCount: null,
210
+ ciphertextBytes: null,
211
+ }),
212
+ limitations: Object.freeze([
213
+ `The saved ${document.mode} Lookup matches its versioned structural contract, but this document format has no embedded checksum or signature. Structural validity does not prove that its evidence is accurate, current, or unchanged since collection.`,
214
+ ]),
215
+ });
216
+ }
189
217
  return verifySignedArtifact(raw, value, schema, version);
190
218
  }
191
219
  export function formatOfflineArtifactVerification(report) {
@@ -3,7 +3,7 @@ import { classifyQuery } from "../lib/classify.mjs";
3
3
  import { runUnifiedLookup } from "../lib/lookup.mjs";
4
4
  import { createBulkCheckpointWriter } from "./bulk-checkpoint.mjs";
5
5
  import { MAX_BULK_INPUT_BYTES, parseBulkQueries, readTextStreamBounded, runBulkLookups, } from "./bulk.mjs";
6
- import { formatBulkCsv, formatBulkDomainList, selectBulkItems } from "./bulk-output.mjs";
6
+ import { formatBulkCsv, formatBulkDomainList, formatBulkQueryList, selectBulkItems } from "./bulk-output.mjs";
7
7
  import { boundedCliErrorMessage, CliUsageError } from "./errors.mjs";
8
8
  import EXIT_CODES from "./exit-codes.mjs";
9
9
  import { buildCliBulkDocument, formatJsonDocument, formatJsonLines } from "./formatters/json.mjs";
@@ -91,6 +91,8 @@ async function runBulkCommand(args, dependencies, context) {
91
91
  context.writeStdout(formatBulkCsv(selectedItems));
92
92
  else if (args.output === 'domains')
93
93
  context.writeStdout(formatBulkDomainList(selectedItems));
94
+ else if (args.output === 'queries')
95
+ context.writeStdout(formatBulkQueryList(selectedItems));
94
96
  else
95
97
  context.writeStdout(context.terminal(formatTerminalBulk(selectedItems, metadata), args.color));
96
98
  }
@@ -18,6 +18,8 @@ function selectBulkItems(items, filter) {
18
18
  if (filter === 'all')
19
19
  return [...items];
20
20
  return items.filter((item) => {
21
+ if (filter === 'errors')
22
+ return !item.ok;
21
23
  if (!item.ok)
22
24
  return filter === 'inconclusive';
23
25
  const state = availabilityState(item);
@@ -109,4 +111,7 @@ function formatBulkDomainList(items) {
109
111
  : []);
110
112
  return values.length ? `${[...new Set(values)].join('\n')}\n` : '';
111
113
  }
112
- export { REGISTERED_STATES, availabilityState, bulkDnsSummary, formatBulkCsv, formatBulkDomainList, selectBulkItems, };
114
+ function formatBulkQueryList(items) {
115
+ return items.length ? `${items.map((item) => item.query).join('\n')}\n` : '';
116
+ }
117
+ export { REGISTERED_STATES, availabilityState, bulkDnsSummary, formatBulkCsv, formatBulkDomainList, formatBulkQueryList, selectBulkItems, };
@@ -0,0 +1,33 @@
1
+ export const CLI_COMMAND_CATALOGUE_SCHEMA = 'whoisleuth.cli.command-catalogue';
2
+ export const CLI_COMMAND_CATALOGUE_VERSION = 1;
3
+ function buildCliCommandCatalogue(options) {
4
+ return Object.freeze({
5
+ schema: CLI_COMMAND_CATALOGUE_SCHEMA,
6
+ version: CLI_COMMAND_CATALOGUE_VERSION,
7
+ packageVersion: options.packageVersion,
8
+ commands: Object.freeze(options.commands.map((command) => {
9
+ const detail = options.details[command];
10
+ return Object.freeze({
11
+ command,
12
+ description: detail.description,
13
+ usage: options.usage[command],
14
+ example: detail.example,
15
+ collection: options.collections[command],
16
+ boundary: detail.boundary,
17
+ });
18
+ })),
19
+ });
20
+ }
21
+ function formatCliCommandCatalogue(catalogue) {
22
+ const lines = [
23
+ `WHOISleuth CLI ${catalogue.packageVersion}`,
24
+ 'Command catalogue',
25
+ '',
26
+ ];
27
+ for (const entry of catalogue.commands) {
28
+ lines.push(`${entry.command} [${entry.collection.mode}]`, ` ${entry.description}`, ` ${entry.usage}`);
29
+ }
30
+ lines.push('', 'Run "whoisleuth <command> --help" for examples and collection boundaries.');
31
+ return `${lines.join('\n')}\n`;
32
+ }
33
+ export { buildCliCommandCatalogue, formatCliCommandCatalogue };
@@ -1,8 +1,8 @@
1
1
  import { CLI_COMMANDS } from "./arguments.mjs";
2
2
  const COMMON_OPTIONS = Object.freeze(['--help', '--output', '--force']);
3
3
  const OPTIONS_BY_COMMAND = Object.freeze({
4
- lookup: ['--json', '--markdown', '--html', '--fast', '--deep', '--summary', '--verbose', '--strict-exit', '--events', '--quiet', '--no-color'],
5
- bulk: ['--json', '--jsonl', '--csv', '--domains', '--registered-only', '--inconclusive-only', '--fast', '--deep', '--concurrency', '--checkpoint', '--resume', '--events', '--quiet', '--no-color'],
4
+ lookup: ['--json', '--markdown', '--html', '--fast', '--deep', '--plan', '--summary', '--verbose', '--strict-exit', '--events', '--quiet', '--no-color'],
5
+ bulk: ['--json', '--jsonl', '--csv', '--domains', '--queries', '--registered-only', '--inconclusive-only', '--errors-only', '--fast', '--deep', '--concurrency', '--checkpoint', '--resume', '--events', '--quiet', '--no-color'],
6
6
  'ct-search': ['--json', '--quiet', '--no-color'],
7
7
  discover: ['--tlds', '--preset', '--families', '--keyboard', '--dictionary', '--snapshot', '--json', '--jsonl', '--domains', '--quiet', '--no-color'],
8
8
  'discover-scan': ['--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', '--json', '--jsonl', '--csv', '--domains', '--quiet', '--no-color'],
@@ -20,8 +20,10 @@ const OPTIONS_BY_COMMAND = Object.freeze({
20
20
  'page-compare': ['--json', '--quiet', '--no-color'],
21
21
  'mail-review': ['--json', '--quiet', '--no-color'],
22
22
  diff: ['--json', '--quiet', '--no-color'],
23
+ timeline: ['--json', '--quiet', '--no-color'],
23
24
  export: ['--markdown', '--html', '--compact'],
24
25
  completion: [],
26
+ commands: ['--json', '--quiet', '--no-color'],
25
27
  doctor: ['--network', '--json', '--quiet', '--no-color'],
26
28
  manual: [],
27
29
  });
@@ -36,7 +38,7 @@ const COMMAND_DESCRIPTIONS = Object.freeze({
36
38
  tls: 'Inspect one TLS connection',
37
39
  'registry-support': 'Explain local registry coverage',
38
40
  'risk-calibrate': 'Replay reviewed Risk labels offline',
39
- 'verify-artifact': 'Validate an evidence artifact offline',
41
+ 'verify-artifact': 'Validate saved evidence offline',
40
42
  'inspect-archive': 'Inspect an archive locally',
41
43
  'sign-artifact': 'Sign a reviewed artifact locally',
42
44
  'verify-signature': 'Verify a signed evidence package',
@@ -45,8 +47,10 @@ const COMMAND_DESCRIPTIONS = Object.freeze({
45
47
  'page-compare': 'Compare saved static page evidence',
46
48
  'mail-review': 'Review saved passive mail evidence',
47
49
  diff: 'Compare two saved domain lookups',
50
+ timeline: 'Build same-domain history from saved lookups',
48
51
  export: 'Convert a lookup to an evidence report',
49
52
  completion: 'Print shell completion',
53
+ commands: 'List installed command contracts',
50
54
  doctor: 'Check the local CLI runtime',
51
55
  manual: 'Print the generated manual page',
52
56
  });
@@ -92,7 +96,7 @@ _whoisleuth_completion() {
92
96
  return
93
97
  fi
94
98
  if [[ "\${command}" == "completion" && \${COMP_CWORD} -eq 2 ]]; then
95
- COMPREPLY=( $(compgen -W "bash zsh fish" -- "\${current}") )
99
+ COMPREPLY=( $(compgen -W "bash zsh fish powershell" -- "\${current}") )
96
100
  return
97
101
  fi
98
102
  case "\${previous}" in
@@ -125,7 +129,7 @@ _whoisleuth() {
125
129
  command="\${words[2]}"
126
130
  previous="\${words[CURRENT-1]}"
127
131
  if [[ "\${command}" == "completion" && CURRENT -eq 3 ]]; then
128
- compadd -- bash zsh fish
132
+ compadd -- bash zsh fish powershell
129
133
  return
130
134
  fi
131
135
  case "\${previous}" in
@@ -160,16 +164,56 @@ complete -c whoisleuth -f
160
164
  complete -c whoisleuth -n '__fish_use_subcommand' -l help
161
165
  complete -c whoisleuth -n '__fish_use_subcommand' -l version
162
166
  ${commandLines.join('\n')}
163
- complete -c whoisleuth -n '__fish_seen_subcommand_from completion' -a 'bash zsh fish'
167
+ complete -c whoisleuth -n '__fish_seen_subcommand_from completion' -a 'bash zsh fish powershell'
164
168
  ${optionLines.join('\n')}
165
169
  ${valueLines.join('\n')}
166
170
  `;
167
171
  }
172
+ function powershellCompletion() {
173
+ const commandOptions = Object.fromEntries(CLI_COMMANDS.map((command) => [
174
+ command,
175
+ [...COMMON_OPTIONS, ...(OPTIONS_BY_COMMAND[command] || [])],
176
+ ]));
177
+ return `# WHOISleuth PowerShell completion
178
+ Register-ArgumentCompleter -Native -CommandName whoisleuth -ScriptBlock {
179
+ param($wordToComplete, $commandAst, $cursorPosition)
180
+ $commands = @(${CLI_COMMANDS.map((command) => `'${command}'`).join(', ')})
181
+ $options = @{
182
+ ${Object.entries(commandOptions).map(([command, options]) => ` '${command}' = @(${options.map((option) => `'${option}'`).join(', ')})`).join('\n')}
183
+ }
184
+ $values = @{
185
+ ${Object.entries(VALUE_OPTIONS).map(([option, values]) => ` '${option}' = @(${values.map((value) => `'${value}'`).join(', ')})`).join('\n')}
186
+ 'completion' = @('bash', 'zsh', 'fish', 'powershell')
187
+ }
188
+ $elements = @($commandAst.CommandElements | ForEach-Object { $_.Extent.Text })
189
+ $command = if ($elements.Count -gt 1) { $elements[1] } else { '' }
190
+ $previous = if ($elements.Count -gt 1) { $elements[$elements.Count - 1] } else { '' }
191
+ $candidates = if ($elements.Count -le 2) {
192
+ @($commands) + @('--help', '--version')
193
+ } elseif ($command -eq 'completion' -and $elements.Count -le 3) {
194
+ $values['completion']
195
+ } elseif ($values.ContainsKey($previous)) {
196
+ $values[$previous]
197
+ } elseif ($options.ContainsKey($command)) {
198
+ $options[$command]
199
+ } else {
200
+ @('--help')
201
+ }
202
+ foreach ($candidate in $candidates) {
203
+ if ($candidate.StartsWith($wordToComplete, [System.StringComparison]::OrdinalIgnoreCase)) {
204
+ [System.Management.Automation.CompletionResult]::new($candidate, $candidate, 'ParameterValue', $candidate)
205
+ }
206
+ }
207
+ }
208
+ `;
209
+ }
168
210
  function buildShellCompletion(shell) {
169
211
  if (shell === 'bash')
170
212
  return bashCompletion();
171
213
  if (shell === 'zsh')
172
214
  return zshCompletion();
173
- return fishCompletion();
215
+ if (shell === 'fish')
216
+ return fishCompletion();
217
+ return powershellCompletion();
174
218
  }
175
219
  export { buildShellCompletion, };