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