Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Based on the alternative [Bitwarden](https://bitwarden.com/) CLI [rbw](https://g
- Autotype username and password (with a `tab` character in between) with `Alt+1` (and copy TOTP to clipboard)
- Configure autotyping either as a keybinding or by having a `_autotype` field in your credential
- Copy username, password or TOTP to the clipboard (`Alt+u`, `Alt+p` and `Alt+t`, respectively)
- Add new entries with `Alt+n` (automatically detects URLs from clipboard and generates secure passwords)
- Show an autotype menu with all fields

## Usage
Expand Down
8 changes: 5 additions & 3 deletions docs/rofi-rbw.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

# SYNOPSIS

| **rofi-rbw** \[**-h**] \[**\--version**] \[**\--action** {*type*,*copy*,*print*}]
| **rofi-rbw** \[**-h**] \[**\--version**] \[**\--action** {*type*,*copy*,*print*,*add*}]
\[**\--target** {*username*,*password*,*totp*,*OTHER*}]
\[**\--prompt** *PROMPT*] \[**\--selector-args** *SELECTOR_ARGS*]
\[**\--clipboarder** *CLIPBOARDER*] \[**\--typer** *TYPER*] \[**\--selector** *SELECTOR*]
Expand All @@ -34,9 +34,9 @@ Type, copy or print your credentials from Bitwarden using rofi.

\--action, -a

: Possible values: type, copy, print
: Possible values: type, copy, print, add

Choose what to do with the selected characters: Directly type them with the "Typer", copy them to the clipboard using the "Clipboarder", or "print" them on stdout
Choose what to do with the selected characters: Directly type them with the "Typer", copy them to the clipboard using the "Clipboarder", "print" them on stdout, or "add" a new entry to the database

\--target, -t

Expand Down Expand Up @@ -122,6 +122,8 @@ Type, copy or print your credentials from Bitwarden using rofi.

*alt+s* to sync the contents of the vault

*alt+n* to add a new entry (detects URLs from clipboard and generates secure passwords)

*alt+1* to autotype username and password, separated with a `tab` character

*alt+2* to type the username
Expand Down
15 changes: 15 additions & 0 deletions src/rofi_rbw/abstractionhelper.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,18 @@ def is_installed(executable: str) -> bool:

def is_wayland() -> bool:
return os.environ.get("WAYLAND_DISPLAY", False) is not None

def extract_domain_from_url(url: str) -> str:
"""Extract domain from URL, removing protocol, www, and path/query parameters."""
import re
Copy link

Copilot AI Aug 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function imports the 're' module inside the function body. It would be more efficient and conventional to import 're' at the module level.

Suggested change
import re

Copilot uses AI. Check for mistakes.


# Remove protocol
url = re.sub(r'^https?://', '', url)

# Remove www prefix
url = re.sub(r'^www\.', '', url)

# Extract domain (everything before first slash or query parameter)
domain = url.split('/')[0].split('?')[0].split('#')[0]

return domain
1 change: 1 addition & 0 deletions src/rofi_rbw/argument_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ def parse_arguments() -> argparse.Namespace:
"Alt+t:copy:totp",
"Alt+m::menu",
"Alt+s:sync",
"Alt+n:add",
]
),
help="Define keyboard shortcuts in the format <shortcut>:<action>:<target>, separated with a comma",
Expand Down
4 changes: 4 additions & 0 deletions src/rofi_rbw/clipboarder/clipboarder.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ def copy_to_clipboard(self, characters: str) -> None:
def clear_clipboard_after(self, clear: int) -> None:
pass

@abstractmethod
def read_from_clipboard(self) -> str:
pass


class NoClipboarderFoundException(Exception):
def __str__(self) -> str:
Expand Down
3 changes: 3 additions & 0 deletions src/rofi_rbw/clipboarder/noop.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,6 @@ def copy_to_clipboard(self, characters: str) -> None:

def clear_clipboard_after(self, clear: int) -> None:
raise NoClipboarderFoundException()

def read_from_clipboard(self) -> str:
raise NoClipboarderFoundException()
3 changes: 3 additions & 0 deletions src/rofi_rbw/clipboarder/wlclip.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,8 @@ def clear_clipboard_after(self, clear: int) -> None:
run(["wl-copy", "--clear"])
self.__last_copied_characters = None

def read_from_clipboard(self) -> str:
return self.__fetch_clipboard_content()

def __fetch_clipboard_content(self) -> str:
return run(["wl-paste"], capture_output=True, encoding="utf-8").stdout
3 changes: 3 additions & 0 deletions src/rofi_rbw/clipboarder/xclip.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,8 @@ def clear_clipboard_after(self, clear: int) -> None:
self.copy_to_clipboard("")
self.__last_copied_characters = None

def read_from_clipboard(self) -> str:
return self.__fetch_clipboard_content()

def __fetch_clipboard_content(self) -> str:
return run(["xclip", "-o", "-selection", "clipboard"], capture_output=True, encoding="utf-8").stdout
3 changes: 3 additions & 0 deletions src/rofi_rbw/clipboarder/xsel.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ def clear_clipboard_after(self, clear: int) -> None:
run(["xsel", "--clear", "--clipboard"])
self.__last_copied_characters = None

def read_from_clipboard(self) -> str:
return self.__fetch_clipboard_content()

def __fetch_clipboard_content(self) -> str:
return run(
[
Expand Down
1 change: 1 addition & 0 deletions src/rofi_rbw/models/action.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ class Action(Enum):
PRINT = "print"
SYNC = "sync"
CANCEL = "cancel"
ADD = "add"
32 changes: 32 additions & 0 deletions src/rofi_rbw/rbw.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,35 @@ def __load_from_rbw(self, name: str, username: str, folder: Optional[str]) -> st

def sync(self):
run(["rbw", "sync"])

def generate_password(self, length: int = 16) -> str:
Copy link

Copilot AI Aug 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generate_password method is defined but never used in the codebase. The add_entry method directly calls 'rbw generate' instead of using this method, creating duplicate functionality.

Copilot uses AI. Check for mistakes.

"""Generate a new password using rbw generate command."""
command = ["rbw", "generate", str(length)]
result = run(command, capture_output=True, encoding="utf-8")

if result.returncode != 0:
raise Exception(f"Failed to generate password: {result.stderr}")

return result.stdout.strip()

def add_entry(self, name: str, username: str, uri: Optional[str] = None, folder: Optional[str] = None, password_length: int = 16) -> str:
"""Add a new entry to the password database and return the generated password."""
command = ["rbw", "generate", str(password_length), name]

if username:
command.append(username)

if uri:
command.extend(["--uri", uri])

if folder:
command.extend(["--folder", folder])

# Run the command to create entry with generated password
result = run(command, capture_output=True, encoding="utf-8")

if result.returncode != 0:
raise Exception(f"Failed to add entry: {result.stderr}")

# The generated password is returned in stdout
return result.stdout.strip()
92 changes: 92 additions & 0 deletions src/rofi_rbw/rofi_rbw.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ def main(self) -> None:
if selected_action == Action.CANCEL:
return

if selected_action == Action.ADD:
self.__handle_add_entry()
return

entry = self.rbw.fetch_credentials(selected_entry)

if self.args.use_cache:
Expand Down Expand Up @@ -126,3 +130,91 @@ def __type_targets(self, detailed_entry: DetailedEntry, targets: List[Target]):
self.clipboarder.copy_to_clipboard(detailed_entry.totp)
if self.args.use_notify_send:
run(["notify-send", "-u", "normal", "-t", "3000", "rofi-rbw", "totp copied to clipboard"], check=True)

def __handle_add_entry(self) -> None:
"""Handle adding a new entry to the password database."""
from .abstractionhelper import extract_domain_from_url

# Try to get URL from clipboard as a default
clipboard_url = ""
try:
clipboard_content = self.clipboarder.read_from_clipboard().strip()

# Check if clipboard contains a URL
if clipboard_content.startswith(('http://', 'https://')) or ('.' in clipboard_content and not clipboard_content.isspace()):
clipboard_url = clipboard_content if clipboard_content.startswith(('http://', 'https://')) else f"https://{clipboard_content}"
Comment on lines +143 to +145
Copy link

Copilot AI Aug 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The URL detection logic is overly broad and could match non-URL strings. A string like 'my.file' or 'john.doe' would be treated as a URL, which could lead to incorrect behavior.

Suggested change
# Check if clipboard contains a URL
if clipboard_content.startswith(('http://', 'https://')) or ('.' in clipboard_content and not clipboard_content.isspace()):
clipboard_url = clipboard_content if clipboard_content.startswith(('http://', 'https://')) else f"https://{clipboard_content}"
# Check if clipboard contains a valid URL
parsed = urlparse(clipboard_content)
if (parsed.scheme in ('http', 'https') and parsed.netloc) or (not parsed.scheme and '.' in clipboard_content and not clipboard_content.isspace()):
# If it already has a scheme and netloc, use as is; otherwise, prepend https:// and check again
if parsed.scheme in ('http', 'https') and parsed.netloc:
clipboard_url = clipboard_content
else:
# Try parsing with https:// prepended
parsed2 = urlparse(f"https://{clipboard_content}")
if parsed2.scheme == 'https' and parsed2.netloc:
clipboard_url = f"https://{clipboard_content}"

Copilot uses AI. Check for mistakes.

Comment on lines +142 to +145
Copy link

Copilot AI Aug 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line automatically prefixes 'https://' to any content that contains a dot but doesn't start with a protocol. This could incorrectly convert non-URL strings like filenames or email addresses into invalid URLs.

Suggested change
# Check if clipboard contains a URL
if clipboard_content.startswith(('http://', 'https://')) or ('.' in clipboard_content and not clipboard_content.isspace()):
clipboard_url = clipboard_content if clipboard_content.startswith(('http://', 'https://')) else f"https://{clipboard_content}"
# Check if clipboard contains a URL or a plausible domain (but not an email or file path)
if clipboard_content.startswith(('http://', 'https://')):
clipboard_url = clipboard_content
else:
# Exclude email addresses and file paths
is_email = re.match(r"^[^@]+@[^@]+\.[^@]+$", clipboard_content)
is_file_path = re.match(r"^([a-zA-Z]:)?[\\/]", clipboard_content) or clipboard_content.startswith(('.', '/'))
# Check for plausible domain (contains a dot, no spaces, not email, not file path)
is_domain = (
'.' in clipboard_content
and not clipboard_content.isspace()
and not is_email
and not is_file_path
and re.match(r"^[a-zA-Z0-9.-]+$", clipboard_content)
)
if is_domain:
clipboard_url = f"https://{clipboard_content}"

Copilot uses AI. Check for mistakes.

except Exception:
# If clipboard reading fails, start with empty values
clipboard_url = ""

# Prompt for URL (pre-filled with clipboard content if available)
url_prompt = f"URL{f' [{clipboard_url}]' if clipboard_url else ''}"
try:
result = self.selector.show_input_dialog(url_prompt, clipboard_url if clipboard_url else "")
if result is None:
return # User cancelled

entered_url = result.strip()
# If user entered something, use that; if empty and we had clipboard content, use clipboard; otherwise no URL
if entered_url:
uri = entered_url if entered_url.startswith(('http://', 'https://')) else f"https://{entered_url}"
elif clipboard_url:
uri = clipboard_url
else:
uri = None
except Exception:
print("Failed to prompt for URL")
return

# Extract domain for default name if we have a URI
if uri:
try:
domain = extract_domain_from_url(uri)
default_name = domain
except Exception:
default_name = ""
else:
default_name = ""

# Prompt for entry name (pre-filled with domain if available)
name_prompt = f"Entry name{f' [{default_name}]' if default_name else ''}"
try:
result = self.selector.show_input_dialog(name_prompt, default_name if default_name else "")
if result is None:
return # User cancelled

entered_name = result.strip()
if entered_name:
name = entered_name
elif default_name:
name = default_name
else:
print("Entry name is required")
return
except Exception:
print("Failed to prompt for entry name")
return

# Prompt for username
try:
username = self.selector.show_input_dialog("Username", "")
if username is None:
return # User cancelled

username = username.strip()
except Exception:
print("Failed to prompt for username")
return

# Add entry to database (this will generate the password)
try:
password = self.rbw.add_entry(name, username, uri)

# Copy password to clipboard
self.clipboarder.copy_to_clipboard(password)
print(f"Entry '{name}' added successfully. Password copied to clipboard.")

# Sync to update the database
self.rbw.sync()
except Exception as e:
print(f"Error adding entry: {e}")
16 changes: 16 additions & 0 deletions src/rofi_rbw/selector/bemenu.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,19 @@ def select_target(
return None, Action.CANCEL

return self._extract_targets(bemenu.stdout), None

def show_input_dialog(self, prompt: str, default_value: str = "") -> str | None:
"""Show an input dialog using bemenu."""
from subprocess import run

bemenu = run(
["bemenu", "-p", prompt],
input=default_value,
capture_output=True,
encoding="utf-8"
)

if bemenu.returncode != 0:
return None # User cancelled

return bemenu.stdout.strip()
18 changes: 18 additions & 0 deletions src/rofi_rbw/selector/fuzzel.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,21 @@ def select_target(
return None, Action.CANCEL

return self._extract_targets(fuzzel.stdout), None

def show_input_dialog(self, prompt: str, default_value: str = "") -> str | None:
"""Show an input dialog using fuzzel."""
from subprocess import run

# Fuzzel doesn't have a direct dmenu mode, so we'll use a simple approach
# by creating a temporary input with the default value
fuzzel = run(
["fuzzel", "--dmenu", "--prompt", prompt],
input=default_value,
capture_output=True,
encoding="utf-8"
)

if fuzzel.returncode != 0:
return None # User cancelled

return fuzzel.stdout.strip()
16 changes: 16 additions & 0 deletions src/rofi_rbw/selector/rofi.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,19 @@ def __format_action_and_targets(self, keybinding: Keybinding) -> str:
return f"{keybinding.action.value.title()} {', '.join([target.raw for target in keybinding.targets])}"
else:
return keybinding.action.value.title()

def show_input_dialog(self, prompt: str, default_value: str = "") -> str | None:
"""Show an input dialog using rofi."""
from subprocess import run

rofi = run(
["rofi", "-dmenu", "-p", prompt],
input=default_value,
capture_output=True,
encoding="utf-8"
)

if rofi.returncode != 0:
return None # User cancelled

return rofi.stdout.strip()
Loading