# CLI --list / --list-all Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add two non-interactive CLI flags, `--list` and `--list-all`, that print an aligned CN/expiry/email table to stdout — `--list` for the same recently-expired/soon-to-expire window the TUI's default view uses, `--list-all` for every valid cert. **Architecture:** A pure formatting function (`_format_cert_table()`) turns a `list[CertInfo]` into an aligned table string, reusing the existing `_expiry_label()` (TUI wording) and `get_email()` (index.txt-backed lookup, no metadata file). A new `CliRunner.list_certs(show_all: bool)` method picks `load_expiring_certs()` or `load_all_certs()` and prints the table. `main()` dispatches the two new mutually-exclusive flags to it, and — since listing never touches the CA private key — skips `resolve_ca_passphrase()` entirely for this path instead of prompting needlessly. **Tech Stack:** Python 3.9, stdlib `argparse`, existing `CertInfo`/`load_all_certs`/`load_expiring_certs`/`get_email`/`_expiry_label` from `openvpncertupdate.py`. Tests via `pytest` + `unittest.mock`, matching the patterns already used in `tests/test_cli.py`. ## Global Constraints - Single-file script: all production code stays in `openvpncertupdate.py`; no new modules. - Follow existing `CliRunner` conventions: business logic prints via `print()`/`sys.stderr`, no return values needed by callers. - `DAYS_PAST` / `DAYS_AHEAD` (SETTINGS constants, currently 30 / 14) define the `--list` window — do not hardcode different values. - No email storage beyond `index.txt` (established in the prior session's refactor) — `get_email()` is the only lookup path, do not reintroduce a metadata file. - Column layout for the table: `CN`, `EXPIRES` (from `_expiry_label()`, whitespace-stripped), `EMAIL` (from `get_email()`, or the literal string `(none)` when empty) — two spaces between columns, dynamically sized to the data, header row included. Empty cert list prints the literal string `(no certificates)`. - `--list`/`--list-all` must be mutually exclusive with each other and with `--create`/`--reissue`/`--revoke`/`--gen-crl` (same `argparse` group), and must NOT trigger `resolve_ca_passphrase()`. --- ## File Structure - **Modify `openvpncertupdate.py`:** - Add `_format_cert_table()` — new function, CLI section, directly above `class CliRunner:`. - Add `CliRunner.list_certs()` — new method on the existing class. - Modify `_build_parser()` — add `--list` / `--list-all` to the existing mutually-exclusive group. - Modify `main()` — dispatch the two new flags, and restructure so CA-passphrase resolution happens after (not before) that dispatch check, since list actions return early. - **Modify `tests/test_cli.py`:** add a new "list_certs / _format_cert_table" section following the file's existing conventions (`_patch_issue`-style monkeypatching, `sys.argv` dispatch tests). - **Modify `CLAUDE.md`:** add the two flags to the "CLI flags" table, add example invocations to "Running", and note the CA-passphrase-resolution exception in "Key constraints". --- ### Task 1: `_format_cert_table()` formatting helper **Files:** - Modify: `openvpncertupdate.py` (insert new function directly above `class CliRunner:`, currently at line 1265 — search for `# === CLI ===` section header) - Test: `tests/test_cli.py` **Interfaces:** - Consumes: `CertInfo` (fields: `cn: str`, `expires: datetime`, `days_left: int`, `email: str = ""`), `_expiry_label(c: CertInfo) -> str`, `get_email(pki_dir: str, cn: str) -> str`, module global `EASYRSA_PKI_DIR: str`. - Produces: `_format_cert_table(certs: list[CertInfo]) -> str` — used by Task 2. - [ ] **Step 1: Write the failing tests** Add to `tests/test_cli.py`, in a new section after the existing `CliRunner.revoke` / `CliRunner.regen_crl` tests and before `main() argument dispatch`: ```python # --------------------------------------------------------------------------- # _format_cert_table # --------------------------------------------------------------------------- def test_format_cert_table_empty_list(): import openvpncertupdate assert openvpncertupdate._format_cert_table([]) == "(no certificates)" def test_format_cert_table_header_and_columns(monkeypatch): import openvpncertupdate certs = [ openvpncertupdate.CertInfo(cn="alice", expires=None, days_left=24), openvpncertupdate.CertInfo(cn="bob", expires=None, days_left=-5), ] monkeypatch.setattr( "openvpncertupdate.get_email", MagicMock(side_effect=lambda pki, cn: {"alice": "alice@example.com"}.get(cn, "")), ) table = openvpncertupdate._format_cert_table(certs) lines = table.splitlines() assert lines[0].split() == ["CN", "EXPIRES", "EMAIL"] assert len(lines) == 3 def test_format_cert_table_shows_email_and_none_placeholder(monkeypatch): import openvpncertupdate certs = [ openvpncertupdate.CertInfo(cn="alice", expires=None, days_left=24), openvpncertupdate.CertInfo(cn="bob", expires=None, days_left=-5), ] monkeypatch.setattr( "openvpncertupdate.get_email", MagicMock(side_effect=lambda pki, cn: {"alice": "alice@example.com"}.get(cn, "")), ) table = openvpncertupdate._format_cert_table(certs) lines = table.splitlines() assert "alice@example.com" in lines[1] assert "(none)" in lines[2] def test_format_cert_table_columns_are_aligned(monkeypatch): import openvpncertupdate certs = [ openvpncertupdate.CertInfo(cn="a", expires=None, days_left=24), openvpncertupdate.CertInfo(cn="a-much-longer", expires=None, days_left=5), ] monkeypatch.setattr("openvpncertupdate.get_email", MagicMock(return_value="")) table = openvpncertupdate._format_cert_table(certs) lines = table.splitlines() # The EMAIL column must start at the same character offset on every row. email_col = len(lines[0]) - len("EMAIL") for line in lines[1:]: assert line.rstrip().endswith("(none)") assert line.index("(none)") == email_col ``` - [ ] **Step 2: Run tests to verify they fail** Run: `python3 -m pytest tests/test_cli.py -k format_cert_table -v` Expected: FAIL — `AttributeError: module 'openvpncertupdate' has no attribute '_format_cert_table'` - [ ] **Step 3: Implement `_format_cert_table()`** In `openvpncertupdate.py`, insert directly above `class CliRunner:` (i.e. right after the `# === CLI ===` section header block and before `class CliRunner:`): ```python def _format_cert_table(certs: list[CertInfo]) -> str: """Render CN / expiry / email as an aligned table, like `ls -l`.""" if not certs: return "(no certificates)" rows = [ (c.cn, _expiry_label(c).strip(), get_email(EASYRSA_PKI_DIR, c.cn) or "(none)") for c in certs ] cn_w = max(len("CN"), max(len(r[0]) for r in rows)) exp_w = max(len("EXPIRES"), max(len(r[1]) for r in rows)) lines = [f"{'CN':<{cn_w}} {'EXPIRES':<{exp_w}} EMAIL"] for cn, exp, email in rows: lines.append(f"{cn:<{cn_w}} {exp:<{exp_w}} {email}") return "\n".join(lines) ``` - [ ] **Step 4: Run tests to verify they pass** Run: `python3 -m pytest tests/test_cli.py -k format_cert_table -v` Expected: 4 passed - [ ] **Step 5: Commit** ```bash git add openvpncertupdate.py tests/test_cli.py git commit -m "add _format_cert_table() helper for CLI cert listings" ``` --- ### Task 2: `CliRunner.list_certs()` **Files:** - Modify: `openvpncertupdate.py` (add method to `class CliRunner`, currently spanning from `def create(...)` through `def _issue(...)`) - Test: `tests/test_cli.py` **Interfaces:** - Consumes: `_format_cert_table(certs: list[CertInfo]) -> str` (Task 1), `load_expiring_certs(pki_dir: str, days_past: int, days_ahead: int) -> list[CertInfo]`, `load_all_certs(pki_dir: str) -> list[CertInfo]`, module globals `EASYRSA_PKI_DIR`, `DAYS_PAST`, `DAYS_AHEAD`. - Produces: `CliRunner.list_certs(self, show_all: bool) -> None` — used by Task 3's `main()` dispatch. - [ ] **Step 1: Write the failing tests** Add to `tests/test_cli.py`, directly after the `_format_cert_table` tests from Task 1: ```python # --------------------------------------------------------------------------- # CliRunner.list_certs # --------------------------------------------------------------------------- def test_list_certs_expiring_uses_days_settings(monkeypatch, capsys): mock_load = MagicMock(return_value=[]) monkeypatch.setattr("openvpncertupdate.load_expiring_certs", mock_load) monkeypatch.setattr("openvpncertupdate.DAYS_PAST", 30) monkeypatch.setattr("openvpncertupdate.DAYS_AHEAD", 14) monkeypatch.setattr("openvpncertupdate.EASYRSA_PKI_DIR", "/pki") CliRunner().list_certs(show_all=False) mock_load.assert_called_once_with("/pki", 30, 14) assert "(no certificates)" in capsys.readouterr().out def test_list_certs_all_uses_load_all_certs(monkeypatch, capsys): mock_load = MagicMock(return_value=[]) monkeypatch.setattr("openvpncertupdate.load_all_certs", mock_load) monkeypatch.setattr("openvpncertupdate.EASYRSA_PKI_DIR", "/pki") CliRunner().list_certs(show_all=True) mock_load.assert_called_once_with("/pki") assert "(no certificates)" in capsys.readouterr().out def test_list_certs_prints_formatted_table(monkeypatch, capsys): import openvpncertupdate certs = [openvpncertupdate.CertInfo(cn="alice", expires=None, days_left=10)] monkeypatch.setattr("openvpncertupdate.load_expiring_certs", MagicMock(return_value=certs)) monkeypatch.setattr("openvpncertupdate.get_email", MagicMock(return_value="alice@example.com")) CliRunner().list_certs(show_all=False) out = capsys.readouterr().out assert "alice" in out assert "alice@example.com" in out ``` - [ ] **Step 2: Run tests to verify they fail** Run: `python3 -m pytest tests/test_cli.py -k list_certs -v` Expected: FAIL — `AttributeError: 'CliRunner' object has no attribute 'list_certs'` - [ ] **Step 3: Implement `CliRunner.list_certs()`** In `openvpncertupdate.py`, add this method to `class CliRunner`, directly after `def regen_crl(self) -> None:` and before `def _issue(...)`: ```python def list_certs(self, show_all: bool) -> None: certs = (load_all_certs(EASYRSA_PKI_DIR) if show_all else load_expiring_certs(EASYRSA_PKI_DIR, DAYS_PAST, DAYS_AHEAD)) print(_format_cert_table(certs)) ``` - [ ] **Step 4: Run tests to verify they pass** Run: `python3 -m pytest tests/test_cli.py -k list_certs -v` Expected: 3 passed - [ ] **Step 5: Commit** ```bash git add openvpncertupdate.py tests/test_cli.py git commit -m "add CliRunner.list_certs()" ``` --- ### Task 3: `--list` / `--list-all` CLI flags and dispatch **Files:** - Modify: `openvpncertupdate.py` (`_build_parser()` and `main()`) - Test: `tests/test_cli.py` **Interfaces:** - Consumes: `CliRunner.list_certs(show_all: bool) -> None` (Task 2), existing `resolve_ca_passphrase()`, `_build_parser()`. - Produces: `args.list: bool`, `args.list_all: bool` on the parsed namespace — internal to `main()`, no other task depends on these names. - [ ] **Step 1: Write the failing tests** Add to `tests/test_cli.py`, in the `main() argument dispatch` section, directly after `test_main_dispatches_gen_crl`: ```python def test_main_dispatches_list(monkeypatch): monkeypatch.setattr(sys, "argv", ["prog", "--list"]) runner = MagicMock() monkeypatch.setattr("openvpncertupdate.CliRunner", lambda: runner) main() runner.list_certs.assert_called_once_with(show_all=False) def test_main_dispatches_list_all(monkeypatch): monkeypatch.setattr(sys, "argv", ["prog", "--list-all"]) runner = MagicMock() monkeypatch.setattr("openvpncertupdate.CliRunner", lambda: runner) main() runner.list_certs.assert_called_once_with(show_all=True) def test_main_list_skips_ca_passphrase_resolution(monkeypatch): monkeypatch.setattr(sys, "argv", ["prog", "--list"]) monkeypatch.setattr("openvpncertupdate.CliRunner", lambda: MagicMock()) mock_resolve = MagicMock() monkeypatch.setattr("openvpncertupdate.resolve_ca_passphrase", mock_resolve) main() mock_resolve.assert_not_called() def test_main_list_all_skips_ca_passphrase_resolution(monkeypatch): monkeypatch.setattr(sys, "argv", ["prog", "--list-all"]) monkeypatch.setattr("openvpncertupdate.CliRunner", lambda: MagicMock()) mock_resolve = MagicMock() monkeypatch.setattr("openvpncertupdate.resolve_ca_passphrase", mock_resolve) main() mock_resolve.assert_not_called() ``` - [ ] **Step 2: Run tests to verify they fail** Run: `python3 -m pytest tests/test_cli.py -k "dispatches_list or list_skips or list_all_skips" -v` Expected: FAIL — `error: unrecognized arguments: --list` (argparse `SystemExit`) - [ ] **Step 3: Add the flags to `_build_parser()`** In `openvpncertupdate.py`, find this existing line in `_build_parser()`: ```python group.add_argument("--gen-crl", action="store_true", help="Regenerate and copy CRL") ``` Leave it unchanged, and insert these two new lines directly after it: ```python group.add_argument("--list", action="store_true", help="List recently-expired/soon-to-expire CNs " "(per DAYS_PAST/DAYS_AHEAD) with email") group.add_argument("--list-all", action="store_true", help="List all CNs with email") ``` - [ ] **Step 4: Dispatch the flags in `main()`, skipping CA passphrase resolution** In `openvpncertupdate.py`, `main()` currently reads: ```python def main() -> None: parser = _build_parser() args = parser.parse_args() try: overrides = load_settings_overrides(args.config, CONFIG_PATH, __file__) except ConfigError as exc: print(f"error: {exc}", file=sys.stderr) sys.exit(1) globals().update(overrides) global CA_PASSPHRASE CA_PASSPHRASE = resolve_ca_passphrase(CA_PASSPHRASE, EASYRSA_PKI_DIR) # --show-eml switches the default to --no-send-email; explicit --send-email overrides. send_email_flag = args.send_email if args.send_email is not None else (not args.show_eml) if args.create: ``` Replace the block from `global CA_PASSPHRASE` up to (not including) `if args.create:` with: ```python if args.list: CliRunner().list_certs(show_all=False) return if args.list_all: CliRunner().list_certs(show_all=True) return global CA_PASSPHRASE CA_PASSPHRASE = resolve_ca_passphrase(CA_PASSPHRASE, EASYRSA_PKI_DIR) # --show-eml switches the default to --no-send-email; explicit --send-email overrides. send_email_flag = args.send_email if args.send_email is not None else (not args.show_eml) if args.create: ``` - [ ] **Step 5: Run tests to verify they pass** Run: `python3 -m pytest tests/test_cli.py -v` Expected: all tests in the file PASS (this also re-verifies Tasks 1–2 didn't regress, and that the pre-existing `test_main_resolves_ca_passphrase_before_dispatch` / `test_main_dispatches_gen_crl` etc. still pass since `--gen-crl` and friends are untouched) - [ ] **Step 6: Run the full suite** Run: `python3 -m pytest tests/ -v` Expected: all tests PASS, no regressions in other files - [ ] **Step 7: Commit** ```bash git add openvpncertupdate.py tests/test_cli.py git commit -m "add --list/--list-all CLI flags" ``` --- ### Task 4: Update CLAUDE.md **Files:** - Modify: `CLAUDE.md` **Interfaces:** - Consumes: nothing (documentation only). - Produces: nothing (documentation only). - [ ] **Step 1: Add example invocations** In `CLAUDE.md`, in the `## Running` fenced code block, add two lines after `python3 openvpncertupdate.py --gen-crl`: ``` python3 openvpncertupdate.py --list python3 openvpncertupdate.py --list-all ``` - [ ] **Step 2: Add the flags to the CLI flags table** In `CLAUDE.md`, in the "### CLI flags" table, add two rows directly after the `--gen-crl` row: ``` | `--gen-crl` | Regenerate and copy CRL only | | `--list` | List recently-expired/soon-to-expire CNs (per `DAYS_PAST`/`DAYS_AHEAD`) with email; read-only, no CA passphrase needed | | `--list-all` | List all CNs with email; read-only, no CA passphrase needed | ``` - [ ] **Step 3: Note the CA-passphrase exception in Key constraints** In `CLAUDE.md`, in `## Key constraints`, the line starting `- CA passphrase resolution (...)` currently ends with `any other value → used literally`. Append a new sentence to that same bullet: ``` `--list`/`--list-all` skip this resolution entirely since they only read `index.txt` and never touch the CA ``` So the full bullet reads: ``` - CA passphrase resolution (`resolve_ca_passphrase()`, run once in `main()` right after arg parsing, before dispatch): `CA_PASSPHRASE=""` → auto-detect via `is_ca_key_encrypted()` (checks `/private/ca.key` PEM header for `ENCRYPTED`) and prompt only if encrypted; `"!empty"` → never check/prompt, passphrase is `""`; `"!ask"` → always prompt, skip detection; any other value → used literally. `--list`/`--list-all` skip this resolution entirely since they only read `index.txt` and never touch the CA ``` - [ ] **Step 4: Verify no stale claims remain** Run: `grep -n "openvpncertupdate-metadata" CLAUDE.md` Expected: no output (confirms Task 4 didn't reintroduce the removed metadata-file reference) - [ ] **Step 5: Commit** ```bash git add CLAUDE.md git commit -m "document --list/--list-all in CLAUDE.md" ``` --- ## Final Verification - [ ] Run `python3 -m pytest tests/ -v` — all tests pass, count should be 126 (prior) + 11 new = 137. - [ ] Manually sanity-check the two new flags against the real `tests/` fixtures aren't needed (no live PKI in this repo) — rely on the unit tests above; if a live PKI directory is available, run `python3 openvpncertupdate.py --list` and `--list-all` by hand to eyeball alignment.