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

View File

@@ -0,0 +1,8 @@
"""``pm3py client`` — an interactive prompt_toolkit REPL that wraps the stock Proxmark3 C client.
Unlike :mod:`pm3py.cli.rawcli` (which speaks pm3py's pure-Python protocol stack), this drives the
real ``proxmark3`` binary over pexpect, so it works with any Proxmark3 / firmware. It offers
autocompletion and help hints for the full pm3 command tree — sourced from the client's
``commands.json`` help dump — plus a right-arrow help toggle: highlight a completion, press RIGHT to
expand its full help, LEFT / ESCAPE to back out.
"""

105
pm3py/cli/clientcli/app.py Normal file
View File

@@ -0,0 +1,105 @@
"""clientcli interactive loop — a prompt_toolkit REPL that drives the stock ``proxmark3`` client
with command-tree autocompletion, live hints, and a right-arrow help toggle. Mirrors the
:mod:`pm3py.cli.rawcli.app` skeleton; the testable logic lives in the sibling modules."""
from __future__ import annotations
import sys
from .tree import load_command_tree
from .driver import make_driver, DriverError
from .session import ClientSession
from .completer import ClientCompleter
from .keys import install_key_bindings
from .hints import one_line_hint, resolve_target, panel_for
_DEFAULT_HINT = "type a pm3 command · Tab to complete · → toggles help on a selection · 'quit'"
def dispatch(state, line, out=print) -> bool:
"""Handle one input line. Returns False to exit. ``quit``/``exit``/``q`` leave the wrapper;
everything else is sent to the client and its output printed. A driver failure ends the loop."""
cmd = line.strip()
if cmd.lower() in ("quit", "exit", "q"):
return False
try:
output = state.driver.execute(cmd)
except DriverError as exc:
out(f"client error: {exc}")
return False
if output:
out(output)
return True
def run(port=None, commands=None, client="proxmark3", driver="auto") -> int:
from prompt_toolkit import PromptSession, print_formatted_text
from prompt_toolkit.application import get_app
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.formatted_text import ANSI
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.patch_stdout import patch_stdout
from prompt_toolkit.shortcuts import CompleteStyle
def out(text):
# render the ANSI our client/formatters emit (colour on a TTY, stripped when piped)
print_formatted_text(ANSI(str(text)))
try:
tree, meta = load_command_tree(commands)
except FileNotFoundError as exc:
print(f"pm3py client: {exc}", file=sys.stderr)
return 2
dev = make_driver(port=port, client=client, driver=driver)
try:
dev.start()
except DriverError as exc:
print(f"pm3py client: {exc}", file=sys.stderr)
return 1
state = ClientSession(dev, tree)
def bottom_toolbar():
# live relevance for the current input; the expanded help panel when the toggle is open
try:
buf = get_app().current_buffer
cur = buf.complete_state.current_completion if buf.complete_state else None
if state.help_open and cur is not None:
panel = panel_for(resolve_target(tree, buf.text, cur))
if panel:
return ANSI(panel)
hint = one_line_hint(tree, buf.text)
except Exception:
hint = None
return ANSI(hint) if hint else _DEFAULT_HINT
kb = KeyBindings()
install_key_bindings(kb, state)
session = PromptSession(
completer=ClientCompleter(state),
complete_while_typing=True, # ipython-style live menu
complete_style=CompleteStyle.COLUMN, # single column keeps ←/→ free for the toggle
auto_suggest=AutoSuggestFromHistory(), # inline suggestion hints
bottom_toolbar=bottom_toolbar, # live relevance / help panel
key_bindings=kb,
)
def message():
return ANSI(state.colored_breadcrumb() + " ")
n = meta.get("commands_extracted", "?")
out(f"pm3py client — {n} commands · {dev.backend} backend · → help · 'quit' to exit")
with patch_stdout():
while True:
state.help_open = False # each new line starts with help collapsed
try:
line = session.prompt(message)
except (EOFError, KeyboardInterrupt):
break
if not line.strip():
continue
if not dispatch(state, line, out):
break
dev.close()
out("bye")
return 0

View File

@@ -0,0 +1,74 @@
"""Completion for ``pm3py client``: walk the command trie by cursor position, offering subcommands
at each level and option flags on a leaf. Models :class:`pm3py.cli.rawcli.completer.RawCompleter`
— route by position, yield ``Completion`` with ``display_meta`` as the tooltip."""
from __future__ import annotations
from prompt_toolkit.completion import Completer, Completion
from .tree import parse_option
def _skip(flags) -> bool:
return "--help" in flags or "-h" in flags
class ClientCompleter(Completer):
def __init__(self, session):
self._session = session
def get_completions(self, document, complete_event):
tree = self._session.tree
text = document.text_before_cursor
tokens = text.split()
at_word = bool(tokens) and not text.endswith(" ")
partial = tokens[-1] if at_word else ""
path = tokens[:-1] if at_word else tokens
node = tree.node_at(path)
if node is not None:
# inside the tree: a leaf with no more subcommands (or a flag being typed) → options;
# otherwise the child subcommands at this level.
if node.is_leaf and (partial.startswith("-") or not node.children):
yield from self._options(node.entry, partial)
else:
yield from self._subcommands(node, partial)
return
# a path token fell off the tree — we're in a leaf's argument territory; offer flags only.
leaf = self._deepest_leaf(tree, path)
if leaf is not None and partial.startswith("-"):
yield from self._options(leaf, partial)
def _subcommands(self, node, partial):
for tok in sorted(node.children):
if not tok.startswith(partial):
continue
child = node.children[tok]
if child.is_leaf:
meta = child.entry.get("description", "")
else:
meta = f"{len(child.children)} subcommands"
# insert a trailing space so accepting the completion commits the token and immediately
# re-triggers completion at the next level ("hf " + <tab> → "hf 14a " → 14a's children)
yield Completion(tok + " ", start_position=-len(partial), display=tok, display_meta=meta)
def _options(self, entry, partial):
for opt in entry.get("options", []):
flags, meta, desc = parse_option(opt)
if not flags or _skip(flags):
continue
match = next((f for f in flags if f.startswith(partial)), None)
if match is None:
continue
disp = (", ".join(flags) + (f" {meta}" if meta else "")).strip()
yield Completion(match, start_position=-len(partial), display=disp, display_meta=desc)
def _deepest_leaf(self, tree, path):
node, leaf = tree.root, None
for tok in path:
node = node.children.get(tok)
if node is None:
break
if node.is_leaf:
leaf = node.entry
return leaf

View File

@@ -0,0 +1,281 @@
"""Drivers for the stock Proxmark3 C client.
``pm3py client`` does not reimplement the wire protocol — it drives the real client, so it works
with any Proxmark3 / firmware. Two interchangeable backends behind :class:`ClientDriver`:
- :class:`SwigDriver` (default) — the in-process ``_pm3`` SWIG bindings: ``pm3.pm3(port).console()``
+ ``grabbed_output``. Cleaner (no prompt-matching) but needs the built ``_pm3`` extension and a
connected device (the C ``pm3_open`` calls ``exit()`` on a real port with no responding PM3).
- :class:`PexpectDriver` — spawns the ``proxmark3`` binary and reads output up to the next
``pm3 -->`` prompt. Zero build; the fallback when ``_pm3`` isn't importable. The client's ANSI
colour is preserved (we ANSI-strip only for prompt/echo matching), and we never mutate ``prefs``.
:func:`make_driver` picks SWIG when ``_pm3`` is importable, else pexpect.
"""
from __future__ import annotations
import glob
import importlib.machinery
import importlib.util
import os
import re
import shutil
import sys
from pathlib import Path
from .tree import find_repo_root
#: full prompt matcher for clean text: ``"[<dev><net><ctx>] pm3 --> "``. Used to parse a label the
#: caller already has in hand (and in tests); pexpect matches on the tail instead — see below.
PROMPT_RE = re.compile(r"\[([^\]]*)\] pm3 -->\s*$")
#: what pexpect waits for. The ``pm3 --> `` tail is emitted uncoloured, so this matches regardless
#: of colour or the bracketed-paste sequences readline emits around the ``[...]`` label — matching
#: the label directly would let a stray ``[`` (from ``\x1b[?2004h``) capture the wrong bracket.
_EXPECT_RE = re.compile(r"pm3 -->\s*$")
#: the ``[label]`` at the end of the ANSI-stripped text just before the tail.
_LABEL_RE = re.compile(r"\[([^\]]*)\]\s*$")
_ANSI = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
#: every CSI sequence except SGR colour (final byte ``m``) — strips readline's bracketed-paste /
#: cursor / erase controls from passed-through output while keeping the client's colour intact.
_NONSGR = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-ln-~]")
#: fallback locations searched when a bare client name isn't on PATH (typical fork build layouts).
_CLIENT_FALLBACKS = (
"~/proxmark3/client/proxmark3",
"/usr/local/bin/proxmark3",
"/usr/bin/proxmark3",
)
def resolve_client(client: str) -> str:
"""Resolve the client binary. An explicit path is used verbatim; a bare name is looked up on
PATH. Only the *default* ``proxmark3`` name falls back to known fork build locations — an
explicitly-named client that isn't found is returned unchanged, so the spawn fails clearly
rather than silently running a different binary. """
if os.sep in client or (os.altsep and os.altsep in client):
return client
found = shutil.which(client)
if found:
return found
if client == "proxmark3": # auto-locate only the default binary
candidates = []
root = find_repo_root()
if root is not None: # the checkout this file was dropped beside
candidates.append(root / "client" / "proxmark3")
candidates += [Path(c).expanduser() for c in _CLIENT_FALLBACKS]
for p in candidates:
if p.is_file() and os.access(p, os.X_OK):
return str(p)
return client
class DriverError(RuntimeError):
"""The underlying client could not be launched, timed out, or exited."""
def strip_ansi(text: str) -> str:
return _ANSI.sub("", text or "")
def _scrubbed_env() -> dict:
"""A minimal environment for spawning the client. Snap / GTK / ``LD_*`` vars leaking in from a
sandboxed terminal (e.g. VSCode's snap) make the Qt-linked client crash on a libpthread symbol
clash, so we hand it only what it needs."""
env = {"TERM": os.environ.get("TERM", "xterm")}
for key in ("HOME", "PATH", "USER", "LANG", "LC_ALL"):
if key in os.environ:
env[key] = os.environ[key]
env.setdefault("PATH", "/usr/local/bin:/usr/bin:/bin")
return env
def _extract_output(raw: str, sent: str) -> str:
"""A command's output: ``child.before`` minus the echoed command line and framing blank lines.
The client's ANSI colour is preserved; readline's non-colour control sequences (bracketed
paste, cursor moves) are stripped, and the echo comparison is fully ANSI-stripped."""
lines = _NONSGR.sub("", raw or "").replace("\r", "").split("\n")
while lines and not strip_ansi(lines[0]).strip():
lines.pop(0)
if lines and strip_ansi(lines[0]).strip() == sent.strip():
lines.pop(0)
return "\n".join(lines).strip("\n")
class ClientDriver:
"""Driver interface. ``prompt`` is the last-seen context label (``usb`` / ``offline`` / …);
``backend`` names the mechanism (``swig`` / ``pexpect``)."""
prompt = "offline"
backend = "?"
def start(self) -> None: ...
def execute(self, line: str) -> str: ...
def close(self) -> None: ...
class PexpectDriver(ClientDriver):
backend = "pexpect"
def __init__(self, port=None, client="proxmark3", timeout=30):
self._port = port
self._client = resolve_client(client)
self._timeout = timeout
self._child = None
self.prompt = "offline"
def start(self) -> None:
import pexpect
# the stock binary does NOT auto-detect a port (that's the `pm3` wrapper's job) — without
# one it drops to offline mode, so resolve a port ourselves when none was given
port = self._port or _autodetect_port()
args = [port] if port else []
try:
self._child = pexpect.spawn(
self._client, args, env=_scrubbed_env(),
encoding="utf-8", codec_errors="replace", timeout=self._timeout,
)
self._child.expect(_EXPECT_RE)
except pexpect.ExceptionPexpect as exc:
raise DriverError(
f"could not start the proxmark3 client ({self._client!r}): {exc}\n"
" pass --client /path/to/proxmark3, or add it to your PATH"
) from exc
self._sync_prompt()
def execute(self, line: str) -> str:
import pexpect
if self._child is None:
raise DriverError("client not started")
try:
self._child.sendline(line)
self._child.expect(_EXPECT_RE)
except pexpect.EOF as exc:
raise DriverError("client exited") from exc
except pexpect.TIMEOUT as exc:
raise DriverError(f"client did not respond within {self._timeout}s") from exc
out = _extract_output(self._child.before, line)
self._sync_prompt()
return out
def _sync_prompt(self) -> None:
# the "[label]" sits at the end of the text just before the matched "pm3 -->" tail
m = _LABEL_RE.search(strip_ansi(self._child.before or ""))
if m:
self.prompt = m.group(1).strip() or "offline"
def close(self) -> None:
if self._child is None:
return
try:
self._child.sendline("quit")
self._child.close()
except Exception:
pass
self._child = None
#: extra checkout roots probed for the SWIG build (the discovered repo is tried first).
_SWIG_ROOTS = ("~/proxmark3",)
def _load_pm3_module():
"""Import and return the SWIG ``pm3`` module. If it isn't already importable, locate the built
library (``libpm3rrg_rdv4.so``, which exports ``PyInit__pm3``) and the ``pm3.py`` stub from the
discovered checkout and load the extension under the name ``_pm3`` — no symlink or filesystem
write. Raises :class:`ImportError` if the build can't be found."""
try:
import pm3
return pm3
except ImportError:
pass
roots = []
if os.environ.get("PM3_SWIG_PATH"):
roots.append(os.environ["PM3_SWIG_PATH"])
repo = find_repo_root()
if repo is not None:
roots.append(str(repo))
roots += [os.path.expanduser(r) for r in _SWIG_ROOTS]
for root in roots:
build = os.path.join(root, "client", "experimental_lib", "build")
pyscripts = os.path.join(root, "client", "pyscripts")
lib = os.path.join(build, "libpm3rrg_rdv4.so")
stub = os.path.join(pyscripts, "pm3.py")
if not (os.path.isfile(lib) and os.path.isfile(stub)):
# PM3_SWIG_PATH may itself be the dir holding both files
lib, stub, pyscripts = (os.path.join(root, "libpm3rrg_rdv4.so"),
os.path.join(root, "pm3.py"), root)
if not (os.path.isfile(lib) and os.path.isfile(stub)):
continue
if "_pm3" not in sys.modules:
loader = importlib.machinery.ExtensionFileLoader("_pm3", lib)
spec = importlib.util.spec_from_loader("_pm3", loader)
mod = importlib.util.module_from_spec(spec)
loader.exec_module(mod)
sys.modules["_pm3"] = mod
if pyscripts not in sys.path:
sys.path.insert(0, pyscripts)
import pm3
return pm3
raise ImportError(
"the pm3 SWIG extension (_pm3) isn't importable. Build it (in client/experimental_lib: "
"cmake .. && make -j), set $PM3_SWIG_PATH, or use --driver pexpect"
)
def _autodetect_port():
ports = sorted(glob.glob("/dev/ttyACM*")) + sorted(glob.glob("/dev/ttyUSB*"))
return ports[0] if ports else None
class SwigDriver(ClientDriver):
"""In-process driver over the ``_pm3`` SWIG bindings. Needs a connected device: the C
``pm3_open`` calls ``exit()`` on a real port with no responding PM3, so we can't recover from a
bad connection — we only open an auto-detected or explicitly given port."""
backend = "swig"
def __init__(self, port=None, pm3_module=None):
self._port = port
self._mod = pm3_module
self._p = None
self.prompt = "offline"
def start(self) -> None:
mod = self._mod or _load_pm3_module()
port = self._port or _autodetect_port()
if port is None:
raise DriverError(
"no serial device found for the SWIG driver (no /dev/ttyACM*). plug in a "
"Proxmark, pass --port, or use --driver pexpect"
)
self._p = mod.pm3(port) # exits the process if this port has no PM3
self.prompt = "usb"
def execute(self, line: str) -> str:
if self._p is None:
raise DriverError("client not started")
# capture=False, quiet=False: let the client print its OWN coloured output straight to
# stdout. The grab buffer always strips ANSI (ui.c force-filters it), so capturing would
# lose the Proxmark's colours — the whole point of the PRINT path. Returns "" (already out).
self._p.console(line, False, False)
return ""
def close(self) -> None:
self._p = None
def make_driver(port=None, client="proxmark3", driver="auto"):
"""The driver for ``pm3py client``. ``driver`` is ``auto`` (SWIG if ``_pm3`` importable, else
pexpect), ``swig`` (error if unavailable), or ``pexpect``."""
if driver in ("auto", "swig"):
try:
mod = _load_pm3_module()
except ImportError as exc:
if driver == "swig":
raise DriverError(str(exc)) from exc
mod = None # auto → fall back to pexpect
if mod is not None:
return SwigDriver(port=port, pm3_module=mod)
return PexpectDriver(port=port, client=client)

View File

@@ -0,0 +1,93 @@
"""Bottom-toolbar hints for clientcli.
Two channels, both driven by the command tree (models :func:`pm3py.cli.rawcli.memory.input_hint`):
- :func:`one_line_hint` — a one-line relevance hint for the current input (always on).
- :func:`help_panel` / :func:`panel_for` — the multi-line help shown when the help toggle is open;
:func:`resolve_target` maps the *highlighted* completion back to the node whose help to show.
"""
from __future__ import annotations
from .tree import parse_option
# ANSI (same palette as the trace / breadcrumb)
_DIM = "\033[2m"
_BOLD = "\033[1m"
_CYAN = "\033[36m"
_RESET = "\033[0m"
def _skip(flags) -> bool:
return "--help" in flags or "-h" in flags
def one_line_hint(tree, text):
"""A one-line hint for ``text``: a leaf's description + usage, or an interior node's subcommand
count. Resolves on the completed prefix when the buffer ends mid-word. ``None`` if unknown."""
tokens = (text or "").split()
if not tokens:
return None
node, used = tree.node_at(tokens), tokens
if node is None:
node, used = tree.node_at(tokens[:-1]), tokens[:-1]
if node is None or node is tree.root:
return None
if node.is_leaf:
e = node.entry
return f"{e['command']}{e['description']} · {e['usage']}"
return f"{' '.join(used)}{len(node.children)} subcommands"
def help_panel(entry) -> str:
"""The full multi-line help for a leaf command: description, usage, options, and notes."""
if not entry:
return ""
out = [f"{_BOLD}{entry['command']}{_RESET}{entry.get('description', '')}",
f"{_DIM}usage:{_RESET} {entry.get('usage', '')}"]
rows = []
for opt in entry.get("options", []):
flags, meta, desc = parse_option(opt)
if not flags or _skip(flags):
continue
rows.append((", ".join(flags) + (f" {meta}" if meta else ""), desc))
if rows:
w = min(max(len(h) for h, _ in rows), 28)
out.append(f"{_DIM}options:{_RESET}")
out += [f" {_CYAN}{h:<{w}}{_RESET} {d}" for h, d in rows]
notes = entry.get("notes", [])
if notes:
out.append(f"{_DIM}notes:{_RESET}")
out += [f" {_DIM}{n}{_RESET}" for n in notes]
return "\n".join(out)
def panel_for(node) -> str | None:
"""The help panel for a highlighted node — a leaf's full help, or an interior node's child
list. ``None`` when there is nothing to show."""
if node is None:
return None
if node.is_leaf:
return help_panel(node.entry)
kids = sorted(node.children)
if not kids:
return None
head = f"{_BOLD}{node.token or '(commands)'}{_RESET}{len(kids)} subcommands"
return head + "\n" + f"{_DIM}{', '.join(kids)}{_RESET}"
def resolve_target(tree, buffer_text, current_completion):
"""The tree node whose help to show for the highlighted completion. A subcommand completion
extends the current path; a flag completion targets the leaf under the cursor. ``None`` if
nothing resolves."""
tokens = (buffer_text or "").split()
at_word = bool(tokens) and not (buffer_text or "").endswith(" ")
path = tokens[:-1] if at_word else tokens
ctext = getattr(current_completion, "text", "") if current_completion else ""
if ctext.startswith("-"):
for i in range(len(path), -1, -1):
node = tree.node_at(path[:i])
if node is not None and node.is_leaf:
return node
return None
full = path + [ctext] if ctext else path
return tree.node_at(full)

View File

@@ -0,0 +1,78 @@
"""The help-toggle key bindings for clientcli — the centrepiece interaction.
When a completion is highlighted in the menu, RIGHT toggles a full help panel for it in the bottom
toolbar; LEFT (primary) or ESCAPE / Ctrl-g (secondary) collapses it. Only a single ``help_open``
flag lives on the session — the toolbar recomputes the *target* from the live highlighted completion
each render, so arrowing through the menu re-targets the panel automatically.
The pure ``open_help`` / ``close_help`` helpers carry the state transitions for unit tests; the key
wiring is exercised in the live REPL.
"""
from __future__ import annotations
def open_help(session, has_selection: bool) -> bool:
"""Open help iff a completion is highlighted. Returns the resulting ``help_open`` state."""
if has_selection:
session.help_open = True
return session.help_open
def close_help(session) -> bool:
session.help_open = False
return session.help_open
def apply_tab(buff) -> None:
"""Commit the highlighted completion (or the first, if none is highlighted) into ``buff``.
The default Tab only *previews* under ``complete_while_typing`` — completions regenerate on
every buffer change, so the preview never sticks. ``apply_completion`` commits it; our
trailing-space completions then re-trigger completion at the next level. When no menu is open
yet, start one (selecting the first)."""
state = buff.complete_state
if state is None:
buff.start_completion(select_first=True)
return
completion = state.current_completion or (state.completions[0] if state.completions else None)
if completion is not None:
buff.apply_completion(completion)
def _bind(kb, key, handler, **kw) -> None:
"""Add a binding, ignoring key names a given prompt_toolkit build doesn't accept."""
try:
kb.add(key, **kw)(handler)
except ValueError:
pass
def install_key_bindings(kb, session) -> None:
"""Wire TAB completion and RIGHT/LEFT/ESCAPE/Ctrl-g help toggling onto ``kb``. Never raises on
an unknown key."""
from prompt_toolkit.filters import Condition, completion_is_selected
help_is_open = Condition(lambda: session.help_open)
# Tab commits the highlighted (or first) completion so it sticks and descends a level;
# Shift-Tab keeps prompt_toolkit's default (previous completion).
_bind(kb, "tab", lambda event: apply_tab(event.current_buffer))
def _toggle(event):
session.help_open = not session.help_open
event.app.invalidate()
def _close(event):
session.help_open = False
event.app.invalidate()
# RIGHT toggles help for the highlighted completion. With nothing selected the filter is false,
# so the binding doesn't apply and the default cursor-right still works.
_bind(kb, "right", _toggle, filter=completion_is_selected)
# LEFT is the instant back-out; active only while help is open, else default cursor-left.
_bind(kb, "left", _close, filter=help_is_open)
# ESCAPE also backs out, but it is prompt_toolkit's meta prefix, so a bare escape fires only
# after ~0.5s (timeoutlen); Ctrl-g is the instant alternative. Do NOT use eager=True — it would
# fix the delay at the cost of breaking Alt-key sequences.
_bind(kb, "escape", _close, filter=help_is_open)
_bind(kb, "c-g", _close, filter=help_is_open)

142
pm3py/cli/clientcli/pack.py Normal file
View File

@@ -0,0 +1,142 @@
"""Generate a standalone, dependency-light single-file ``pm3cli`` from this package.
The runtime modules (:mod:`tree`, :mod:`driver`, :mod:`session`, :mod:`hints`, :mod:`completer`,
:mod:`keys`, :mod:`app`) are concatenated in dependency order with their intra-package imports
stripped, then a self-contained ``main()`` is appended. The result is one file that needs no pm3py
install — drop it inside a stock Iceman/RRG Proxmark3 checkout and run it. Two outputs:
- ``pm3cli.py`` — a readable single file; the target runs ``pip install prompt_toolkit`` (and
``pexpect`` for the non-SWIG backend), then ``python3 pm3cli.py``.
- ``pm3cli.pyz`` — a zipapp with prompt_toolkit/pexpect bundled inside; ``python3 pm3cli.pyz`` with
nothing to install.
The package (with its tests) stays the source of truth — this only re-emits it, so the standalone
can't drift.
"""
from __future__ import annotations
import importlib
import os
import shutil
import tempfile
import zipapp
#: runtime modules, in definition-before-use order (tree first, app last).
_MODULES = ("tree", "driver", "session", "hints", "completer", "keys", "app")
#: bundled into the .pyz so it runs with zero pip installs (all pure-Python).
_PYZ_DEPS = ("prompt_toolkit", "pexpect", "ptyprocess", "wcwidth")
_HEADER = '''\
#!/usr/bin/env python3
"""pm3cli — an autocompleting REPL for the stock Proxmark3 client.
GENERATED from pm3py.cli.clientcli — do not edit by hand; regenerate with `pm3py client --pack`.
Drop this beside (or inside) a Proxmark3 checkout and run it: python3 pm3cli.py
It finds the checkout's doc/commands.json + client automatically, auto-detects /dev/ttyACM*,
and drives the stock client (SWIG _pm3 if built, else the proxmark3 binary via pexpect).
Needs prompt_toolkit (pip install prompt_toolkit); the pexpect backend also needs pexpect.
"""
from __future__ import annotations
'''
_MAIN = '''
# --------------------------------------------------------------------------- standalone entrypoint
def main(argv=None) -> int:
import argparse
import importlib.util
import sys
p = argparse.ArgumentParser(
prog="pm3cli", description="autocompleting REPL for the stock Proxmark3 client")
p.add_argument("--port", default=None, help="serial port (default: auto-detect /dev/ttyACM*)")
p.add_argument("--commands", default=None,
help="path to commands.json (default: auto-locate in the checkout)")
p.add_argument("--client", default="proxmark3",
help="proxmark3 binary to wrap (default: PATH, then the checkout)")
p.add_argument("--driver", choices=["auto", "swig", "pexpect"], default="auto",
help="backend: auto (SWIG if built, else pexpect), swig, or pexpect")
args = p.parse_args(argv)
if importlib.util.find_spec("prompt_toolkit") is None:
print("pm3cli needs prompt_toolkit: pip install prompt_toolkit", file=sys.stderr)
return 2
if args.driver != "swig" and importlib.util.find_spec("pexpect") is None:
print("pm3cli's pexpect backend needs pexpect: pip install pexpect\\n"
" (or build the SWIG _pm3 lib and pass --driver swig)", file=sys.stderr)
return 2
return run(port=args.port, commands=args.commands, client=args.client, driver=args.driver)
if __name__ == "__main__":
raise SystemExit(main())
'''
def _drop(line: str) -> bool:
"""True for intra-package / future imports that must not survive amalgamation."""
s = line.lstrip()
return (s.startswith("from __future__")
or s.startswith("from .")
or s.startswith("from pm3py")
or s.startswith("import pm3py"))
def amalgamate() -> str:
"""Return the full standalone source as a string."""
here = os.path.dirname(os.path.abspath(__file__))
parts = [_HEADER]
for mod in _MODULES:
src = open(os.path.join(here, mod + ".py"), encoding="utf-8").read()
body = "\n".join(ln for ln in src.splitlines() if not _drop(ln))
parts.append(f"\n\n# ===================================================== clientcli.{mod}\n"
+ body.strip() + "\n")
parts.append(_MAIN)
return "".join(parts)
def _build_pyz(code: str, out_path: str) -> None:
import importlib.metadata as _im
with tempfile.TemporaryDirectory() as d:
with open(os.path.join(d, "__main__.py"), "w", encoding="utf-8") as f:
f.write(code)
for pkg in _PYZ_DEPS:
try:
mod = importlib.import_module(pkg)
except Exception:
continue
src = mod.__file__
if src.endswith("__init__.py"): # a package → copy the whole dir
shutil.copytree(os.path.dirname(src), os.path.join(d, pkg),
ignore=shutil.ignore_patterns("__pycache__", "*.pyc",
"tests", "test"))
else: # a single-module dep
shutil.copy2(src, os.path.join(d, os.path.basename(src)))
# bundle the .dist-info too — some packages read their own version via
# importlib.metadata at import time (e.g. prompt_toolkit), which reads it from the zip.
try:
info = getattr(_im.distribution(pkg), "_path", None)
if info is not None and os.path.isdir(info):
shutil.copytree(info, os.path.join(d, os.path.basename(str(info))))
except Exception:
pass
zipapp.create_archive(d, out_path, interpreter="/usr/bin/env python3")
os.chmod(out_path, 0o755)
def pack(out_path: str) -> str:
"""Write the standalone to ``out_path``. A ``.pyz`` extension builds a dependency-bundled
zipapp; anything else writes the readable single ``.py``. Returns ``out_path``."""
code = amalgamate()
compile(code, out_path, "exec") # fail loudly if the amalgamation is invalid
if out_path.endswith(".pyz"):
_build_pyz(code, out_path)
else:
with open(out_path, "w", encoding="utf-8") as f:
f.write(code)
os.chmod(out_path, 0o755)
return out_path

View File

@@ -0,0 +1,40 @@
"""clientcli session state: the driver, the loaded command tree, and the help-toggle flag. Kept
free of prompt_toolkit so it stays unit-testable (mirrors :mod:`pm3py.cli.rawcli.session`)."""
from __future__ import annotations
from typing import Any
# ANSI palette aligned with pm3py/trace/format.py (same as rawcli's breadcrumb)
_DIM = "\033[2m"
_BGREEN = "\033[1;32m"
_BRED = "\033[1;31m"
_BOLD = "\033[1m"
_RESET = "\033[0m"
class ClientSession:
"""Mutable clientcli state. ``driver`` runs the stock client, ``tree`` is the loaded command
trie, and ``help_open`` toggles the bottom-toolbar help panel."""
def __init__(self, driver: Any, tree: Any):
self.driver = driver
self.tree = tree
self.help_open: bool = False
@property
def prompt_label(self) -> str:
"""The client's current context label (``usb`` / ``fpc`` / ``offline`` / ``usb|tcp`` …)."""
return getattr(self.driver, "prompt", "offline") or "offline"
def breadcrumb(self) -> str:
return f"[client / {self.prompt_label}] pm3py -->"
def colored_breadcrumb(self) -> str:
"""The breadcrumb with ANSI colour — bold ``client``, green when a device is attached, red
when offline. Stripping the ANSI yields :meth:`breadcrumb`."""
label = self.prompt_label
color = _BRED if "offline" in label else _BGREEN
sep = f"{_DIM} / {_RESET}"
segs = [f"{_BOLD}client{_RESET}", f"{color}{label}{_RESET}"]
return (f"{_DIM}[{_RESET}" + sep.join(segs) + f"{_DIM}]{_RESET} "
f"{_BOLD}pm3py -->{_RESET}")

158
pm3py/cli/clientcli/tree.py Normal file
View File

@@ -0,0 +1,158 @@
"""Command-tree model for ``pm3py client``.
Loads the stock Proxmark3 client's machine-readable help dump (``doc/commands.json``) and builds a
prefix trie over the full command paths (``"hf mf rdbl"``). The JSON keys are **leaf** paths only —
interior nodes (``"hf"``, ``"hf mf"``) are synthesised from the path segments (no leaf path is a
prefix of another, so the trie is unambiguous). Option strings are pre-rendered by the client
(``"-k, --key <hex> key, 6 hex bytes"``); :func:`parse_option` splits them into
``(flags, metavar, description)`` for option-flag completion.
Regenerate the JSON for another client build with::
proxmark3 --fulltext | client/pyscripts/pm3_help2json.py - -
"""
from __future__ import annotations
import json
import os
import re
from dataclasses import dataclass, field
from pathlib import Path
#: env override for the commands.json path (highest priority in :func:`load_command_tree`).
_ENV_VAR = "PM3_COMMANDS_JSON"
#: a single option flag, e.g. ``-a``, ``-1``, ``--key`` (numeric short flags like ``-1`` exist).
_FLAG = re.compile(r"^--?[A-Za-z0-9][\w-]*$")
def find_repo_root(start=None):
"""Locate a Proxmark3 client checkout (stock Iceman/RRG or a fork) by its ``doc/commands.json``
marker, walking up from ``$PM3_REPO``/``$PM3_ROOT``, ``start``, the current directory, and this
file's location. Returns a :class:`~pathlib.Path` to the repo root, or ``None``. This is what
lets a dropped-in single file find whatever checkout it lives beside."""
seeds = []
for env in ("PM3_REPO", "PM3_ROOT"):
if os.environ.get(env):
seeds.append(Path(os.environ[env]))
if start:
seeds.append(Path(start))
seeds.append(Path.cwd())
try:
seeds.append(Path(__file__).resolve().parent)
except NameError: # __file__ absent (e.g. frozen/REPL)
pass
seen = set()
for seed in seeds:
base = seed.resolve() if seed.exists() else seed
for cand in (base, *base.parents):
if cand in seen:
continue
seen.add(cand)
if (cand / "doc" / "commands.json").is_file():
return cand # dropped inside the checkout
sub = cand / "proxmark3" # a side-by-side checkout (dev layout)
if (sub / "doc" / "commands.json").is_file():
return sub
return None
@dataclass
class CommandNode:
"""A node in the command trie. ``entry`` is the ``commands.json`` leaf dict for a command, or
``None`` for a synthesised interior (category) node."""
token: str # last path segment; "" for the root
children: dict[str, "CommandNode"] = field(default_factory=dict)
entry: dict | None = None
@property
def is_leaf(self) -> bool:
return self.entry is not None
class CommandTree:
def __init__(self, commands: dict):
self.root = CommandNode("")
for path, entry in commands.items():
node = self.root
for seg in path.split():
node = node.children.setdefault(seg, CommandNode(seg))
node.entry = entry
def node_at(self, tokens) -> CommandNode | None:
"""The node reached by walking ``tokens`` from the root, or ``None`` if any is unknown."""
node = self.root
for tok in tokens:
node = node.children.get(tok)
if node is None:
return None
return node
def children_of(self, tokens) -> list[CommandNode]:
"""Completion candidates below ``tokens`` (alphabetical), or ``[]`` for an unknown path."""
node = self.node_at(tokens)
if node is None:
return []
return [node.children[k] for k in sorted(node.children)]
def entry_of(self, tokens) -> dict | None:
"""The leaf metadata at ``tokens`` (``None`` for an interior or unknown path)."""
node = self.node_at(tokens)
return node.entry if node else None
def parse_option(line: str):
"""Split a rendered option string into ``(flags, metavar, description)``.
``"-k, --key <hex> key, 6 hex bytes"`` -> ``(["-k", "--key"], "<hex>", "key, 6 hex bytes")``
``"--blk <dec> block number"`` -> ``(["--blk"], "<dec>", "block number")``
``"-a input key type is key A (def)"`` -> ``(["-a"], "", "input key type is key A (def)")``
The leading run of flag tokens is delimited by trailing commas (``-k,`` promises another flag);
a following ``<...>`` token is the metavar; the rest is the description.
"""
toks = line.split()
flags: list[str] = []
i, more = 0, True
while i < len(toks) and more:
raw = toks[i]
core = raw[:-1] if raw.endswith(",") else raw
if not _FLAG.match(core):
break
flags.append(core)
more = raw.endswith(",") # a trailing comma promises another flag
i += 1
meta = ""
if i < len(toks) and toks[i].startswith("<"):
meta = toks[i]
i += 1
return flags, meta, " ".join(toks[i:])
def load_command_tree(path=None):
"""Return ``(CommandTree, metadata)``. Resolution order: an explicit ``path`` (``--commands``),
then ``$PM3_COMMANDS_JSON``, then the discovered repo's ``doc/commands.json`` (see
:func:`find_repo_root`), then ``./doc/commands.json``. Raises :class:`FileNotFoundError` (with
the regen hint) when nothing is found."""
candidates = []
if path:
candidates.append(Path(path))
elif os.environ.get(_ENV_VAR):
candidates.append(Path(os.environ[_ENV_VAR]))
else:
root = find_repo_root()
if root is not None:
candidates.append(root / "doc" / "commands.json")
candidates.append(Path("doc/commands.json"))
for cand in candidates:
p = cand.expanduser()
if p.is_file():
data = json.loads(p.read_text())
return CommandTree(data["commands"]), data.get("metadata", {})
raise FileNotFoundError(
"no Proxmark3 commands.json found (looked in: "
+ ", ".join(str(c) for c in candidates) + ").\n"
" run this from inside a Proxmark3 checkout, pass --commands PATH, set $PM3_REPO, or\n"
" regenerate it: proxmark3 --fulltext | client/pyscripts/pm3_help2json.py - -"
)

View File

@@ -1,7 +1,8 @@
"""`pm3py` top-level CLI dispatcher.
Subcommands live in submodules; ``rawcli`` is the first (the interactive raw-command TUI). Room
is left for ``flash`` (wrapping pm3flash) and a future ``client`` command CLI.
Subcommands live in submodules: ``rawcli`` (the interactive raw-command TUI over pm3py's own
protocol stack) and ``client`` (an autocompleting REPL wrapping the stock ``proxmark3`` binary).
Room is left for ``flash`` (wrapping pm3flash).
"""
from __future__ import annotations
@@ -14,6 +15,16 @@ def build_parser() -> argparse.ArgumentParser:
sub = parser.add_subparsers(dest="command")
p = sub.add_parser("rawcli", help="interactive raw-command TUI for a connected Proxmark3")
p.add_argument("--port", default=None, help="serial port (default: auto-detect)")
c = sub.add_parser("client", help="autocompleting REPL wrapping the stock proxmark3 client")
c.add_argument("--port", default=None, help="serial port passed to proxmark3 (default: auto)")
c.add_argument("--commands", default=None, help="path to commands.json (default: auto-locate)")
c.add_argument("--client", default="proxmark3", help="proxmark3 binary to wrap (default: PATH)")
c.add_argument("--driver", choices=["auto", "swig", "pexpect"], default="auto",
help="backend: auto (SWIG if built, else pexpect), swig, or pexpect")
c.add_argument("--pack", metavar="OUT", default=None,
help="spin off a standalone drop-in instead of launching: OUT.py (single file) "
"or OUT.pyz (deps bundled). Distributable to stock Proxmark3 users.")
return parser
@@ -32,5 +43,30 @@ def main(argv: list[str] | None = None) -> int:
return 2
from pm3py.cli.rawcli.app import run
return run(port=args.port)
if args.command == "client":
if args.pack:
from pm3py.cli.clientcli.pack import pack
out = pack(args.pack)
print(f"wrote standalone drop-in: {out}")
return 0
# prompt_toolkit is always needed; pexpect only for the pexpect backend (SWIG doesn't use it)
needs = ["prompt_toolkit"] + (["pexpect"] if args.driver != "swig" else [])
missing = [m for m in needs if not _importable(m)]
if missing:
print(
f"pm3py client needs {' and '.join(missing)} but "
f"{'they are' if len(missing) > 1 else 'it is'} not importable.\n"
f" reinstall pm3py, or: pip install {' '.join(missing)}",
file=sys.stderr,
)
return 2
from pm3py.cli.clientcli.app import run
return run(port=args.port, commands=args.commands, client=args.client, driver=args.driver)
parser.print_help()
return 1
def _importable(name: str) -> bool:
import importlib.util
return importlib.util.find_spec(name) is not None