sourcelock 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hc_source/cli.py ADDED
@@ -0,0 +1,959 @@
1
+ """``hc-source`` command line.
2
+
3
+ Exit codes are part of the contract:
4
+
5
+ * ``doctor``: 0 in sync with the lockfile, 1 drift or schema change,
6
+ 2 unreachable source, adapter load failure, or an unpinned canary.
7
+ * ``call``: 0 on success, 1 on a usage or validation error, 2 when the upstream
8
+ source could not be read. 1 and 2 are kept apart deliberately -- a caller has
9
+ to be able to tell "your input was wrong" from "CMS is down", and retrying is
10
+ the right response to only one of them.
11
+ * every other command: 0 on success, 1 on a usage, I/O, or validation error.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from pathlib import Path
18
+ from typing import Any, Optional
19
+
20
+ import typer
21
+ from pydantic import ValidationError
22
+ from rich.console import Console
23
+ from rich.table import Table
24
+
25
+ from . import __version__, cache
26
+ from .adapters import AdapterError, discover, discovery_errors, find_tool, iter_tools
27
+ from .doctor import EXIT_DRIFT, EXIT_OK, EXIT_UNREACHABLE, run_doctor
28
+ from .guard import (
29
+ ParamsRejected,
30
+ PHIRejected,
31
+ assert_public_params,
32
+ safe_label,
33
+ safe_tool_name,
34
+ safe_validation_message,
35
+ )
36
+ from .http import SourceUnreachable
37
+ from .lockfile import (
38
+ DEFAULT_LOCK_FILENAME,
39
+ build_lockfile,
40
+ default_lock_path,
41
+ load_lockfile,
42
+ save_lockfile,
43
+ )
44
+ from .schemas import CanarySeverity, CanaryStatus, Lockfile, Receipt
45
+
46
+ app = typer.Typer(
47
+ name="hc-source",
48
+ help="Source assurance for public healthcare data. Receipts on every answer.",
49
+ no_args_is_help=True,
50
+ add_completion=False,
51
+ )
52
+ lock_app = typer.Typer(help="Manage source-lock.json.", no_args_is_help=True)
53
+ app.add_typer(lock_app, name="lock")
54
+
55
+ # Additive registration: the manifest commands live in cli_manifest.py.
56
+ from .cli_manifest import manifest_app # noqa: E402
57
+
58
+ app.add_typer(manifest_app, name="manifest")
59
+
60
+ console = Console(stderr=False)
61
+ err = Console(stderr=True)
62
+
63
+ _STATUS_STYLE = {
64
+ CanaryStatus.OK: "green",
65
+ CanaryStatus.DRIFT: "yellow",
66
+ CanaryStatus.SCHEMA_CHANGED: "yellow",
67
+ CanaryStatus.UNREACHABLE: "red",
68
+ CanaryStatus.UNPINNED: "yellow",
69
+ CanaryStatus.STALE: "yellow",
70
+ CanaryStatus.ERROR: "red",
71
+ }
72
+
73
+
74
+ def _version_callback(value: bool) -> None:
75
+ if value:
76
+ typer.echo(f"hc-source {__version__}")
77
+ raise typer.Exit(EXIT_OK)
78
+
79
+
80
+ @app.callback()
81
+ def main_callback(
82
+ version: bool = typer.Option(
83
+ False, "--version", callback=_version_callback, is_eager=True, help="Show version and exit."
84
+ ),
85
+ ) -> None:
86
+ """Source assurance for public healthcare data."""
87
+
88
+
89
+ def _fail(message: str, code: int = 1) -> None:
90
+ typer.echo(message)
91
+ raise typer.Exit(code)
92
+
93
+
94
+ def _os_error_reason(exc: OSError) -> str:
95
+ """A short human reason for an OSError, with no traceback and no payload."""
96
+ return (exc.strerror or type(exc).__name__).lower()
97
+
98
+
99
+ def _load_lock_or_none(path: Path) -> tuple[Any, str | None]:
100
+ try:
101
+ return load_lockfile(path), None
102
+ except FileNotFoundError as exc:
103
+ return None, str(exc)
104
+ except OSError as exc:
105
+ # Unreadable (permissions, a directory, a broken symlink). Not "no
106
+ # lockfile": pretending nothing is pinned would report a green run that
107
+ # verified nothing, the same failure `_scoped_discovery` refuses.
108
+ _fail(f"cannot read {path}: {_os_error_reason(exc)}")
109
+ raise AssertionError # pragma: no cover - _fail always exits
110
+ except ValidationError as exc:
111
+ _fail(f"source-lock.json at {path} is not valid: {exc.error_count()} problem(s). "
112
+ "Regenerate it with `hc-source lock init`.")
113
+ raise AssertionError # pragma: no cover - _fail always exits
114
+
115
+
116
+ def _scoped_discovery(source: list[str]):
117
+ """Discover adapters, optionally scoped to specific source ids.
118
+
119
+ Returns ``(adapters, load_errors)``. An unknown ``--source`` is a usage
120
+ error: silently checking nothing would report a green run that verified
121
+ nothing. Load errors are matched to a scope by module basename, since a
122
+ module that failed to load has no source_id to ask.
123
+ """
124
+ found = discover()
125
+ adapters, load_errors = found.adapters, found.errors
126
+ if not source:
127
+ return adapters, load_errors
128
+
129
+ wanted = set(source)
130
+ known = {a.source_id for a in adapters} | {
131
+ e.module.rsplit(".", 1)[-1] for e in load_errors
132
+ }
133
+ unknown = sorted(wanted - known)
134
+ if unknown:
135
+ available = ", ".join(sorted(a.source_id for a in adapters)) or "none"
136
+ _fail(
137
+ f"unknown source(s): {', '.join(unknown)}. Discovered sources: {available}."
138
+ )
139
+ return (
140
+ [a for a in adapters if a.source_id in wanted],
141
+ [e for e in load_errors if e.module.rsplit(".", 1)[-1] in wanted],
142
+ )
143
+
144
+
145
+ # ---------------------------------------------------------------------------
146
+ # doctor
147
+ # ---------------------------------------------------------------------------
148
+
149
+
150
+ @app.command()
151
+ def doctor(
152
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
153
+ lock: Optional[Path] = typer.Option(
154
+ None, "--lock", help="Path to source-lock.json (default: ./source-lock.json)."
155
+ ),
156
+ source: list[str] = typer.Option(
157
+ [],
158
+ "--source",
159
+ "-s",
160
+ help="Check only these source ids (repeatable). Default: every discovered source.",
161
+ ),
162
+ ) -> None:
163
+ """Run every adapter's canaries and compare them to the lockfile."""
164
+ lock_path = lock or default_lock_path()
165
+ lockfile, missing = _load_lock_or_none(lock_path)
166
+ adapters, load_errors = _scoped_discovery(source)
167
+ report = run_doctor(lockfile, adapters, load_errors=load_errors)
168
+
169
+ if json_output:
170
+ payload = report.to_dict()
171
+ payload["lock_path"] = str(lock_path)
172
+ if missing:
173
+ payload["lockfile_error"] = missing
174
+ typer.echo(json.dumps(payload, indent=2))
175
+ raise typer.Exit(report.exit_code)
176
+
177
+ if missing:
178
+ typer.echo(f"! {missing}")
179
+
180
+ for error in report.load_errors:
181
+ typer.echo(f"! adapter failed to load -- {error}")
182
+
183
+ # canary and status must always be readable; observed/expected absorb the
184
+ # truncation when the terminal is narrow (all-no_wrap columns make rich
185
+ # collapse the narrowest column to zero width instead). Full values are
186
+ # always available via --json.
187
+ table = Table(title=f"hc-source doctor ({lock_path.name})", title_justify="left")
188
+ table.add_column("canary", no_wrap=True)
189
+ table.add_column("status", no_wrap=True)
190
+ # Which source actually answered. An `ok` from a mirror while the authority
191
+ # is dark is a different claim from an `ok` from the authority (finding H2).
192
+ table.add_column("via", overflow="ellipsis", max_width=22)
193
+ table.add_column("observed", overflow="ellipsis", max_width=38)
194
+ table.add_column("expected", overflow="ellipsis", max_width=38)
195
+
196
+ for result in report.results:
197
+ advisory = result.severity is CanarySeverity.ADVISORY
198
+ # An advisory finding is still a finding: it keeps its status word and
199
+ # is marked as not counting, rather than being quietly recoloured green.
200
+ status_text = result.status.value + (" (advisory)" if advisory else "")
201
+ style = "dim" if advisory and result.status is not CanaryStatus.OK else (
202
+ _STATUS_STYLE[result.status]
203
+ )
204
+ table.add_row(
205
+ result.canary_id,
206
+ f"[{style}]{status_text}[/]",
207
+ f"[yellow]{result.fallback_name or 'mirror'}[/]" if result.fallback_used
208
+ else "authority",
209
+ result.observed or "-",
210
+ result.expected or "-",
211
+ )
212
+ console.print(table)
213
+
214
+ summary = report.summary
215
+ extras = []
216
+ if summary["fallback"]:
217
+ extras.append(f"{summary['fallback']} answered by a mirror")
218
+ if summary["advisory"]:
219
+ extras.append(f"{summary['advisory']} advisory, not counted")
220
+ if summary["retried"]:
221
+ extras.append(f"{summary['retried']} needed a retry")
222
+ typer.echo(
223
+ ", ".join(f"{summary[s.value]} {s.value}" for s in CanaryStatus)
224
+ + (f" ({'; '.join(extras)})" if extras else "")
225
+ )
226
+
227
+ # Printed unwrapped: remediation text is meant to be copied and run.
228
+ for result in report.results:
229
+ if result.status is not CanaryStatus.OK:
230
+ mark = (
231
+ " advisory -- reported, not counted toward the exit code"
232
+ if result.severity is CanarySeverity.ADVISORY
233
+ else ""
234
+ )
235
+ typer.echo(
236
+ f"\n{result.canary_id} [{result.status.value}{mark}]: {result.remediation}"
237
+ )
238
+ for warning in result.warnings:
239
+ typer.echo(f" note {result.canary_id}: {warning}")
240
+
241
+ raise typer.Exit(report.exit_code)
242
+
243
+
244
+ # ---------------------------------------------------------------------------
245
+ # lock
246
+ # ---------------------------------------------------------------------------
247
+
248
+
249
+ @lock_app.command("init")
250
+ def lock_init(
251
+ lock: Optional[Path] = typer.Option(
252
+ None, "--lock", help="Where to write the lockfile (default: ./source-lock.json)."
253
+ ),
254
+ source: list[str] = typer.Option(
255
+ [],
256
+ "--source",
257
+ "-s",
258
+ help=(
259
+ "Pin only these source ids (repeatable); other sources' existing pins are "
260
+ "kept untouched. Default: every discovered source."
261
+ ),
262
+ ),
263
+ allow_partial: bool = typer.Option(
264
+ False,
265
+ "--allow-partial",
266
+ help=(
267
+ "Write the lockfile even though some canaries could not be observed. "
268
+ "Previous pins for those canaries are kept; canaries that have never "
269
+ "been pinned stay unpinned, and doctor will report them as such."
270
+ ),
271
+ ),
272
+ ) -> None:
273
+ """Observe every source now and pin what it says to source-lock.json.
274
+
275
+ Refuses to write unless every selected canary was observed. A `lock init`
276
+ that runs offline used to pin what it could reach and drop the rest, which
277
+ quietly deleted exactly the pins that detect drift. Pass `--allow-partial`
278
+ to accept a partial run; previous pins are preserved either way.
279
+ """
280
+ lock_path = lock or default_lock_path()
281
+ adapters, load_errors = _scoped_discovery(source)
282
+
283
+ for error in load_errors:
284
+ typer.echo(f"! adapter failed to load -- {error}")
285
+
286
+ existing, _ = _load_lock_or_none(lock_path)
287
+ build = build_lockfile(adapters, previous=existing)
288
+ lockfile = build.lockfile
289
+
290
+ if existing is not None:
291
+ # Never drop a source this run did not actually re-observe. A source
292
+ # whose adapter module failed to LOAD has no canaries to run, so it is
293
+ # absent from `build.lockfile` -- and dropping it here is how a scoped
294
+ # `lock init --source leie` with a broken leie.py wiped leie's pins.
295
+ rebuilt = set(lockfile.sources)
296
+ merged = {
297
+ sid: entry for sid, entry in existing.sources.items() if sid not in rebuilt
298
+ }
299
+ merged.update(lockfile.sources)
300
+ merged = dict(sorted(merged.items()))
301
+ generated_at = (
302
+ existing.generated_at if merged == existing.sources else lockfile.generated_at
303
+ )
304
+ lockfile = Lockfile(generated_at=generated_at, sources=merged)
305
+
306
+ blocked = bool(build.failures or load_errors) and not allow_partial
307
+ if blocked:
308
+ for failure in build.failures:
309
+ typer.echo(f"! {failure}")
310
+ typer.echo(
311
+ f"refusing to write {lock_path}: "
312
+ + _incomplete_summary(build.failures, load_errors)
313
+ + ". Nothing was changed -- the existing pins are intact. Fix the source (or "
314
+ "the adapter) and run again, or accept a partial pin with "
315
+ "`hc-source lock init --allow-partial`."
316
+ )
317
+ raise typer.Exit(EXIT_UNREACHABLE)
318
+
319
+ try:
320
+ save_lockfile(lockfile, lock_path)
321
+ except OSError as exc:
322
+ # Read-only checkout, a directory in the way, a full disk. All of these
323
+ # used to surface as a raw PermissionError traceback.
324
+ _fail(f"cannot write {lock_path}: {_os_error_reason(exc)}")
325
+ raise AssertionError # pragma: no cover
326
+
327
+ pinned = sum(len(e.expected_canaries) for e in lockfile.sources.values())
328
+ typer.echo(
329
+ f"wrote {lock_path} -- {len(lockfile.sources)} source(s), {pinned} canary expectation(s)"
330
+ )
331
+
332
+ if build.failures or load_errors:
333
+ typer.echo(
334
+ f"! PARTIAL PIN: {_incomplete_summary(build.failures, load_errors)}. "
335
+ "This lockfile is not a complete observation of upstream."
336
+ )
337
+ for failure in build.failures:
338
+ typer.echo(f"! {failure}")
339
+ if build.preserved:
340
+ typer.echo(
341
+ f"! {len(build.preserved)} previous pin(s) were kept unchanged and "
342
+ "describe an EARLIER observation, not this run."
343
+ )
344
+ if build.unpinned:
345
+ typer.echo(
346
+ f"! {len(build.unpinned)} canary/canaries remain unpinned; "
347
+ "`hc-source doctor` will report them as unpinned (exit 3)."
348
+ )
349
+
350
+ raise typer.Exit(
351
+ EXIT_UNREACHABLE if (build.failures or load_errors) else EXIT_OK
352
+ )
353
+
354
+
355
+ def _incomplete_summary(failures, load_errors) -> str:
356
+ parts = []
357
+ if failures:
358
+ parts.append(f"{len(failures)} canary/canaries could not be observed")
359
+ if load_errors:
360
+ parts.append(f"{len(load_errors)} adapter(s) failed to load")
361
+ return " and ".join(parts)
362
+
363
+
364
+ # ---------------------------------------------------------------------------
365
+ # tools
366
+ # ---------------------------------------------------------------------------
367
+
368
+
369
+ @app.command()
370
+ def tools(
371
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
372
+ ) -> None:
373
+ """List every tool from every discovered adapter.
374
+
375
+ Exits 2 when any adapter module failed to load. An adapter that is broken
376
+ looks exactly like an adapter that is absent from a list of what works, and
377
+ a source someone is paying for must not disappear quietly.
378
+ """
379
+ found = discover()
380
+ specs = iter_tools(found.adapters)
381
+ load_errors = found.errors
382
+
383
+ if json_output:
384
+ typer.echo(
385
+ json.dumps(
386
+ {
387
+ "load_errors": [e.as_dict() for e in load_errors],
388
+ # Where each source came from: built in, an installed
389
+ # distribution, or a local file. A proprietary adapter is
390
+ # not a second-class citizen, but it is a DIFFERENT claim,
391
+ # and the person reading a receipt gets to see which.
392
+ "adapters": [
393
+ {"source_id": a.source_id, "origin": found.origin(a.source_id)}
394
+ for a in found.adapters
395
+ ],
396
+ "tools": [
397
+ {
398
+ "name": s.name,
399
+ "source_id": s.source_id,
400
+ "description": s.description,
401
+ "tags": list(s.tags),
402
+ "params_schema": s.params_schema(),
403
+ }
404
+ for s in specs
405
+ ]
406
+ },
407
+ indent=2,
408
+ )
409
+ )
410
+ raise typer.Exit(EXIT_UNREACHABLE if load_errors else EXIT_OK)
411
+
412
+ # tool names stay whole; parameters wrap; description gets the room
413
+ # (a no_wrap parameters column can starve description to nothing).
414
+ table = Table(title="hc-source tools", title_justify="left")
415
+ table.add_column("tool", no_wrap=True)
416
+ table.add_column("parameters", max_width=24)
417
+ table.add_column("description", ratio=1)
418
+ for spec in specs:
419
+ params = ", ".join(spec.params_model.model_fields) or "-"
420
+ table.add_row(spec.name, params, spec.description)
421
+ console.print(table)
422
+
423
+ third_party = found.third_party()
424
+ if third_party:
425
+ typer.echo(
426
+ f"{len(third_party)} source(s) came from outside this package: "
427
+ + ", ".join(f"{a.source_id} ({found.origin(a.source_id)})" for a in third_party)
428
+ )
429
+
430
+ for error in load_errors:
431
+ typer.echo(f"! adapter failed to load -- {error}")
432
+ if load_errors:
433
+ typer.echo(
434
+ f"! {len(load_errors)} adapter(s) are missing from this list because they "
435
+ "failed to import, not because they do not exist. Every route they provide "
436
+ "is unavailable."
437
+ )
438
+ raise typer.Exit(EXIT_UNREACHABLE)
439
+
440
+
441
+ # ---------------------------------------------------------------------------
442
+ # call
443
+ # ---------------------------------------------------------------------------
444
+
445
+
446
+ def _parse_params(pairs: list[str], tool: str) -> dict[str, str]:
447
+ """Turn ``k=v`` strings into a mapping, guarding the RAW text first.
448
+
449
+ Three round-1 holes lived in the four lines this replaced:
450
+
451
+ * a syntax error printed the offending pair verbatim, so ``--param
452
+ 123-45-6789`` was refused by echoing it;
453
+ * ``params[key] = value`` was last-wins, so ``-p code=<something dirty>
454
+ -p code=E11.9`` dropped the first value BEFORE the guard ever saw it --
455
+ a clean second value hid a dirty first;
456
+ * the key itself was never scanned as text.
457
+
458
+ So the raw pairs are scanned before anything is parsed, duplicates are a
459
+ refusal rather than an overwrite, and nothing caller-supplied is echoed
460
+ unless it is identifier-shaped.
461
+ """
462
+ # Scan the raw text of every occurrence. The keys here are ours, so the
463
+ # guard's own paths stay readable; the values are the whole `k=v` strings.
464
+ try:
465
+ assert_public_params(
466
+ {f"param_{i + 1}": pair for i, pair in enumerate(pairs)}, tool=tool
467
+ )
468
+ except PHIRejected as exc:
469
+ # Carries detector codes and positions only, never the text.
470
+ _fail(str(exc))
471
+ raise AssertionError # pragma: no cover
472
+
473
+ params: dict[str, str] = {}
474
+ for i, pair in enumerate(pairs, start=1):
475
+ key, sep, value = pair.partition("=")
476
+ if not sep or not key:
477
+ _fail(
478
+ f"bad --param #{i}: expected k=v. The pair is not repeated here on "
479
+ "purpose -- SourceLock does not echo parameter text back at you."
480
+ )
481
+ if key in params:
482
+ _fail(
483
+ f"--param {safe_label(key)} was given more than once. SourceLock refuses "
484
+ "rather than taking the last one: a repeated key means one of the values "
485
+ "was going to be discarded, and discarding a value before it is inspected "
486
+ "is how a clean second value hides a dirty first."
487
+ )
488
+ params[key] = value
489
+ return params
490
+
491
+
492
+ @app.command()
493
+ def call(
494
+ tool: str = typer.Argument(..., help="Tool name, e.g. demo.lookup_code."),
495
+ param: list[str] = typer.Option(
496
+ [], "--param", "-p", help="Typed public parameter as k=v. Repeatable."
497
+ ),
498
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
499
+ ) -> None:
500
+ """Call one tool and print its result with the evidence receipt."""
501
+ # Resolved before the parameters are parsed so that a misspelled tool name
502
+ # is reported as a misspelled tool name, not as a complaint about the flags
503
+ # that were going to be passed to it.
504
+ spec = _resolve_tool(tool)
505
+ _run_tool(tool, _parse_params(param, tool), json_output, spec=spec)
506
+
507
+
508
+ def _resolve_tool(tool: str) -> Any:
509
+ """Find one tool, or exit with the right code and a fix.
510
+
511
+ ``call`` had this and the shortcuts did not, so a broken adapter answered
512
+ ``valid-on``, ``lookup-npi``, ``check-npi`` and ``hcc score`` with a Python
513
+ traceback and exit 1. A traceback is not remediation, and exit 1 says "your
514
+ command was wrong" about something that was not.
515
+ """
516
+ try:
517
+ return find_tool(tool)
518
+ except AdapterError as exc:
519
+ # The route exists and is broken. Exit 2, not 1: nothing about the
520
+ # caller's command was wrong.
521
+ _fail(str(exc), EXIT_UNREACHABLE)
522
+ raise AssertionError # pragma: no cover
523
+ except KeyError:
524
+ # safe_tool_name, not `tool!r`: the name is caller-supplied text, and
525
+ # "no tool named X" reflecting X back is a free write into anyone's log.
526
+ _fail(
527
+ f"no tool named {safe_tool_name(tool)}. Run `hc-source tools` to see what "
528
+ "is available."
529
+ )
530
+ raise AssertionError # pragma: no cover
531
+
532
+
533
+ def _run_tool(
534
+ tool: str, params: dict[str, Any], json_output: bool, *, spec: Any = None
535
+ ) -> None:
536
+ """Resolve, invoke and print one tool call. The single error-mapping path.
537
+
538
+ Every command that reaches an adapter goes through here -- ``call`` and all
539
+ four shortcuts -- so the exit codes, the remediation text and the
540
+ no-echo rules are the same wherever you arrive from. Two copies of this
541
+ drifted once already: the shortcuts' copy was missing the adapter-load
542
+ cases entirely.
543
+ """
544
+ spec = spec if spec is not None else _resolve_tool(tool)
545
+ try:
546
+ result = spec.invoke(params)
547
+ except (PHIRejected, ParamsRejected) as exc:
548
+ # Both carry field names and rule text only, never a parameter value.
549
+ _fail(str(exc))
550
+ raise AssertionError # pragma: no cover
551
+ except ValidationError as exc:
552
+ # Defence in depth: invoke() converts these, so reaching here means an
553
+ # adapter validated something itself. Never print the raw exception.
554
+ _fail(f"{tool}: invalid parameters -- {safe_validation_message(exc)}")
555
+ raise AssertionError # pragma: no cover
556
+ except SourceUnreachable as exc:
557
+ # exc carries a URL and a transport reason, never a response body and
558
+ # never a parameter value. Exit 2, not 1: this is not the caller's fault.
559
+ _fail(
560
+ f"{tool}: source unreachable -- {exc.url} ({exc.reason}"
561
+ + (f", HTTP {exc.status}" if exc.status else "")
562
+ + ").\n"
563
+ "The answer would have had no evidence behind it, so no answer was given. "
564
+ "Check connectivity and any proxy settings, then run `hc-source doctor "
565
+ f"--source {tool.split('.', 1)[0]}` to see whether the source is reachable "
566
+ "at all.",
567
+ EXIT_UNREACHABLE,
568
+ )
569
+ raise AssertionError # pragma: no cover
570
+ except AdapterError as exc:
571
+ # An adapter that returned something invalid -- a receipt naming another
572
+ # source, a handler returning the wrong type. Our bug, not the caller's.
573
+ _fail(str(exc), EXIT_UNREACHABLE)
574
+ raise AssertionError # pragma: no cover
575
+
576
+ if json_output:
577
+ typer.echo(
578
+ json.dumps(
579
+ {"data": result.data, "receipt": result.receipt.model_dump(mode="json")},
580
+ indent=2,
581
+ )
582
+ )
583
+ raise typer.Exit(EXIT_OK)
584
+
585
+ typer.echo(json.dumps(result.data, indent=2))
586
+ _print_receipt(result.receipt)
587
+
588
+
589
+ def _print_receipt(receipt: Receipt) -> None:
590
+ # The field names must stay whole and the values must stay VISIBLE. When
591
+ # every column is no_wrap, rich collapses the narrowest one to zero width
592
+ # instead of eliding it -- which rendered every receipt value, including the
593
+ # fallback flag, as an empty box at narrow widths (finding H5; the same bug
594
+ # commit be56f3d fixed for the doctor table). Giving the field column a
595
+ # ceiling and letting the value column fold keeps the evidence on screen.
596
+ table = Table(title="receipt", title_justify="left", show_header=False)
597
+ table.add_column("field", overflow="ellipsis", max_width=17)
598
+ table.add_column("value", overflow="fold", ratio=1)
599
+ table.add_row("source", receipt.source_id)
600
+ table.add_row("route", receipt.route)
601
+ table.add_row("source_version", receipt.source_version)
602
+ table.add_row("effective", f"{receipt.effective_from or '-'} .. {receipt.effective_to or 'open'}")
603
+ table.add_row("retrieved_at", receipt.retrieved_at.isoformat())
604
+ table.add_row("upstream_status", str(receipt.upstream_status))
605
+ table.add_row("raw_sha256", receipt.raw_sha256)
606
+ table.add_row("transform_version", receipt.transform_version)
607
+ table.add_row(
608
+ "fallback", receipt.fallback_name if receipt.fallback_used else "no (authority used)"
609
+ )
610
+ console.print(table)
611
+
612
+ for warning in receipt.warnings:
613
+ typer.echo(f"warning: {warning}")
614
+ typer.echo("does not prove:")
615
+ for claim in receipt.non_claims:
616
+ typer.echo(f" - {claim}")
617
+
618
+
619
+ # ---------------------------------------------------------------------------
620
+ # shortcuts
621
+ # ---------------------------------------------------------------------------
622
+ #
623
+ # `call` is the honest general form: any tool, any parameter, one syntax that
624
+ # does not change when an adapter does. It is also the reason a newcomer's first
625
+ # command is
626
+ #
627
+ # hc-source call codes.valid_on --param code=E11.9 --param date=2026-07-15
628
+ #
629
+ # which asks them to know a route name and a parameter grammar before they have
630
+ # seen the product do anything. These four wrap the questions people actually
631
+ # arrive with. They are thin on purpose: same tool, same guard, same receipt,
632
+ # same exit codes -- `call` remains the power-user path and the only one that
633
+ # reaches a third-party adapter's routes.
634
+
635
+
636
+ def _shortcut(tool: str, params: dict[str, Any], json_output: bool) -> None:
637
+ """Run a tool exactly as `call` would, and print it exactly as `call` does.
638
+
639
+ Literally the same function -- "exactly as `call` would" used to be a
640
+ promise kept by a second copy of the error handling, and the copy was
641
+ missing the two adapter-load cases.
642
+ """
643
+ _run_tool(tool, params, json_output)
644
+
645
+
646
+ @app.command("valid-on")
647
+ def valid_on(
648
+ code: str = typer.Argument(..., help="ICD-10-CM code, e.g. E11.9."),
649
+ date: str = typer.Argument(..., help="Date of service, YYYY-MM-DD."),
650
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
651
+ ) -> None:
652
+ """Was this ICD-10-CM code valid on this date of service? (offline)
653
+
654
+ Answered from the vendored release tables, so it works with no network and
655
+ no credentials. Shorthand for `call codes.valid_on`.
656
+ """
657
+ _shortcut("codes.valid_on", {"code": code, "date": date}, json_output)
658
+
659
+
660
+ @app.command("lookup-npi")
661
+ def lookup_npi(
662
+ npi: str = typer.Argument(..., help="10-digit NPI."),
663
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
664
+ ) -> None:
665
+ """Look one NPI up in the NPPES registry. Shorthand for `call provider.lookup_npi`."""
666
+ _shortcut("provider.lookup_npi", {"npi": npi}, json_output)
667
+
668
+
669
+ @app.command("check-npi")
670
+ def check_npi(
671
+ npi: str = typer.Argument(..., help="10-digit NPI."),
672
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
673
+ ) -> None:
674
+ """Screen one NPI against the OIG LEIE exclusion list.
675
+
676
+ A miss never clears a provider -- about 89.5% of LEIE records carry no NPI.
677
+ The receipt says so. Shorthand for `call leie.check_npi`.
678
+ """
679
+ _shortcut("leie.check_npi", {"npi": npi}, json_output)
680
+
681
+
682
+ hcc_app = typer.Typer(help="CMS-HCC risk model shortcuts.", no_args_is_help=True)
683
+ app.add_typer(hcc_app, name="hcc")
684
+
685
+
686
+ @hcc_app.command("score")
687
+ def hcc_score(
688
+ dx: str = typer.Option(..., "--dx", help="Comma-separated ICD-10-CM codes."),
689
+ model: str = typer.Option(..., "--model", help="v24 or v28."),
690
+ year: int = typer.Option(..., "--year", help="Payment year, e.g. 2026."),
691
+ age: Optional[int] = typer.Option(None, "--age", help="Beneficiary age."),
692
+ sex: Optional[str] = typer.Option(None, "--sex", help="F or M."),
693
+ dual: str = typer.Option("non", "--dual", help="non, full, or partial."),
694
+ orig_disabled: bool = typer.Option(
695
+ False, "--orig-disabled", help="Originally disabled entitlement."
696
+ ),
697
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
698
+ ) -> None:
699
+ """Community continuing-enrollee RAF score. Shorthand for `call hcc.score`."""
700
+ params: dict[str, Any] = {
701
+ "diagnoses": dx,
702
+ "model": model,
703
+ "payment_year": year,
704
+ "dual": dual,
705
+ "orig_disabled": orig_disabled,
706
+ }
707
+ if age is not None:
708
+ params["age"] = age
709
+ if sex is not None:
710
+ params["sex"] = sex
711
+ _shortcut("hcc.score", params, json_output)
712
+
713
+
714
+ # ---------------------------------------------------------------------------
715
+ # init
716
+ # ---------------------------------------------------------------------------
717
+
718
+ #: The `actions/checkout` pin the scaffold writes, and the tag it names.
719
+ #:
720
+ #: A mutable `@v4` follows wherever that tag is republished, including to a
721
+ #: compromised republish -- which is the hole this repository's own workflows
722
+ #: closed, and which the scaffold went on writing into other people's
723
+ #: repositories afterwards. A product about pinning the things your build
724
+ #: depends on does not hand its users an unpinned dependency on the way in.
725
+ #:
726
+ #: `.github/` is not in the wheel, so this cannot be read off our workflows at
727
+ #: runtime; `tests/test_release_workflow.py` asserts the two agree, so the pair
728
+ #: moves together or the suite goes red.
729
+ CHECKOUT_ACTION_PIN = "actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683"
730
+ CHECKOUT_ACTION_VERSION = "v4.2.2"
731
+
732
+ _CI_WORKFLOW = """\
733
+ # Written by `hc-source init --ci`.
734
+ #
735
+ # What this does: pins the public data sources your build depends on, and fails
736
+ # the build when one of them moves underneath you. `source-lock.json` is the pin;
737
+ # commit it and review its diffs like any other lockfile.
738
+ name: source-doctor
739
+
740
+ on:
741
+ pull_request:
742
+ push:
743
+ branches: [{branch}]
744
+ schedule:
745
+ # Drift happens on CMS's clock, not yours.
746
+ - cron: "17 6 * * *"
747
+
748
+ # This job reads public data sources and reports. It writes nothing, so it is
749
+ # given nothing to write with. GitHub's default token is read-write on most
750
+ # repositories; a workflow that runs on `pull_request` and does not narrow it
751
+ # is handing that token to whatever the pull request contains.
752
+ permissions:
753
+ contents: read
754
+
755
+ jobs:
756
+ doctor:
757
+ runs-on: ubuntu-latest
758
+ steps:
759
+ # Pinned to a commit, with the tag in the comment. A `@v4` follows
760
+ # wherever that tag points next -- including a compromised republish of
761
+ # it -- and this job runs before anything else in the workflow. Update
762
+ # the SHA and the comment together.
763
+ - uses: {checkout} # {checkout_version}
764
+ - uses: {action}
765
+ with:
766
+ lock-path: {lock_path}
767
+ # Warn rather than fail when a source is unreachable or a snapshot is
768
+ # stale on a scheduled run, and fail on a pull request. A nightly job
769
+ # exists to tell you CMS was down; a PR that could not read its inputs
770
+ # has verified nothing. "auto" is that rule.
771
+ on-unreachable: auto
772
+ on-stale: auto
773
+ """
774
+
775
+
776
+ @app.command()
777
+ def init(
778
+ ci: bool = typer.Option(False, "--ci", help="Write a GitHub Actions workflow."),
779
+ path: Optional[Path] = typer.Option(
780
+ None, "--path", help="Where to write it (default: .github/workflows/source-doctor.yml)."
781
+ ),
782
+ action: str = typer.Option(
783
+ "OWNER/sourcelock/action@REF",
784
+ "--action",
785
+ help="The action reference to use. Pin REF to a commit SHA: a tag can be moved after you read it.",
786
+ ),
787
+ branch: str = typer.Option("main", "--branch", help="Default branch to run on push."),
788
+ lock: Optional[Path] = typer.Option(
789
+ None, "--lock", help="Lockfile path to reference (default: source-lock.json)."
790
+ ),
791
+ force: bool = typer.Option(False, "--force", help="Overwrite an existing file."),
792
+ ) -> None:
793
+ """Scaffold SourceLock into a repository.
794
+
795
+ Today this writes the CI workflow. It never overwrites without `--force`:
796
+ the file it would replace is the one deciding whether your build fails.
797
+ """
798
+ if not ci:
799
+ _fail("nothing to do: pass --ci to write the GitHub Actions workflow.")
800
+
801
+ target = path or Path(".github/workflows/source-doctor.yml")
802
+ if target.exists() and not force:
803
+ _fail(
804
+ f"{target} already exists. Pass --force to overwrite it, or --path to write "
805
+ "somewhere else -- this file decides whether your build fails, so it is not "
806
+ "replaced silently."
807
+ )
808
+
809
+ content = _CI_WORKFLOW.format(
810
+ branch=branch,
811
+ action=action,
812
+ lock_path=(lock or Path(DEFAULT_LOCK_FILENAME)).as_posix(),
813
+ checkout=CHECKOUT_ACTION_PIN,
814
+ checkout_version=CHECKOUT_ACTION_VERSION,
815
+ )
816
+ try:
817
+ target.parent.mkdir(parents=True, exist_ok=True)
818
+ target.write_text(content, encoding="utf-8")
819
+ except OSError as exc:
820
+ _fail(f"cannot write {target}: {_os_error_reason(exc)}")
821
+ raise AssertionError # pragma: no cover
822
+
823
+ typer.echo(f"wrote {target}")
824
+ if "OWNER/sourcelock" in action:
825
+ typer.echo(
826
+ "! edit the `uses:` line: OWNER/sourcelock/action@REF is a placeholder. "
827
+ "Pin REF to a commit SHA: a tag can be moved after you read it."
828
+ )
829
+ typer.echo("next: `hc-source lock init`, then commit source-lock.json with the workflow.")
830
+
831
+
832
+ # ---------------------------------------------------------------------------
833
+ # receipt
834
+ # ---------------------------------------------------------------------------
835
+
836
+
837
+ @app.command()
838
+ def receipt(
839
+ verify: Path = typer.Option(..., "--verify", help="Path to a receipt JSON file."),
840
+ ) -> None:
841
+ """Validate a receipt file against the Receipt schema.
842
+
843
+ Schema validation only. Signature verification is not implemented yet:
844
+ a valid receipt here proves shape, not authorship.
845
+ """
846
+ try:
847
+ raw = Path(verify).read_text(encoding="utf-8")
848
+ except OSError as exc:
849
+ _fail(f"cannot read {verify}: {type(exc).__name__}")
850
+ raise AssertionError # pragma: no cover
851
+
852
+ try:
853
+ parsed = Receipt.model_validate_json(raw)
854
+ except ValidationError as exc:
855
+ problems = "; ".join(
856
+ f"{'.'.join(str(p) for p in e['loc']) or '<receipt>'}: {e['msg']}" for e in exc.errors()
857
+ )
858
+ _fail(f"receipt is not valid -- {problems}")
859
+ raise AssertionError # pragma: no cover
860
+
861
+ typer.echo(f"receipt is valid against the Receipt schema (v{__version__}).")
862
+ typer.echo(f" source={parsed.source_id} route={parsed.route} version={parsed.source_version}")
863
+ typer.echo(f" raw_sha256={parsed.raw_sha256}")
864
+ typer.echo("signature verification: not implemented -- this checks shape, not authorship.")
865
+
866
+
867
+ # ---------------------------------------------------------------------------
868
+ # cache
869
+ # ---------------------------------------------------------------------------
870
+
871
+ cache_app = typer.Typer(help="Inspect and clear the local caches.", no_args_is_help=True)
872
+ app.add_typer(cache_app, name="cache")
873
+
874
+
875
+ def _cache_report(stats: dict) -> None:
876
+ if not stats["enabled"]:
877
+ typer.echo(
878
+ f"caching is off ({cache.CACHE_DIR_ENV} is set to a disabling value). "
879
+ "Every fetch goes to the source."
880
+ )
881
+ return
882
+ ttl = stats["tool_cache_ttl_seconds"]
883
+ typer.echo(f"cache directory: {stats['path']}")
884
+ typer.echo(
885
+ f"HTTP entries: {stats['http_entries']} "
886
+ "(revalidated with ETag/Last-Modified on every use, so a hit is bytes "
887
+ "the source just confirmed)"
888
+ )
889
+ typer.echo(
890
+ f"result entries: {stats['tool_entries']} "
891
+ + (
892
+ f"(replayed for up to {ttl:g}s without contacting anyone)"
893
+ if ttl > 0
894
+ else f"(the result cache is OFF; set {cache.TOOL_TTL_ENV} to a number of seconds)"
895
+ )
896
+ )
897
+ typer.echo(f"on disk: {stats['bytes'] / 1024:.1f} KiB")
898
+
899
+ uncacheable = stats["uncacheable_urls"]
900
+ if uncacheable:
901
+ # Otherwise "0 entries" reads as a broken cache. It usually is not: most
902
+ # of the CMS Coverage API answers with neither an ETag nor a
903
+ # Last-Modified, so there is nothing to revalidate against and storing
904
+ # the body would break the only promise this cache makes.
905
+ typer.echo(
906
+ f"\n{len(uncacheable)} endpoint(s) cannot be cached: upstream sends no "
907
+ "ETag and no Last-Modified, so a stored copy could never be re-confirmed. "
908
+ "These are fetched fresh every time, by design:"
909
+ )
910
+ for url in uncacheable[:10]:
911
+ typer.echo(f" {url}")
912
+ if len(uncacheable) > 10:
913
+ typer.echo(f" ... and {len(uncacheable) - 10} more (--json for all)")
914
+
915
+
916
+ @cache_app.command("info")
917
+ def cache_info(
918
+ json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
919
+ ) -> None:
920
+ """Where the cache is, what is in it, and what it is allowed to do."""
921
+ stats = cache.cache_stats()
922
+ if json_output:
923
+ typer.echo(json.dumps(stats, indent=2))
924
+ raise typer.Exit(EXIT_OK)
925
+ _cache_report(stats)
926
+
927
+
928
+ @cache_app.command("clear")
929
+ def cache_clear() -> None:
930
+ """Delete every cached response and result."""
931
+ before = cache.clear_cache()
932
+ if not before["enabled"]:
933
+ typer.echo("caching is off; there was nothing to clear.")
934
+ raise typer.Exit(EXIT_OK)
935
+ typer.echo(
936
+ f"cleared {before['path']} -- {before['http_entries']} HTTP entry/entries, "
937
+ f"{before['tool_entries']} result(s), {before['bytes'] / 1024:.1f} KiB"
938
+ )
939
+
940
+
941
+ # ---------------------------------------------------------------------------
942
+ # mcp
943
+ # ---------------------------------------------------------------------------
944
+
945
+
946
+ @app.command()
947
+ def mcp() -> None:
948
+ """Serve every discovered tool to an agent over MCP stdio."""
949
+ from .mcp_server import serve_stdio
950
+
951
+ serve_stdio()
952
+
953
+
954
+ def main() -> None:
955
+ app()
956
+
957
+
958
+ if __name__ == "__main__": # pragma: no cover
959
+ main()