From a316e1b233ef52deaa901c519269b01191442069 Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Tue, 17 Feb 2026 19:31:06 +0100 Subject: [PATCH] Rewrite bw-menu using rbw CLI backend and proper Python packaging Replace bw-serve/Ansible-based implementation with a clean rewrite: - rbw CLI wrapper for listing entries and fetching credentials - Rofi script-mode and fzf selectors with history-first sorting - JSON-backed frecency history at ~/.cache/bw-menu/ - Clipboard support via wl-copy/xclip auto-detection - uv + hatchling packaging with bw-menu entry point --- .gitignore | 5 +++ pyproject.toml | 11 ++++++ src/bw_menu/__init__.py | 0 src/bw_menu/__main__.py | 51 ++++++++++++++++++++++++ src/bw_menu/cli.py | 27 +++++++++++++ src/bw_menu/clipboard.py | 19 +++++++++ src/bw_menu/history.py | 46 +++++++++++++++++++++ src/bw_menu/rbw.py | 68 ++++++++++++++++++++++++++++++++ src/bw_menu/selector/__init__.py | 34 ++++++++++++++++ src/bw_menu/selector/fzf.py | 44 +++++++++++++++++++++ src/bw_menu/selector/rofi.py | 59 +++++++++++++++++++++++++++ uv.lock | 8 ++++ 12 files changed, 372 insertions(+) create mode 100644 .gitignore create mode 100644 pyproject.toml create mode 100644 src/bw_menu/__init__.py create mode 100644 src/bw_menu/__main__.py create mode 100644 src/bw_menu/cli.py create mode 100644 src/bw_menu/clipboard.py create mode 100644 src/bw_menu/history.py create mode 100644 src/bw_menu/rbw.py create mode 100644 src/bw_menu/selector/__init__.py create mode 100644 src/bw_menu/selector/fzf.py create mode 100644 src/bw_menu/selector/rofi.py create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c1a6952 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +*.egg-info/ +dist/ +.venv/ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..39b8513 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,11 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "bw-menu" +version = "0.1.0" +requires-python = ">=3.10" + +[project.scripts] +bw-menu = "bw_menu.__main__:main" diff --git a/src/bw_menu/__init__.py b/src/bw_menu/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/bw_menu/__main__.py b/src/bw_menu/__main__.py new file mode 100644 index 0000000..f0575f2 --- /dev/null +++ b/src/bw_menu/__main__.py @@ -0,0 +1,51 @@ +import sys + +from bw_menu import rbw, history, clipboard +from bw_menu.cli import parse_args +from bw_menu.selector import format_entry, create_selector + + +def main(): + args = parse_args() + + if args.command == "select": + __handle_select(args) + elif args.command == "history": + __handle_history(args) + + +def __handle_select(args): + selector = create_selector(args.selector) + entry = selector.run(args) + if entry is None: + return + + value = rbw.get_field(entry, args.field) + history.add(entry) + + if args.print_result: + print(value) + else: + clipboard.copy(value) + + +def __handle_history(args): + if args.history_command == "list": + for i, entry in enumerate(history.load()): + print(f"{i}: {format_entry(entry)}") + + elif args.history_command == "get": + entry = history.get(args.index) + if entry is None: + print(f"No history entry at index {args.index}", file=sys.stderr) + sys.exit(1) + + value = rbw.get_field(entry, args.field) + if args.print_result: + print(value) + else: + clipboard.copy(value) + + +if __name__ == "__main__": + main() diff --git a/src/bw_menu/cli.py b/src/bw_menu/cli.py new file mode 100644 index 0000000..f9711de --- /dev/null +++ b/src/bw_menu/cli.py @@ -0,0 +1,27 @@ +import argparse + + +def parse_args(): + parser = argparse.ArgumentParser(prog="bw-menu") + subparsers = parser.add_subparsers(dest="command", required=True) + + # select subcommand + select_parser = subparsers.add_parser("select") + select_parser.add_argument("--selector", default="rofi", choices=["rofi", "fzf"]) + select_parser.add_argument("--field", default="password", choices=["username", "password", "totp"]) + select_parser.add_argument("--print", dest="print_result", action="store_true") + # rofi script-mode passes the selected title as a positional arg — accept and ignore it + select_parser.add_argument("_rofi_arg", nargs="?", help=argparse.SUPPRESS) + + # history subcommand + history_parser = subparsers.add_parser("history") + history_sub = history_parser.add_subparsers(dest="history_command", required=True) + + history_sub.add_parser("list") + + get_parser = history_sub.add_parser("get") + get_parser.add_argument("index", type=int) + get_parser.add_argument("--field", default="password", choices=["username", "password", "totp"]) + get_parser.add_argument("--print", dest="print_result", action="store_true") + + return parser.parse_args() diff --git a/src/bw_menu/clipboard.py b/src/bw_menu/clipboard.py new file mode 100644 index 0000000..161106a --- /dev/null +++ b/src/bw_menu/clipboard.py @@ -0,0 +1,19 @@ +import shutil +import subprocess +import sys + + +def copy(text: str): + tool = __detect_tool() + if tool is None: + print("No clipboard tool found (install wl-copy or xclip)", file=sys.stderr) + sys.exit(5) + subprocess.run(tool, input=text, encoding="utf-8", check=True) + + +def __detect_tool() -> list[str] | None: + if shutil.which("wl-copy"): + return ["wl-copy"] + if shutil.which("xclip"): + return ["xclip", "-selection", "clipboard"] + return None diff --git a/src/bw_menu/history.py b/src/bw_menu/history.py new file mode 100644 index 0000000..c3e0574 --- /dev/null +++ b/src/bw_menu/history.py @@ -0,0 +1,46 @@ +import json +import os +from pathlib import Path + +from bw_menu.rbw import Entry + +MAX_ENTRIES = 10 + + +def __history_path() -> Path: + cache_dir = os.environ.get("XDG_CACHE_HOME", os.path.expanduser("~/.cache")) + return Path(cache_dir) / "bw-menu" / "history.json" + + +def load() -> list[Entry]: + path = __history_path() + if not path.exists(): + return [] + + try: + data = json.loads(path.read_text()) + return [Entry(e["name"], e["folder"], e["username"]) for e in data["entries"]] + except (json.JSONDecodeError, KeyError): + return [] + + +def __save(entries: list[Entry]): + path = __history_path() + path.parent.mkdir(parents=True, exist_ok=True) + data = {"entries": [{"name": e.name, "folder": e.folder, "username": e.username} for e in entries]} + path.write_text(json.dumps(data)) + + +def add(entry: Entry): + entries = load() + entries = [e for e in entries if e != entry] + entries.insert(0, entry) + entries = entries[:MAX_ENTRIES] + __save(entries) + + +def get(index: int) -> Entry | None: + entries = load() + if 0 <= index < len(entries): + return entries[index] + return None diff --git a/src/bw_menu/rbw.py b/src/bw_menu/rbw.py new file mode 100644 index 0000000..e4b0a7a --- /dev/null +++ b/src/bw_menu/rbw.py @@ -0,0 +1,68 @@ +import json +import sys +from dataclasses import dataclass +from subprocess import run + + +@dataclass(frozen=True) +class Entry: + name: str + folder: str + username: str + + +def list_entries() -> list[Entry]: + result = run(["rbw", "list", "--raw"], capture_output=True, encoding="utf-8") + + if result.returncode != 0: + print("Error calling rbw. Is it correctly configured?", file=sys.stderr) + print(result.stderr, file=sys.stderr) + sys.exit(2) + + data = json.loads(result.stdout.strip()) + return [Entry(item["name"], item["folder"] or "", item["user"] or "") for item in data] + + +def get_field(entry: Entry, field: str) -> str: + if field == "totp": + return __get_totp(entry) + if field == "username": + return entry.username + + data = __load_raw(entry) + if field == "password": + return data["data"]["password"] or "" + return "" + + +def __get_totp(entry: Entry) -> str: + cmd = ["rbw", "code", entry.name] + if entry.username: + cmd.append(entry.username) + if entry.folder: + cmd.extend(["--folder", entry.folder]) + + result = run(cmd, capture_output=True, encoding="utf-8") + if result.returncode != 0: + print(f"Error getting TOTP: {result.stderr}", file=sys.stderr) + sys.exit(3) + return result.stdout.strip() + + +def __load_raw(entry: Entry) -> dict: + cmd = ["rbw", "get", "--raw", entry.name] + if entry.username: + cmd.append(entry.username) + if entry.folder: + cmd.extend(["--folder", entry.folder]) + + result = run(cmd, capture_output=True, encoding="utf-8") + if result.returncode != 0: + print(f"Error getting entry: {result.stderr}", file=sys.stderr) + sys.exit(3) + + try: + return json.loads(result.stdout.strip()) + except json.JSONDecodeError as e: + print(f"Could not parse rbw output: {e}", file=sys.stderr) + sys.exit(4) diff --git a/src/bw_menu/selector/__init__.py b/src/bw_menu/selector/__init__.py new file mode 100644 index 0000000..8ca6bb8 --- /dev/null +++ b/src/bw_menu/selector/__init__.py @@ -0,0 +1,34 @@ +from bw_menu.rbw import Entry + +# Record separator — safe delimiter unlikely to appear in vault entry names +SEP = "\x1e" + + +def format_entry(entry: Entry) -> str: + parts = [] + if entry.folder: + parts.append(f"{entry.folder}/") + parts.append(entry.name) + if entry.username: + parts.append(f" ({entry.username})") + return "".join(parts) + + +def encode_info(entry: Entry) -> str: + return f"{entry.name}{SEP}{entry.folder}{SEP}{entry.username}" + + +def decode_info(info: str) -> Entry: + parts = info.split(SEP, 2) + return Entry(parts[0], parts[1], parts[2]) + + +def create_selector(name: str): + from bw_menu.selector.rofi import RofiSelector + from bw_menu.selector.fzf import FzfSelector + + if name == "rofi": + return RofiSelector() + if name == "fzf": + return FzfSelector() + raise ValueError(f"Unknown selector: {name}") diff --git a/src/bw_menu/selector/fzf.py b/src/bw_menu/selector/fzf.py new file mode 100644 index 0000000..08e87e4 --- /dev/null +++ b/src/bw_menu/selector/fzf.py @@ -0,0 +1,44 @@ +import subprocess + +from bw_menu import rbw, history +from bw_menu.rbw import Entry +from bw_menu.selector import format_entry, encode_info, decode_info + + +class FzfSelector: + def run(self, args) -> Entry | None: + all_entries = rbw.list_entries() + history_entries = history.load() + + all_set = set(all_entries) + history_in_list = [e for e in history_entries if e in all_set] + history_set = set(history_in_list) + remaining = [e for e in all_entries if e not in history_set] + remaining.sort(key=lambda e: (e.folder.lower(), e.name.lower())) + + lines = [] + for entry in history_in_list: + display = f"* {format_entry(entry)}" + lines.append(f"{display}\t{encode_info(entry)}") + for entry in remaining: + display = f" {format_entry(entry)}" + lines.append(f"{display}\t{encode_info(entry)}") + + input_text = "\n".join(lines) + + result = subprocess.run( + ["fzf", "--no-sort", "-d", "\t", "--with-nth=1"], + input=input_text, + stdout=subprocess.PIPE, + encoding="utf-8", + ) + + if result.returncode != 0: + return None + + selected = result.stdout.strip() + parts = selected.split("\t", 1) + if len(parts) < 2: + return None + + return decode_info(parts[1]) diff --git a/src/bw_menu/selector/rofi.py b/src/bw_menu/selector/rofi.py new file mode 100644 index 0000000..48abd0d --- /dev/null +++ b/src/bw_menu/selector/rofi.py @@ -0,0 +1,59 @@ +import os +import subprocess +import sys +from pathlib import Path + +from bw_menu import rbw, history +from bw_menu.rbw import Entry +from bw_menu.selector import format_entry, encode_info, decode_info + + +class RofiSelector: + def run(self, args) -> Entry | None: + rofi_retv = os.environ.get("ROFI_RETV") + + if rofi_retv is None: + self.__launch(args.field) + return None + + rofi_retv = int(rofi_retv) + + if rofi_retv == 0: + self.__print_entries() + return None + + if rofi_retv == 1: + rofi_info = os.environ.get("ROFI_INFO", "") + return decode_info(rofi_info) + + return None + + def __launch(self, field: str): + script = str(Path(sys.argv[0]).resolve()) + cmd = [ + "rofi", + "-show", "bw", + "-modes", f"bw:{script} select --selector rofi --field {field}", + ] + subprocess.run(cmd) + + def __print_entries(self): + all_entries = rbw.list_entries() + history_entries = history.load() + + all_set = set(all_entries) + history_in_list = [e for e in history_entries if e in all_set] + history_set = set(history_in_list) + remaining = [e for e in all_entries if e not in history_set] + remaining.sort(key=lambda e: (e.folder.lower(), e.name.lower())) + + print(f"\0prompt\x1fSelect item") + print(f"\0no-custom\x1ftrue") + + for entry in history_in_list: + display = format_entry(entry) + print(f"{display}\0info\x1f{encode_info(entry)}\x1ficon\x1fdocument-open-recent") + + for entry in remaining: + display = format_entry(entry) + print(f"{display}\0info\x1f{encode_info(entry)}") diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..72dfcd6 --- /dev/null +++ b/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "bw-menu" +version = "0.1.0" +source = { editable = "." }