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
This commit is contained in:
Navid Sassan 2026-02-17 19:31:06 +01:00
parent 8cf7c9e24c
commit a316e1b233
12 changed files with 372 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
__pycache__/
*.pyc
*.egg-info/
dist/
.venv/

11
pyproject.toml Normal file
View File

@ -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"

0
src/bw_menu/__init__.py Normal file
View File

51
src/bw_menu/__main__.py Normal file
View File

@ -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()

27
src/bw_menu/cli.py Normal file
View File

@ -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()

19
src/bw_menu/clipboard.py Normal file
View File

@ -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

46
src/bw_menu/history.py Normal file
View File

@ -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

68
src/bw_menu/rbw.py Normal file
View File

@ -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)

View File

@ -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}")

View File

@ -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])

View File

@ -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)}")

8
uv.lock generated Normal file
View File

@ -0,0 +1,8 @@
version = 1
revision = 3
requires-python = ">=3.10"
[[package]]
name = "bw-menu"
version = "0.1.0"
source = { editable = "." }