# Summary: hxprobe reads target URLs from a file Plan: [docs/plans/2026-07-02-11-14-hxprobe-file-input.md](../plans/2026-07-02-11-14-hxprobe-file-input.md) ## What was built - **`hxprobe/hxprobe/cli.py`**: new `-f`/`--file PATH` flag, mutually exclusive with positional `url` args. `urls` positional changed `nargs="+"` → `nargs="*"`. New `_load_urls()` helper (same format as `simple.py`'s `load_sites()`: one URL per line, `#` comments, blank lines skipped, first token per line) — reimplemented locally rather than imported, keeping hxprobe's "no imports outside its own directory" rule intact. Validation added right after `parser.parse_args()`, inside the same `try/except _ArgExit` block: both-given and neither-given are usage errors via `parser.error()`; missing/unreadable/empty file are usage errors via direct `stderr.write()` + `return EXIT_USAGE` (mirroring the existing `--timeout` invalid-value handling style already in the file). - **`hxprobe/tests/test_cli.py`**: new `TestCLIFileInput` class, 5 tests — reads URLs from a temp file successfully, missing file, empty file, both-sources error, neither-given error. - **`hxprobe/configs/*.txt`**: 7 new fixtures mirroring `python/configs/`'s exact set. Each header comment states an "Expected exit code" that was verified by actually running the fixture through `hxprobe -f ...` during implementation (not assumed from the `simple.py` originals, whose blanket 0/1 exit scheme is fundamentally different from hxprobe's per-failure-class 0–6 worst-code-wins scheme). - **`hxprobe/USAGE.md`**: new "Reading URLs from a file (`-f`)" section, placed after "Multiple URLs", with real captured output for the successful case, the mutually-exclusive error case, and one failure fixture (`dns-failure.txt`), plus a table listing all 7 fixtures and their expected exit codes. ## Key design decisions - **Manual post-parse validation instead of `argparse`'s `add_mutually_exclusive_group`** — a variadic positional (`nargs="*"`) doesn't mix cleanly with argparse's built-in mutually-exclusive-group machinery. Manual checks after `parser.parse_args()` (still inside the same `try/except _ArgExit`, using `parser.error()`) give the same usage-error behavior with full control over the message text. - **File I/O errors don't go through `parser.error()`** — they use the same direct `stderr.write()` + `return EXIT_USAGE` pattern already established for `--timeout` parsing failures, since they're discovered after parsing succeeds, not during it. - **Every fixture's exit code was verified live, not assumed.** Two required real judgment calls the `simple.py` originals didn't need: `http-errors.txt` and `mixed.txt` both needed `--fail` added to their demo command (hxprobe treats 4xx as success without it, unlike `simple.py` which always raises on `HTTPError`) to actually demonstrate a failure — without `--fail` both would silently show exit 0. ## Deviations from the plan None of substance. The plan anticipated needing to verify exit codes live rather than assume them; that anticipation paid off exactly as expected for `http-errors.txt`/`mixed.txt` (needed `--fail` added) and `timeout.txt` (needed `--timeout 2s` added to keep the demo fast, and a note added about this sandbox occasionally short-circuiting one of the two timeout targets to an immediate "connection refused" — observed directly: in this session's test run, `10.255.255.1` genuinely timed out; in an earlier, unrelated test earlier in the session it instead got refused immediately. The fixture keeps both targets so at least one demonstrates the real timeout path regardless). ## Verification - `all-ok.txt` → exit 0, `dns-failure.txt` → 2, `connection-refused.txt` → 3, `tls-errors.txt` → 5, `timeout.txt` (with `--timeout 2s`) → 4, `http-errors.txt` (with `--fail`) → 6, `mixed.txt` (with `--fail`) → 6 — all confirmed via real `$?` checks (not through a `grep` pipe, which masks the real exit code — caught and corrected this during testing) - `uv run pytest tests/test_cli.py -m "not integration"` — 16/16 pass (11 pre-existing + 5 new) - `uv run ruff check .` / `uv run ruff format --check .` — clean - Both-sources-given and missing-file error messages verified against the doc's pasted output, including the `usage:` block that's actually printed (an early draft of the doc omitted it — caught on review)