feat(cli): add pm3py client — autocompleting console over the stock proxmark3 client

Wraps the standard Proxmark3 C client (so it works with any Proxmark/firmware) in a prompt_toolkit REPL: full command-tree completion sourced from the client's commands.json, option-flag completion, Tab-to-descend, a press-right help toggle, and the client's own coloured output.

Two swappable backends behind ClientDriver: in-process SWIG (_pm3) by default, a pexpect-around-the-binary fallback otherwise. Both auto-detect /dev/ttyACM* and discover the checkout via its doc/commands.json marker (walk-up + side-by-side), so no path is hardcoded.

`pm3py client --pack OUT` spins off a self-contained drop-in — a readable .py, or a .pyz with prompt_toolkit/pexpect bundled — amalgamated from the package (the tested source of truth) so it can't drift. Adds pexpect to deps; hardware-free tests in tests/test_clientcli.py.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
michael
2026-07-15 21:05:53 -07:00
parent 925d2c92f0
commit 4f66a93de9
13 changed files with 1622 additions and 2 deletions

55
tests/fixtures/commands_min.json vendored Normal file
View File

@@ -0,0 +1,55 @@
{
"metadata": {
"commands_extracted": 5,
"extracted_by": "test-fixture",
"extracted_on": "2026-01-01T00:00:00+00:00"
},
"commands": {
"clear": {
"command": "clear",
"description": "Clear screen",
"notes": [],
"offline": true,
"options": ["-h, --help This help"],
"usage": "clear [-h]"
},
"hf 14a info": {
"command": "hf 14a info",
"description": "Get info about an ISO14443-A tag",
"notes": ["hf 14a info -v"],
"offline": false,
"options": ["-h, --help This help", "-v, --verbose verbose output"],
"usage": "hf 14a info [-hv]"
},
"hf mf rdbl": {
"command": "hf mf rdbl",
"description": "Read MIFARE Classic block",
"notes": ["hf mf rdbl --blk 0", "hf mf rdbl --blk 0 -k A0A1A2A3A4A5"],
"offline": false,
"options": [
"-h, --help This help",
"--blk <dec> block number",
"-a input key type is key A (def)",
"-k, --key <hex> key, 6 hex bytes",
"-v, --verbose verbose output"
],
"usage": "hf mf rdbl [-habv] --blk <dec> [-k <hex>]"
},
"hf mf wrbl": {
"command": "hf mf wrbl",
"description": "Write MIFARE Classic block",
"notes": [],
"offline": false,
"options": ["-h, --help This help", "--blk <dec> block number", "-k, --key <hex> key, 6 hex bytes"],
"usage": "hf mf wrbl [-h] --blk <dec> [-k <hex>]"
},
"lf search": {
"command": "lf search",
"description": "Read and identify a LF tag",
"notes": ["lf search -1"],
"offline": false,
"options": ["-h, --help This help", "-1 Use data from Graphbuffer (offline mode)", "-c Continue searching"],
"usage": "lf search [-1ch]"
}
}
}

549
tests/test_clientcli.py Normal file
View File

@@ -0,0 +1,549 @@
"""clientcli — command tree, completer, hints, help-toggle, driver helpers, dispatch. Hardware-free
(the pexpect path is not driven here; a FakeDriver stands in for the client)."""
import json
import os
import re
from pathlib import Path
from types import SimpleNamespace
import pytest
from prompt_toolkit.document import Document
from prompt_toolkit.key_binding import KeyBindings
from pm3py.cli.main import build_parser, main
from pm3py.cli.clientcli.tree import CommandTree, load_command_tree, parse_option
from pm3py.cli.clientcli.session import ClientSession
from pm3py.cli.clientcli.completer import ClientCompleter
from pm3py.cli.clientcli.hints import one_line_hint, help_panel, panel_for, resolve_target
from pm3py.cli.clientcli.keys import open_help, close_help, install_key_bindings, apply_tab
from pm3py.cli.clientcli.driver import (
strip_ansi, _extract_output, PROMPT_RE, resolve_client,
make_driver, SwigDriver, PexpectDriver, DriverError,
)
from pm3py.cli.clientcli.app import dispatch
FIXTURE = Path(__file__).parent / "fixtures" / "commands_min.json"
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
def _plain(s: str) -> str:
return _ANSI.sub("", s)
def _tree() -> CommandTree:
return CommandTree(json.loads(FIXTURE.read_text())["commands"])
class FakeDriver:
"""Records executed lines and returns canned output; stands in for PexpectDriver."""
def __init__(self, output="", prompt="offline"):
self.prompt = prompt
self.lines = []
self._output = output
self.closed = False
def start(self):
pass
def execute(self, line):
self.lines.append(line)
return self._output
def close(self):
self.closed = True
def _session(driver=None, tree=None):
return ClientSession(driver or FakeDriver(), tree or _tree())
def _complete(session, text):
return list(ClientCompleter(session).get_completions(Document(text), None))
def _texts(comps):
return [c.text for c in comps]
# --------------------------------------------------------------------------- tree
class TestTree:
def test_top_level_mixes_leaves_and_categories(self):
toks = [n.token for n in _tree().children_of([])]
assert toks == ["clear", "hf", "lf"]
def test_interior_children_synthesised(self):
assert [n.token for n in _tree().children_of(["hf"])] == ["14a", "mf"]
assert [n.token for n in _tree().children_of(["hf", "mf"])] == ["rdbl", "wrbl"]
def test_entry_of_leaf(self):
e = _tree().entry_of(["hf", "mf", "rdbl"])
assert e is not None and e["description"] == "Read MIFARE Classic block"
def test_interior_has_no_entry(self):
node = _tree().node_at(["hf", "mf"])
assert node is not None and node.entry is None and not node.is_leaf
def test_leaf_flags_as_leaf(self):
node = _tree().node_at(["clear"])
assert node.is_leaf and node.children == {}
def test_unknown_path(self):
assert _tree().node_at(["hf", "zz"]) is None
assert _tree().children_of(["hf", "zz"]) == []
class TestParseOption:
def test_short_and_long_with_meta(self):
assert parse_option("-k, --key <hex> key, 6 hex bytes") == (
["-k", "--key"], "<hex>", "key, 6 hex bytes")
def test_long_with_meta(self):
assert parse_option("--blk <dec> block number") == (["--blk"], "<dec>", "block number")
def test_short_no_meta(self):
assert parse_option("-a input key type is key A (def)") == (
["-a"], "", "input key type is key A (def)")
def test_numeric_short_flag(self):
assert parse_option("-1 Use data from Graphbuffer (offline mode)") == (
["-1"], "", "Use data from Graphbuffer (offline mode)")
def test_help(self):
assert parse_option("-h, --help This help") == (["-h", "--help"], "", "This help")
# --------------------------------------------------------------------------- completer
class TestCompleter:
def test_top_level(self):
assert _texts(_complete(_session(), "")) == ["clear ", "hf ", "lf "]
def test_partial_top_level(self):
assert _texts(_complete(_session(), "h")) == ["hf "]
def test_subcommand_partial(self):
assert _texts(_complete(_session(), "hf m")) == ["mf "]
def test_subcommands_after_space(self):
assert _texts(_complete(_session(), "hf mf ")) == ["rdbl ", "wrbl "]
def test_subcommand_completion_has_trailing_space(self):
# the trailing space makes accepting a completion commit + descend a level
assert all(t.endswith(" ") for t in _texts(_complete(_session(), "hf mf ")))
def test_accepting_subcommand_descends(self):
# "hf " → accept "14a " → "hf 14a " must offer 14a's children, not re-offer 14a
assert "14a " in _texts(_complete(_session(), "hf "))
assert _texts(_complete(_session(), "hf 14a ")) == ["info "]
def test_subcommand_display_omits_trailing_space(self):
disp = {c.display_text: c.text for c in _complete(_session(), "hf mf ")}
assert "rdbl" in disp and disp["rdbl"] == "rdbl "
def test_subcommand_meta_is_description(self):
comps = {c.text: c.display_meta_text for c in _complete(_session(), "hf mf ")}
assert comps["rdbl "] == "Read MIFARE Classic block"
def test_interior_meta_is_count(self):
comps = {c.text: c.display_meta_text for c in _complete(_session(), "hf ")}
assert comps["mf "] == "2 subcommands"
def test_option_flags_on_leaf(self):
texts = _texts(_complete(_session(), "hf mf rdbl --"))
assert "--blk" in texts and "--key" in texts and "--verbose" in texts
def test_option_meta_is_description(self):
comps = {c.text: c.display_meta_text for c in _complete(_session(), "hf mf rdbl --")}
assert comps["--blk"] == "block number"
def test_help_flag_excluded(self):
assert "--help" not in _texts(_complete(_session(), "hf mf rdbl --"))
def test_leaf_bare_lists_all_options(self):
texts = _texts(_complete(_session(), "hf mf rdbl "))
assert "--blk" in texts and "-a" in texts and "-k" in texts
def test_argument_value_slot_is_empty(self):
assert _complete(_session(), "hf mf rdbl -k ") == []
# --------------------------------------------------------------------------- hints
class TestHints:
def test_leaf_one_line(self):
h = one_line_hint(_tree(), "hf mf rdbl")
assert "Read MIFARE Classic block" in h and "usage" not in h.lower()
assert "hf mf rdbl [-habv]" in h
def test_interior_one_line(self):
assert "2 subcommands" in one_line_hint(_tree(), "hf mf")
def test_partial_word_resolves_prefix(self):
assert "2 subcommands" in one_line_hint(_tree(), "hf mf rd")
def test_unknown_is_none(self):
assert one_line_hint(_tree(), "zz") is None
assert one_line_hint(_tree(), "") is None
def test_help_panel_multiline(self):
panel = _plain(help_panel(_tree().entry_of(["hf", "mf", "rdbl"])))
assert "hf mf rdbl" in panel
assert "usage:" in panel
assert "--blk" in panel and "--key <hex>" in panel
assert "This help" not in panel # -h/--help is skipped
assert "hf mf rdbl --blk 0" in panel # a note
def test_panel_for_leaf(self):
node = _tree().node_at(["hf", "mf", "rdbl"])
assert panel_for(node) == help_panel(node.entry)
def test_panel_for_interior(self):
panel = _plain(panel_for(_tree().node_at(["hf", "mf"])))
assert "2 subcommands" in panel and "rdbl" in panel and "wrbl" in panel
def test_resolve_target_subcommand(self):
cur = SimpleNamespace(text="rdbl")
node = resolve_target(_tree(), "hf mf ", cur)
assert node is not None and node.token == "rdbl" and node.is_leaf
def test_resolve_target_flag_points_at_leaf(self):
cur = SimpleNamespace(text="--blk")
node = resolve_target(_tree(), "hf mf rdbl --", cur)
assert node is not None and node.token == "rdbl"
def test_resolve_target_interior(self):
cur = SimpleNamespace(text="mf")
node = resolve_target(_tree(), "hf ", cur)
assert node is not None and node.token == "mf" and not node.is_leaf
# --------------------------------------------------------------------------- keys
class TestKeys:
def test_open_help_requires_selection(self):
s = _session()
assert open_help(s, has_selection=False) is False
assert s.help_open is False
assert open_help(s, has_selection=True) is True
assert s.help_open is True
def test_close_help(self):
s = _session()
s.help_open = True
assert close_help(s) is False
assert s.help_open is False
def test_install_does_not_raise(self):
install_key_bindings(KeyBindings(), _session())
def _buffer_with_menu(self, text):
from prompt_toolkit.buffer import Buffer, CompletionState
from prompt_toolkit.completion import CompleteEvent
from prompt_toolkit.document import Document
buff = Buffer()
buff.set_document(Document(text, len(text)))
comps = list(ClientCompleter(_session()).get_completions(Document(text, len(text)),
CompleteEvent()))
buff.complete_state = CompletionState(original_document=buff.document,
completions=comps, complete_index=None)
return buff
def test_tab_commits_first_completion(self):
# with nothing highlighted, Tab commits the FIRST completion (+ its trailing space)
buff = self._buffer_with_menu("hf ")
apply_tab(buff)
assert buff.text == "hf 14a " # descends: next Tab would show 14a's kids
def test_tab_commits_highlighted_completion(self):
buff = self._buffer_with_menu("hf ")
buff.complete_state.complete_index = 1 # highlight the 2nd (mf)
apply_tab(buff)
assert buff.text == "hf mf "
def test_tab_completes_partial(self):
buff = self._buffer_with_menu("hf m")
apply_tab(buff)
assert buff.text == "hf mf "
# --------------------------------------------------------------------------- driver helpers
class TestDriverHelpers:
def test_strip_ansi(self):
assert strip_ansi("\x1b[1;32musb\x1b[0m") == "usb"
def test_prompt_re_variants(self):
cases = {
"[usb] pm3 --> ": "usb",
"[offline] pm3 --> ": "offline",
"[usb|tcp] pm3 --> ": "usb|tcp",
"[fpc|script] pm3 --> ": "fpc|script",
}
for text, label in cases.items():
m = PROMPT_RE.search(text)
assert m is not None and m.group(1) == label
def test_prompt_re_captures_through_ansi(self):
colored = "[\x1b[1;32musb\x1b[0m] pm3 --> "
m = PROMPT_RE.search(colored)
assert m is not None and strip_ansi(m.group(1)) == "usb"
def test_extract_output_drops_echo(self):
raw = "hf 14a info\r\n[+] UID: 04 11 22 33\r\n"
assert _extract_output(raw, "hf 14a info") == "[+] UID: 04 11 22 33"
def test_extract_output_preserves_color(self):
raw = "hw version\r\n\x1b[32m[+] ok\x1b[0m\r\n"
assert _extract_output(raw, "hw version") == "\x1b[32m[+] ok\x1b[0m"
def test_extract_output_no_echo(self):
assert _extract_output("just output\r\n", "cmd") == "just output"
def test_extract_output_strips_bracketed_paste(self):
raw = "hw status\x1b[?2004l\r\n\x1b[?2004h[+] ok\r\n"
assert _extract_output(raw, "hw status") == "[+] ok"
def test_resolve_client_explicit_path_verbatim(self):
assert resolve_client("/opt/pm3/proxmark3") == "/opt/pm3/proxmark3"
assert resolve_client("./proxmark3") == "./proxmark3"
def test_resolve_client_named_binary_not_substituted(self):
# an explicitly-named client that isn't found is returned unchanged (never swapped for a
# fallback), so the spawn fails clearly instead of running a different binary
assert resolve_client("definitely-not-a-real-pm3-binary") == "definitely-not-a-real-pm3-binary"
# --------------------------------------------------------------------------- dispatch
class _FakePm3:
def __init__(self, port):
self.name = port
self.calls = []
def console(self, cmd, capture=True, quiet=True):
self.calls.append((cmd, capture, quiet))
return 0
@property
def grabbed_output(self):
return ""
class _FakePm3Module:
pm3 = _FakePm3
class TestSwigDriver:
def test_execute_prints_via_client_not_grab(self):
# the client prints its own coloured output (capture=False, quiet=False); execute returns ""
d = SwigDriver(port="/dev/ttyACM0", pm3_module=_FakePm3Module())
d.start()
assert d.backend == "swig" and d.prompt == "usb"
assert d.execute("hw version") == ""
assert d._p.calls == [("hw version", False, False)]
def test_execute_before_start(self):
d = SwigDriver(port="/dev/ttyACM0", pm3_module=_FakePm3Module())
with pytest.raises(DriverError):
d.execute("hw status")
def test_no_device_errors_without_calling_pm3(self, monkeypatch):
import pm3py.cli.clientcli.driver as drv
monkeypatch.setattr(drv, "_autodetect_port", lambda: None)
d = SwigDriver(port=None, pm3_module=_FakePm3Module())
with pytest.raises(DriverError, match="no serial device"):
d.start()
assert d._p is None # never constructed → no exit() risk
class TestPexpectPort:
def test_autodetects_port_when_none_given(self, monkeypatch):
# the stock binary needs a port arg (no auto-detect); the driver must resolve one itself
import pexpect
import pm3py.cli.clientcli.driver as drv
class _FakeChild:
before = "[usb] "
def expect(self, pat):
return 0
captured = {}
def fake_spawn(command, args, **kw):
captured["command"], captured["args"] = command, args
return _FakeChild()
monkeypatch.setattr(pexpect, "spawn", fake_spawn)
monkeypatch.setattr(drv, "_autodetect_port", lambda: "/dev/ttyACM9")
d = drv.PexpectDriver(port=None, client="proxmark3")
d.start()
assert captured["args"] == ["/dev/ttyACM9"] # resolved, not empty (would be offline)
assert d.prompt == "usb" # label parsed from the prompt tail
def test_explicit_port_used_verbatim(self, monkeypatch):
import pexpect
import pm3py.cli.clientcli.driver as drv
class _FakeChild:
before = "[usb] "
def expect(self, pat):
return 0
captured = {}
def fake_spawn(command, args, **kw):
captured["args"] = args
return _FakeChild()
monkeypatch.setattr(pexpect, "spawn", fake_spawn)
monkeypatch.setattr(drv, "_autodetect_port", lambda: "/dev/ttyACM9")
drv.PexpectDriver(port="/dev/ttyACM0").start()
assert captured["args"] == ["/dev/ttyACM0"] # explicit --port wins over auto-detect
class TestMakeDriver:
def test_explicit_pexpect(self):
assert isinstance(make_driver(driver="pexpect"), PexpectDriver)
def test_auto_uses_swig_when_available(self, monkeypatch):
import pm3py.cli.clientcli.driver as drv
monkeypatch.setattr(drv, "_load_pm3_module", lambda: _FakePm3Module())
d = make_driver(port="/dev/ttyACM0", driver="auto")
assert isinstance(d, SwigDriver)
def test_auto_falls_back_to_pexpect(self, monkeypatch):
import pm3py.cli.clientcli.driver as drv
def boom():
raise ImportError("no _pm3")
monkeypatch.setattr(drv, "_load_pm3_module", boom)
assert isinstance(make_driver(driver="auto"), PexpectDriver)
def test_swig_explicit_unavailable_errors(self, monkeypatch):
import pm3py.cli.clientcli.driver as drv
def boom():
raise ImportError("no _pm3")
monkeypatch.setattr(drv, "_load_pm3_module", boom)
with pytest.raises(DriverError, match="_pm3"):
make_driver(driver="swig")
class TestDispatch:
def test_runs_command(self):
drv = FakeDriver(output="[+] done")
out = []
assert dispatch(_session(drv), "hf 14a info", out.append) is True
assert drv.lines == ["hf 14a info"]
assert out == ["[+] done"]
def test_quit_exits(self):
drv = FakeDriver()
assert dispatch(_session(drv), "quit", lambda *_: None) is False
assert drv.lines == []
def test_empty_output_not_printed(self):
out = []
dispatch(_session(FakeDriver(output="")), "clear", out.append)
assert out == []
def test_driver_error_ends_loop(self):
from pm3py.cli.clientcli.driver import DriverError
class Boom(FakeDriver):
def execute(self, line):
raise DriverError("client exited")
out = []
assert dispatch(_session(Boom()), "hw status", out.append) is False
assert any("client exited" in m for m in out)
# --------------------------------------------------------------------------- session + argparse + loader
class TestSession:
def test_breadcrumb_offline(self):
s = _session(FakeDriver(prompt="offline"))
assert s.breadcrumb() == "[client / offline] pm3py -->"
assert _plain(s.colored_breadcrumb()) == s.breadcrumb()
def test_breadcrumb_usb(self):
assert _session(FakeDriver(prompt="usb")).breadcrumb() == "[client / usb] pm3py -->"
class TestArgparse:
def test_client_subcommand(self):
args = build_parser().parse_args(["client", "--port", "X", "--client", "pm3"])
assert args.command == "client" and args.port == "X" and args.client == "pm3"
assert args.driver == "auto" # SWIG-first default
def test_client_driver_choice(self):
args = build_parser().parse_args(["client", "--driver", "pexpect"])
assert args.driver == "pexpect"
def test_no_subcommand_prints_help(self):
assert main([]) == 1
class TestLoader:
def test_loads_fixture(self):
tree, meta = load_command_tree(str(FIXTURE))
assert meta["commands_extracted"] == 5
assert tree.entry_of(["hf", "mf", "rdbl"])["command"] == "hf mf rdbl"
def test_missing_raises(self):
with pytest.raises(FileNotFoundError):
load_command_tree("/nonexistent/commands.json")
class TestRepoDiscovery:
def _make_repo(self, base):
repo = base / "proxmark3"
(repo / "doc").mkdir(parents=True)
(repo / "doc" / "commands.json").write_text('{"commands": {}}')
return repo
def test_via_env(self, tmp_path, monkeypatch):
from pm3py.cli.clientcli.tree import find_repo_root
repo = self._make_repo(tmp_path)
monkeypatch.setenv("PM3_REPO", str(repo))
assert find_repo_root() == repo.resolve()
def test_walks_up_from_subdir(self, tmp_path, monkeypatch):
from pm3py.cli.clientcli.tree import find_repo_root
monkeypatch.delenv("PM3_REPO", raising=False)
monkeypatch.delenv("PM3_ROOT", raising=False)
repo = self._make_repo(tmp_path)
deep = repo / "client" / "src"
deep.mkdir(parents=True)
assert find_repo_root(start=deep) == repo.resolve()
def test_finds_sibling_checkout(self, tmp_path, monkeypatch):
# dev layout: run from a sibling dir, discover ../proxmark3 (keeps `pm3py client` working)
from pm3py.cli.clientcli.tree import find_repo_root
monkeypatch.delenv("PM3_REPO", raising=False)
monkeypatch.delenv("PM3_ROOT", raising=False)
repo = self._make_repo(tmp_path)
sibling = tmp_path / "pm3py"
sibling.mkdir()
assert find_repo_root(start=sibling) == repo.resolve()
class TestPacker:
def test_amalgamate_is_self_contained(self):
from pm3py.cli.clientcli.pack import amalgamate
code = amalgamate()
compile(code, "pm3cli", "exec") # valid Python
assert code.count("from __future__") == 1 # exactly one, at the top
for ln in code.splitlines():
s = ln.lstrip()
assert not s.startswith("from ."), ln # no intra-package imports survive
assert not s.startswith(("from pm3py", "import pm3py")), ln
assert "def main(" in code and 'if __name__ == "__main__"' in code
assert "def run(" in code # the REPL entrypoint is present
def test_pack_writes_executable_py(self, tmp_path):
from pm3py.cli.clientcli.pack import pack
out = tmp_path / "pm3cli.py"
pack(str(out))
assert out.is_file() and os.access(out, os.X_OK)
compile(out.read_text(), str(out), "exec")