Plings Keygen Menu 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: Build an interactive menu program (scripts/plings_keygen.py) that wraps key/tag generation, master-key management, and URL validation so the operator never types long CLI commands.

Architecture: A thin questionary/rich TUI in scripts/plings_keygen.py calls a pure, testable core (scripts/plings_keygen_core.py) and reuses existing classes (HDPathKeyDerivation, load_master_key, MasterKeyGenerator, MasterKeyImporter). CSV writing moves to a shared helper (scripts/utils/output_utils.py) used by both the menu and derive_path_keys.py. No cryptographic logic is reimplemented. Execute this plan in a dedicated Plings-API git worktree (other sessions touch this repo).

Tech Stack: Python 3.10+, Poetry, pytest, questionary + rich (dev-only deps), existing crypto_utils/path_utils.

Spec: docs/specs/2026-06-13-plings-keygen-menu-design.md


File Structure

  • Create scripts/utils/output_utils.pywrite_keys_csv(keys, path). Single CSV writer (cols: shortcode, decimal_path, base58_path, url).
  • Create scripts/plings_keygen_core.py — pure, testable functions: filename builder, env inference, base58↔decimal path, history I/O, master-key discovery, URL parse + validate.
  • Create scripts/plings_keygen.py — the questionary/rich TUI; orchestrates the core + existing classes.
  • Modify scripts/derive_path_keys.py — use write_keys_csv instead of inline CSV code (behaviour-preserving).
  • Modify pyproject.toml — add questionary, rich to [tool.poetry.group.dev.dependencies].
  • Modify .gitignore — ignore keys/.keygen_history.json.
  • Create tests/test_output_utils.py, tests/test_keygen_core.py — unit + determinism regression.

All script imports rely on the existing sys.path insertion of scripts/utils (see top of derive_path_keys.py).


Task 1: Add dev dependencies and gitignore the history file

Files:

  • Modify: pyproject.toml ([tool.poetry.group.dev.dependencies])
  • Modify: .gitignore

  • Step 1: Add deps to the dev group

In pyproject.toml, under [tool.poetry.group.dev.dependencies] (create the section if absent), add:

questionary = "^2.0.1"
rich = "^13.7.0"

Do not add these to requirements.txt (Vercel deploy manifest must stay clean — this is a local-only tool).

  • Step 2: Install

Run: poetry install Expected: questionary + rich resolved into the dev environment.

  • Step 3: Ignore the history file

Append to .gitignore:

# Local keygen menu state (not shared)
keys/.keygen_history.json
  • Step 4: Commit
git add pyproject.toml poetry.lock .gitignore
git commit -m "chore(scripts): add questionary+rich dev deps; ignore keygen history"

Task 2: Extract the shared CSV writer

Files:

  • Create: scripts/utils/output_utils.py
  • Modify: scripts/derive_path_keys.py (replace inline CSV block in main())
  • Test: tests/test_output_utils.py

  • Step 1: Write the failing test
# tests/test_output_utils.py
import csv, os, sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'scripts', 'utils'))
from output_utils import write_keys_csv

def test_write_keys_csv_columns_and_decimal(tmp_path):
    keys = [{
        "short_code": "CCue",
        "qr_url": "https://s.plings.io?t=q&i=CCue...&p=4.2.3.2.2",
        "plings_path": "4.2.3.2.2",   # base58 → decimal 3.1.2.1.1
    }]
    out = tmp_path / "x.csv"
    write_keys_csv(keys, str(out))
    rows = list(csv.reader(open(out, newline="", encoding="utf-8")))
    assert rows[0] == ["shortcode", "decimal_path", "base58_path", "url"]
    assert rows[1][0] == "CCue"
    assert rows[1][1] == "3.1.2.1.1"
    assert rows[1][2] == "4.2.3.2.2"
  • Step 2: Run test to verify it fails

Run: poetry run pytest tests/test_output_utils.py -v Expected: FAIL with ModuleNotFoundError: No module named 'output_utils'.

  • Step 3: Create the writer
# scripts/utils/output_utils.py
#!/usr/bin/env python3
"""Shared output helpers for Plings key-generation scripts."""
import csv
from typing import Dict, List

from crypto_utils import CryptoUtils


def write_keys_csv(keys: List[Dict[str, str]], output_file: str) -> None:
    """Write derived keys to CSV: shortcode, decimal_path, base58_path, url."""
    crypto_utils = CryptoUtils()
    with open(output_file, "w", newline="", encoding="utf-8") as csvfile:
        writer = csv.writer(csvfile)
        writer.writerow(["shortcode", "decimal_path", "base58_path", "url"])
        for key_data in keys:
            base58_path = key_data.get("plings_path", "")
            decimal_parts = []
            for part in base58_path.split("."):
                try:
                    decimal_parts.append(str(crypto_utils.decode_number_base58(part)))
                except Exception:
                    decimal_parts.append(part)
            writer.writerow([
                key_data.get("short_code", ""),
                ".".join(decimal_parts),
                base58_path,
                key_data.get("qr_url", ""),
            ])
  • Step 4: Run test to verify it passes

Run: poetry run pytest tests/test_output_utils.py -v Expected: PASS.

  • Step 5: Refactor derive_path_keys.py to use it

In scripts/derive_path_keys.py, add to the utils imports near the top:

from output_utils import write_keys_csv

Replace the CSV-writing block inside main() (the with open(args.csv_output, ...) loop that writes ['shortcode', 'decimal_path', 'base58_path', 'url']) with:

        if args.csv_output:
            try:
                write_keys_csv(keys, args.csv_output)
                logger.info(f"📊 CSV file generated: {args.csv_output}")
                logger.info(f"📋 Total rows: {len(keys)} (plus header)")
            except Exception as e:
                logger.error(f"❌ CSV generation failed: {e}")
                sys.exit(1)
  • Step 6: Verify the CLI still produces identical CSV

Run: poetry run python scripts/derive_path_keys.py --path "3.1.2.1" --decimal-path --quantity 100 --master-key keys/dev/dev_key.json --csv-output /tmp/regen2.csv --quiet && diff keys/dev/pauls_1-100st.csv /tmp/regen2.csv && echo IDENTICAL Expected: IDENTICAL (behaviour preserved).

  • Step 7: Commit
git add scripts/utils/output_utils.py scripts/derive_path_keys.py tests/test_output_utils.py
git commit -m "refactor(scripts): extract write_keys_csv into shared output_utils"

Task 3: Core — filename builder, env inference, base58↔decimal path

Files:

  • Create: scripts/plings_keygen_core.py
  • Test: tests/test_keygen_core.py

  • Step 1: Write the failing test
# tests/test_keygen_core.py
import os, sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'scripts'))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'scripts', 'utils'))
import plings_keygen_core as core

def test_build_csv_filename():
    assert core.build_csv_filename("pauls", 101, 150) == "pauls_101-150st.csv"

def test_infer_env_dir():
    assert core.infer_env_dir("keys/dev/dev_key.json").replace("\\", "/") == "keys/dev"

def test_decimal_base58_roundtrip():
    assert core.decimal_path_to_base58("3.1.2.1") == "4.2.3.2"
    assert core.base58_path_to_decimal("4.2.3.2.2") == "3.1.2.1.1"
  • Step 2: Run test to verify it fails

Run: poetry run pytest tests/test_keygen_core.py -v Expected: FAIL with ModuleNotFoundError: No module named 'plings_keygen_core'.

  • Step 3: Create the module with these functions
# scripts/plings_keygen_core.py
#!/usr/bin/env python3
"""Pure, testable core for the Plings keygen menu (no I/O prompts)."""
import os
import sys

sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'app'))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'utils'))

from crypto_utils import CryptoUtils

_crypto = CryptoUtils()


def build_csv_filename(label: str, start: int, end: int) -> str:
    """e.g. ('pauls', 101, 150) -> 'pauls_101-150st.csv'."""
    return f"{label}_{start}-{end}st.csv"


def infer_env_dir(master_key_path: str) -> str:
    """Directory family of the chosen master key (e.g. keys/dev/dev_key.json -> keys/dev)."""
    return os.path.dirname(master_key_path)


def decimal_path_to_base58(decimal_path: str) -> str:
    return ".".join(_crypto.encode_number_base58(int(p)) for p in decimal_path.split("."))


def base58_path_to_decimal(base58_path: str) -> str:
    return ".".join(str(_crypto.decode_number_base58(p)) for p in base58_path.split("."))
  • Step 4: Run test to verify it passes

Run: poetry run pytest tests/test_keygen_core.py -v Expected: PASS (3 tests).

  • Step 5: Commit
git add scripts/plings_keygen_core.py tests/test_keygen_core.py
git commit -m "feat(keygen): core filename/env/path helpers"

Task 4: Core — history read/write/update

Files:

  • Modify: scripts/plings_keygen_core.py
  • Test: tests/test_keygen_core.py

  • Step 1: Write the failing test
# append to tests/test_keygen_core.py
def test_history_roundtrip_and_update(tmp_path):
    hist_file = tmp_path / ".keygen_history.json"
    assert core.load_history(str(hist_file)) == {}          # missing -> empty
    h = core.update_history({}, "pauls", "3.1.2.1", 100, "keys/dev/dev_key.json")
    assert h["pauls"]["last_instance"] == 100
    assert h["pauls"]["path_decimal"] == "3.1.2.1"
    core.save_history(str(hist_file), h)
    assert core.load_history(str(hist_file))["pauls"]["last_instance"] == 100

def test_history_corrupt_file_is_empty(tmp_path):
    hist_file = tmp_path / ".keygen_history.json"
    hist_file.write_text("{ not json")
    assert core.load_history(str(hist_file)) == {}
  • Step 2: Run test to verify it fails

Run: poetry run pytest tests/test_keygen_core.py -k history -v Expected: FAIL with AttributeError: module 'plings_keygen_core' has no attribute 'load_history'.

  • Step 3: Add history functions
# add to scripts/plings_keygen_core.py
import json
from datetime import datetime, timezone


def load_history(history_path: str) -> dict:
    """Read the history file; missing or corrupt -> empty dict (never fatal)."""
    try:
        with open(history_path, "r", encoding="utf-8") as f:
            data = json.load(f)
            return data if isinstance(data, dict) else {}
    except (FileNotFoundError, json.JSONDecodeError, OSError):
        return {}


def save_history(history_path: str, history: dict) -> None:
    with open(history_path, "w", encoding="utf-8") as f:
        json.dump(history, f, indent=2)


def update_history(history: dict, label: str, path_decimal: str,
                   last_instance: int, master_key: str) -> dict:
    history = dict(history)
    history[label] = {
        "path_decimal": path_decimal,
        "last_instance": last_instance,
        "master_key": master_key,
        "updated_at": datetime.now(timezone.utc).isoformat(),
    }
    return history
  • Step 4: Run test to verify it passes

Run: poetry run pytest tests/test_keygen_core.py -k history -v Expected: PASS (2 tests).

  • Step 5: Commit
git add scripts/plings_keygen_core.py tests/test_keygen_core.py
git commit -m "feat(keygen): history read/write/update"

Task 5: Core — master-key discovery

Files:

  • Modify: scripts/plings_keygen_core.py
  • Test: tests/test_keygen_core.py

  • Step 1: Write the failing test
# append to tests/test_keygen_core.py
import json as _json
def test_discover_master_keys(tmp_path):
    (tmp_path / "dev").mkdir()
    good = tmp_path / "dev" / "dev_key.json"
    good.write_text(_json.dumps({"wallet_version": 3, "private_key": "x", "chain_code": "y"}))
    (tmp_path / "dev" / "batch.csv").write_text("not,a,key")
    (tmp_path / "dev" / "notkey.json").write_text(_json.dumps({"foo": "bar"}))
    found = core.discover_master_keys(str(tmp_path))
    assert any(p.endswith("dev/dev_key.json") or p.endswith("dev\\dev_key.json") for p in found)
    assert all("batch.csv" not in p and "notkey.json" not in p for p in found)
  • Step 2: Run test to verify it fails

Run: poetry run pytest tests/test_keygen_core.py -k discover -v Expected: FAIL with AttributeError: ... has no attribute 'discover_master_keys'.

  • Step 3: Add discovery
# add to scripts/plings_keygen_core.py
import glob


def is_master_key(path: str) -> bool:
    try:
        with open(path, "r", encoding="utf-8") as f:
            data = json.load(f)
        return isinstance(data, dict) and "private_key" in data and "chain_code" in data
    except (json.JSONDecodeError, OSError):
        return False


def discover_master_keys(keys_root: str) -> list:
    """All *.json under keys_root that look like master keys, sorted by path."""
    candidates = glob.glob(os.path.join(keys_root, "**", "*.json"), recursive=True)
    return sorted(p for p in candidates if is_master_key(p))
  • Step 4: Run test to verify it passes

Run: poetry run pytest tests/test_keygen_core.py -k discover -v Expected: PASS.

  • Step 5: Commit
git add scripts/plings_keygen_core.py tests/test_keygen_core.py
git commit -m "feat(keygen): master-key discovery"

Task 6: Core — URL parse + validate against a master key

Files:

  • Modify: scripts/plings_keygen_core.py
  • Test: tests/test_keygen_core.py

  • Step 1: Write the failing test (uses a real row from keys/dev/pauls_1-100st.csv)
# append to tests/test_keygen_core.py
from generate_master_key import MasterKeyGenerator

# keys/dev/dev_key.json is GITIGNORED, so regenerate it deterministically instead of
# reading the file. Dev mode is seed-based and reproduces dev_key.json byte-for-byte.
DEV_KEY = MasterKeyGenerator(production=False).generate_master_key(3)
GOOD_URL = "https://s.plings.io?t=q&i=CCuejfEAmFGKaTqhjgXjxQepiY9CZyEB463wtA4izNb9&p=4.2.3.2.2"

def test_parse_plings_url():
    parsed = core.parse_plings_url(GOOD_URL)
    assert parsed["i"] == "CCuejfEAmFGKaTqhjgXjxQepiY9CZyEB463wtA4izNb9"
    assert parsed["p"] == "4.2.3.2.2"
    assert parsed["t"] == "q"

def test_validate_url_match():
    r = core.validate_url_against_master(GOOD_URL, DEV_KEY)
    assert r["match"] is True
    assert r["decimal_path"] == "3.1.2.1.1"
    assert r["wallet_version"] == 3

def test_validate_url_mismatch():
    tampered = GOOD_URL.replace("CCue", "ZZZZ")
    r = core.validate_url_against_master(tampered, DEV_KEY)
    assert r["match"] is False
  • Step 2: Run test to verify it fails

Run: poetry run pytest tests/test_keygen_core.py -k url -v Expected: FAIL with AttributeError: ... has no attribute 'parse_plings_url'.

  • Step 3: Add URL parse + validate
# add to scripts/plings_keygen_core.py
from urllib.parse import urlparse, parse_qs

from derive_path_keys import HDPathKeyDerivation


def parse_plings_url(url: str) -> dict:
    """Extract i (instance_key), p (base58 path), cp (class pointer), t (transport)."""
    qs = parse_qs(urlparse(url).query)
    return {
        "i": (qs.get("i") or [""])[0],
        "p": (qs.get("p") or [""])[0],
        "cp": (qs.get("cp") or [None])[0],
        "t": (qs.get("t") or [None])[0],
    }


def validate_url_against_master(url: str, master_key_data: dict) -> dict:
    """Re-derive the path against the master key and compare to the URL's instance key."""
    parsed = parse_plings_url(url)
    deriv = HDPathKeyDerivation(master_key_data)
    derived = deriv.derive_path_key(parsed["p"])
    return {
        "match": derived["instance_key"] == parsed["i"],
        "url_instance_key": parsed["i"],
        "derived_instance_key": derived["instance_key"],
        "base58_path": parsed["p"],
        "decimal_path": base58_path_to_decimal(parsed["p"]),
        "wallet_version": derived["wallet_version"],
        "short_code": derived["short_code"],
        "cp_in_url": parsed["cp"],
    }
  • Step 4: Run test to verify it passes

Run: poetry run pytest tests/test_keygen_core.py -k url -v Expected: PASS (3 tests).

  • Step 5: Commit
git add scripts/plings_keygen_core.py tests/test_keygen_core.py
git commit -m "feat(keygen): URL parse + validate against master key"

Task 7: Determinism regression test

Files:

  • Test: tests/test_keygen_core.py

  • Step 1: Write the test (self-contained known-answer — no gitignored fixtures)

# append to tests/test_keygen_core.py
from derive_path_keys import HDPathKeyDerivation

def test_determinism_known_answer():
    # DEV_KEY is regenerated deterministically above — no gitignored fixture needed.
    keys = HDPathKeyDerivation(DEV_KEY).derive_batch_keys("4.2.3.2", 100)  # decimal 3.1.2.1, qty 100
    # Known answers pin the BIP32 derivation (these equal rows in keys/dev/pauls_1-100st.csv).
    assert keys[0]["plings_path"] == "4.2.3.2.2"
    assert keys[0]["instance_key"] == "CCuejfEAmFGKaTqhjgXjxQepiY9CZyEB463wtA4izNb9"
    assert keys[0]["short_code"] == "CCue"
    assert keys[99]["plings_path"] == "4.2.3.2.2j"
    assert keys[99]["instance_key"] == "EroLzw2j2jfXM1fydPyvRcAThMb1Uyidi6SVq6tSMfpj"
    # Re-derivation is byte-stable (determinism guarantee).
    keys2 = HDPathKeyDerivation(DEV_KEY).derive_batch_keys("4.2.3.2", 100)
    assert [k["instance_key"] for k in keys] == [k["instance_key"] for k in keys2]
  • Step 2: Run test to verify it passes (regression guard, expected to pass immediately)

Run: poetry run pytest tests/test_keygen_core.py -k determinism -v Expected: PASS. If it FAILS, generation behaviour drifted — stop and investigate before continuing.

  • Step 3: Commit
git add tests/test_keygen_core.py
git commit -m "test(keygen): determinism regression vs pauls_1-100st.csv"

Task 8: The interactive TUI entrypoint

Files:

  • Create: scripts/plings_keygen.py

This task is verified manually (interactive TUI). Write the complete file, then run the three manual checks.

  • Step 1: Create scripts/plings_keygen.py
#!/usr/bin/env python3
"""Plings Keygen — interactive menu for key/tag generation, master keys, URL validation."""
import os
import sys

sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'app'))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'utils'))
sys.path.insert(0, os.path.dirname(__file__))

import questionary
from rich.console import Console
from rich.table import Table

import plings_keygen_core as core
from derive_path_keys import HDPathKeyDerivation, load_master_key
from generate_master_key import MasterKeyGenerator
from import_master_key import MasterKeyImporter
from output_utils import write_keys_csv
from path_utils import PlingsPathUtils

console = Console()
KEYS_ROOT = os.path.join(os.path.dirname(__file__), '..', 'keys')
HISTORY_FILE = os.path.join(KEYS_ROOT, '.keygen_history.json')


def choose_master_key():
    keys = core.discover_master_keys(KEYS_ROOT)
    if not keys:
        console.print("[red]No master keys found under keys/. Create one first.[/red]")
        return None
    choice = questionary.select(
        "Select master key:",
        choices=[questionary.Choice(os.path.relpath(k), value=k) for k in keys],
    ).ask()
    return choice


def generate_flow():
    mk_path = choose_master_key()
    if not mk_path:
        return
    master = load_master_key(mk_path)
    history = core.load_history(HISTORY_FILE)

    source = questionary.select(
        "Path source:",
        choices=["Continue from history", "New path"] if history else ["New path"],
    ).ask()

    if source == "Continue from history":
        label = questionary.select("Which batch?", choices=list(history.keys())).ask()
        decimal_path = history[label]["path_decimal"]
        default_start = history[label]["last_instance"] + 1
    else:
        label = questionary.text("Label (e.g. pauls):").ask()
        decimal_path = questionary.text("Decimal path (e.g. 3.1.2.1):").ask()
        if not PlingsPathUtils.validate_plings_path(core.decimal_path_to_base58(decimal_path) + ".2"):
            console.print("[red]Invalid path.[/red]")
            return
        default_start = 1

    start = int(questionary.text("Start instance:", default=str(default_start)).ask())
    qty = int(questionary.text("Quantity:", default="1").ask())
    cp = questionary.text("Class pointer (blank = none):", default="").ask() or None
    end = start + qty - 1

    env_dir = core.infer_env_dir(mk_path)
    out_path = os.path.join(env_dir, core.build_csv_filename(label, start, end))

    console.print(f"\n[bold]Master:[/bold] {os.path.relpath(mk_path)}")
    console.print(f"[bold]Path:[/bold] {decimal_path}  (base58 {core.decimal_path_to_base58(decimal_path)})")
    console.print(f"[bold]Instances:[/bold] {start}{end}  ({qty} keys)")
    console.print(f"[bold]Output:[/bold] {out_path}")
    if os.path.exists(out_path) and not questionary.confirm(
            f"{out_path} exists. Overwrite?", default=False).ask():
        return
    if not questionary.confirm("Generate?", default=True).ask():
        return

    deriv = HDPathKeyDerivation(master, class_pointer=cp)
    base58_start = core.decimal_path_to_base58(decimal_path) + "." + \
        deriv.crypto_utils.encode_number_base58(start)
    keys = deriv.derive_batch_from_instance(base58_start, qty)
    if not deriv.validate_derived_keys(keys):
        console.print("[red]Validation failed.[/red]")
        return
    write_keys_csv(keys, out_path)
    core.save_history(HISTORY_FILE,
                      core.update_history(history, label, decimal_path, end, mk_path))

    table = Table(title=f"{len(keys)} keys → {os.path.relpath(out_path)}")
    table.add_column("shortcode"); table.add_column("decimal_path"); table.add_column("url")
    for k in keys[:5]:
        table.add_row(k["short_code"], core.base58_path_to_decimal(k["plings_path"]), k["qr_url"])
    console.print(table)


def master_key_flow():
    action = questionary.select("Master keys:", choices=["Create new", "Import existing", "Back"]).ask()
    if action == "Create new":
        version = int(questionary.text("Wallet version:", default="1").ask())
        production = questionary.confirm("Production key?", default=False).ask()
        env = "production" if production else "dev"
        name = questionary.text("Filename:", default=f"master_v{version}.json").ask()
        out = os.path.join(KEYS_ROOT, env, name)
        gen = MasterKeyGenerator(production=production)
        gen.save_master_key(gen.generate_master_key(version), out)
        console.print(f"[green]Created {out}[/green]")
    elif action == "Import existing":
        env = questionary.select("Store under:", choices=["dev", "production"]).ask()
        name = questionary.text("Filename:", default="imported_key.json").ask()
        out = os.path.join(KEYS_ROOT, env, name)
        version = int(questionary.text("Wallet version:", default="1").ask())
        importer = MasterKeyImporter()
        kind = questionary.select("Import from:", choices=["Base58 private key", "JSON file"]).ask()
        if kind == "Base58 private key":
            data = importer.import_from_base58(questionary.text("Paste base58 private key:").ask(), version)
        else:
            data = importer.import_from_json(questionary.text("Path to JSON key file:").ask(), version)
        importer.save_imported_key(data, out)
        console.print(f"[green]Imported → {out}[/green]")


def validate_url_flow():
    url = questionary.text("Paste the full s.plings.io URL:").ask()
    mk_path = choose_master_key()
    if not mk_path:
        return
    r = core.validate_url_against_master(url, load_master_key(mk_path))
    status = "[green]✅ MATCH[/green]" if r["match"] else "[red]❌ MISMATCH[/red]"
    console.print(f"\n{status}")
    console.print(f"path: {r['decimal_path']}  wallet_v: {r['wallet_version']}  short_code: {r['short_code']}")
    console.print(f"url i=:  {r['url_instance_key']}")
    console.print(f"derived: {r['derived_instance_key']}")
    if r["cp_in_url"]:
        console.print(f"cp in url: {r['cp_in_url']}")


def main():
    console.print("[bold cyan]Plings Keygen[/bold cyan]")
    while True:
        action = questionary.select(
            "Main menu:",
            choices=["Generate keys / tags", "Master keys", "Validate a URL", "Exit"],
        ).ask()
        if action is None or action == "Exit":
            break
        try:
            if action == "Generate keys / tags":
                generate_flow()
            elif action == "Master keys":
                master_key_flow()
            elif action == "Validate a URL":
                validate_url_flow()
        except (KeyboardInterrupt, EOFError):
            break
        except Exception as e:  # never crash the menu on one operation
            console.print(f"[red]Error: {e}[/red]")


if __name__ == "__main__":
    main()
  • Step 2: Sanity-check the reused class APIs

The code above relies on these verified signatures — confirm they still exist before running:

  • MasterKeyImporter.import_from_base58(base58_key, wallet_version=1), import_from_json(file_path, wallet_version=None), save_imported_key(master_key_data, output_file) (scripts/import_master_key.py)
  • MasterKeyGenerator(production).generate_master_key(version) + save_master_key(data, out) (scripts/generate_master_key.py)
  • HDPathKeyDerivation(master, class_pointer=cp).derive_batch_from_instance(start_path, qty) + .validate_derived_keys() + .crypto_utils.encode_number_base58() (scripts/derive_path_keys.py)

  • Step 3: Manual check — generate

Run: poetry run python scripts/plings_keygen.py Choose Generate → select keys/dev/dev_key.json → New path → label test_menu, path 3.1.2.1, start 1, qty 3, no cp → confirm. Expected: writes keys/dev/test_menu_1-3st.csv, prints a rich table, and keys/.keygen_history.json now has a test_menu entry. Delete the test CSV afterward.

  • Step 4: Manual check — validate URL

Run the program → Validate a URL → paste https://s.plings.io?t=q&i=CCuejfEAmFGKaTqhjgXjxQepiY9CZyEB463wtA4izNb9&p=4.2.3.2.2 → select keys/dev/dev_key.json. Expected: ✅ MATCH, path 3.1.2.1.1, wallet_v 3.

  • Step 5: Manual check — continue from history

Run the program → GenerateContinue from historytest_menu. Expected: start instance pre-filled as 4.

  • Step 6: Commit
git add scripts/plings_keygen.py
git commit -m "feat(keygen): interactive questionary/rich menu entrypoint"

Task 9: Docs + final quality control

Files:

  • Modify: scripts/README.md

  • Step 1: Document the new tool

Add a short section to scripts/README.md describing plings_keygen.py: purpose (menu-driven alternative to derive_path_keys.py), how to run (poetry run python scripts/plings_keygen.py), the three menu functions, the keys/<env>/<label>_<start>-<end>st.csv output convention, and the keys/.keygen_history.json state file. Note that questionary/rich are dev-only deps.

  • Step 2: Run the full test suite

Run: poetry run pytest tests/test_keygen_core.py tests/test_output_utils.py -v Expected: all PASS (including the determinism regression).

  • Step 3: Quality control

Per CLAUDE.md, run the quality-control-enforcer agent on the new code (scripts/plings_keygen.py, scripts/plings_keygen_core.py, scripts/utils/output_utils.py) and address findings.

  • Step 4: Commit
git add scripts/README.md
git commit -m "docs(scripts): document plings_keygen interactive menu"

FINAL: Use quality-control-enforcer agent to validate implementation