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:
parent
8cf7c9e24c
commit
a316e1b233
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.egg-info/
|
||||
dist/
|
||||
.venv/
|
||||
11
pyproject.toml
Normal file
11
pyproject.toml
Normal 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
0
src/bw_menu/__init__.py
Normal file
51
src/bw_menu/__main__.py
Normal file
51
src/bw_menu/__main__.py
Normal 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
27
src/bw_menu/cli.py
Normal 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
19
src/bw_menu/clipboard.py
Normal 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
46
src/bw_menu/history.py
Normal 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
68
src/bw_menu/rbw.py
Normal 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)
|
||||
34
src/bw_menu/selector/__init__.py
Normal file
34
src/bw_menu/selector/__init__.py
Normal 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}")
|
||||
44
src/bw_menu/selector/fzf.py
Normal file
44
src/bw_menu/selector/fzf.py
Normal 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])
|
||||
59
src/bw_menu/selector/rofi.py
Normal file
59
src/bw_menu/selector/rofi.py
Normal 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)}")
|
||||
Loading…
x
Reference in New Issue
Block a user