diff --git a/src/main.py b/src/main.py index 21f3271..6ea1428 100644 --- a/src/main.py +++ b/src/main.py @@ -32,6 +32,7 @@ from utils import ( pause, clear_header_with_notification, ) +from utils.clipboard import ClipboardUnavailableError from utils.atomic_write import atomic_write import queue from local_bip85.bip85 import Bip85Error @@ -1233,6 +1234,11 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in load_global_config() parser = argparse.ArgumentParser() parser.add_argument("--fingerprint") + parser.add_argument( + "--no-clipboard", + action="store_true", + help="Disable clipboard support and print secrets", + ) sub = parser.add_subparsers(dest="command") exp = sub.add_parser("export") @@ -1264,6 +1270,9 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in print(colored(f"Error: Failed to initialize PasswordManager: {e}", "red")) return 1 + if args.no_clipboard: + password_manager.secret_mode_enabled = False + if args.command == "export": password_manager.handle_export_database(Path(args.file)) return 0 @@ -1311,8 +1320,17 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in idx, password_manager.parent_seed ) print(code) - if copy_to_clipboard(code, password_manager.clipboard_clear_delay): - print(colored("Code copied to clipboard", "green")) + try: + if copy_to_clipboard(code, password_manager.clipboard_clear_delay): + print(colored("Code copied to clipboard", "green")) + except ClipboardUnavailableError as exc: + print( + colored( + f"Clipboard unavailable: {exc}\n" + "Re-run with '--no-clipboard' to print codes instead.", + "yellow", + ) + ) return 0 def signal_handler(sig, _frame): diff --git a/src/seedpass/cli/__init__.py b/src/seedpass/cli/__init__.py index cfcb7bd..771d623 100644 --- a/src/seedpass/cli/__init__.py +++ b/src/seedpass/cli/__init__.py @@ -23,6 +23,13 @@ fingerprint_option = typer.Option( help="Specify which seed profile to use", ) +no_clipboard_option = typer.Option( + False, + "--no-clipboard", + help="Disable clipboard support and print secrets instead", + is_flag=True, +) + # Sub command groups from . import entry, vault, nostr, config, fingerprint, util, api @@ -44,12 +51,16 @@ def _gui_backend_available() -> bool: @app.callback(invoke_without_command=True) -def main(ctx: typer.Context, fingerprint: Optional[str] = fingerprint_option) -> None: +def main( + ctx: typer.Context, + fingerprint: Optional[str] = fingerprint_option, + no_clipboard: bool = no_clipboard_option, +) -> None: """SeedPass CLI entry point. When called without a subcommand this launches the interactive TUI. """ - ctx.obj = {"fingerprint": fingerprint} + ctx.obj = {"fingerprint": fingerprint, "no_clipboard": no_clipboard} if ctx.invoked_subcommand is None: tui = importlib.import_module("main") raise typer.Exit(tui.main(fingerprint=fingerprint)) diff --git a/src/seedpass/cli/common.py b/src/seedpass/cli/common.py index aa1d4f8..c2ff6f0 100644 --- a/src/seedpass/cli/common.py +++ b/src/seedpass/cli/common.py @@ -27,6 +27,8 @@ def _get_pm(ctx: typer.Context) -> PasswordManager: pm = PasswordManager() else: pm = PasswordManager(fingerprint=fp) + if ctx.obj.get("no_clipboard"): + pm.secret_mode_enabled = False return pm diff --git a/src/seedpass/cli/entry.py b/src/seedpass/cli/entry.py index 66ba76a..f3a5b7e 100644 --- a/src/seedpass/cli/entry.py +++ b/src/seedpass/cli/entry.py @@ -8,6 +8,7 @@ from typing import List, Optional import typer from .common import _get_entry_service, EntryType +from utils.clipboard import ClipboardUnavailableError app = typer.Typer(help="Manage individual entries") @@ -69,31 +70,39 @@ def entry_search( def entry_get(ctx: typer.Context, query: str) -> None: """Retrieve a single entry's secret.""" service = _get_entry_service(ctx) - matches = service.search_entries(query) - if len(matches) == 0: - typer.echo("No matching entries found") - raise typer.Exit(code=1) - if len(matches) > 1: - typer.echo("Matches:") - for idx, label, username, _url, _arch, etype in matches: - name = f"{idx}: {etype.value.replace('_', ' ').title()} - {label}" - if username: - name += f" ({username})" - typer.echo(name) - raise typer.Exit(code=1) + try: + matches = service.search_entries(query) + if len(matches) == 0: + typer.echo("No matching entries found") + raise typer.Exit(code=1) + if len(matches) > 1: + typer.echo("Matches:") + for idx, label, username, _url, _arch, etype in matches: + name = f"{idx}: {etype.value.replace('_', ' ').title()} - {label}" + if username: + name += f" ({username})" + typer.echo(name) + raise typer.Exit(code=1) - index = matches[0][0] - entry = service.retrieve_entry(index) - etype = entry.get("type", entry.get("kind")) - if etype == EntryType.PASSWORD.value: - length = int(entry.get("length", 12)) - password = service.generate_password(length, index) - typer.echo(password) - elif etype == EntryType.TOTP.value: - code = service.get_totp_code(index) - typer.echo(code) - else: - typer.echo("Unsupported entry type") + index = matches[0][0] + entry = service.retrieve_entry(index) + etype = entry.get("type", entry.get("kind")) + if etype == EntryType.PASSWORD.value: + length = int(entry.get("length", 12)) + password = service.generate_password(length, index) + typer.echo(password) + elif etype == EntryType.TOTP.value: + code = service.get_totp_code(index) + typer.echo(code) + else: + typer.echo("Unsupported entry type") + raise typer.Exit(code=1) + except ClipboardUnavailableError as exc: + typer.echo( + f"Clipboard unavailable: {exc}\n" + "Re-run with '--no-clipboard' to print secrets instead.", + err=True, + ) raise typer.Exit(code=1) diff --git a/src/tests/test_cli_clipboard_flag.py b/src/tests/test_cli_clipboard_flag.py new file mode 100644 index 0000000..29911ff --- /dev/null +++ b/src/tests/test_cli_clipboard_flag.py @@ -0,0 +1,44 @@ +from typer.testing import CliRunner + +from seedpass.cli import app, entry as cli_entry +from seedpass.core.entry_types import EntryType +from utils.clipboard import ClipboardUnavailableError + + +runner = CliRunner() + + +def _stub_service(ctx, raise_error=True): + class Service: + def search_entries(self, query, kinds=None): + return [(1, "label", None, None, False, EntryType.PASSWORD)] + + def retrieve_entry(self, idx): + return {"type": EntryType.PASSWORD.value, "length": 12} + + def generate_password(self, length, index): + if raise_error and not ctx.obj.get("no_clipboard"): + raise ClipboardUnavailableError("missing") + return "pwd" + + return Service() + + +def test_entry_get_handles_missing_clipboard(monkeypatch): + monkeypatch.setattr( + cli_entry, "_get_entry_service", lambda ctx: _stub_service(ctx, True) + ) + result = runner.invoke(app, ["entry", "get", "label"], catch_exceptions=False) + assert result.exit_code == 1 + assert "no-clipboard" in result.stderr.lower() + + +def test_entry_get_no_clipboard_flag(monkeypatch): + monkeypatch.setattr( + cli_entry, "_get_entry_service", lambda ctx: _stub_service(ctx, True) + ) + result = runner.invoke( + app, ["--no-clipboard", "entry", "get", "label"], catch_exceptions=False + ) + assert result.exit_code == 0 + assert "pwd" in result.stdout diff --git a/src/tests/test_clipboard_utils.py b/src/tests/test_clipboard_utils.py index 3ea5442..ac3bd81 100644 --- a/src/tests/test_clipboard_utils.py +++ b/src/tests/test_clipboard_utils.py @@ -7,7 +7,9 @@ import sys sys.path.append(str(Path(__file__).resolve().parents[1])) -from utils.clipboard import copy_to_clipboard +import pytest + +from utils.clipboard import ClipboardUnavailableError, copy_to_clipboard def test_copy_to_clipboard_clears(monkeypatch): @@ -69,7 +71,7 @@ def test_copy_to_clipboard_does_not_clear_if_changed(monkeypatch): assert clipboard["text"] == "other" -def test_copy_to_clipboard_missing_dependency(monkeypatch, capsys): +def test_copy_to_clipboard_missing_dependency(monkeypatch): def fail_copy(*args, **kwargs): raise pyperclip.PyperclipException("no copy") @@ -77,6 +79,5 @@ def test_copy_to_clipboard_missing_dependency(monkeypatch, capsys): monkeypatch.setattr(pyperclip, "paste", lambda: "") monkeypatch.setattr(shutil, "which", lambda cmd: None) - copy_to_clipboard("secret", 1) - out = capsys.readouterr().out - assert "install xclip" in out.lower() + with pytest.raises(ClipboardUnavailableError): + copy_to_clipboard("secret", 1) diff --git a/src/utils/__init__.py b/src/utils/__init__.py index 0061f7f..6b91dac 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -38,13 +38,16 @@ from .atomic_write import atomic_write # Optional clipboard support try: # pragma: no cover - exercised when dependency missing - from .clipboard import copy_to_clipboard + from .clipboard import ClipboardUnavailableError, copy_to_clipboard except Exception as exc: # pragma: no cover - executed only if pyperclip missing + class ClipboardUnavailableError(RuntimeError): + """Stub exception when clipboard support is unavailable.""" + def copy_to_clipboard(*_args, **_kwargs): """Stub when clipboard support is unavailable.""" logger.warning("Clipboard support unavailable: %s", exc) - return False + raise ClipboardUnavailableError(str(exc)) __all__ = [ @@ -69,6 +72,7 @@ __all__ = [ "timed_input", "InMemorySecret", "copy_to_clipboard", + "ClipboardUnavailableError", "clear_screen", "clear_and_print_fingerprint", "clear_header_with_notification", diff --git a/src/utils/clipboard.py b/src/utils/clipboard.py index 66621a6..5a03b27 100644 --- a/src/utils/clipboard.py +++ b/src/utils/clipboard.py @@ -1,3 +1,5 @@ +"""Clipboard utility helpers.""" + import logging import shutil import sys @@ -5,6 +7,11 @@ import threading import pyperclip + +class ClipboardUnavailableError(RuntimeError): + """Raised when required clipboard utilities are not installed.""" + + logger = logging.getLogger(__name__) @@ -15,31 +22,20 @@ def _ensure_clipboard() -> None: except pyperclip.PyperclipException as exc: if sys.platform.startswith("linux"): if shutil.which("xclip") is None and shutil.which("xsel") is None: - raise pyperclip.PyperclipException( + raise ClipboardUnavailableError( "Clipboard support requires the 'xclip' package. " - "Install it with 'sudo apt install xclip' and restart SeedPass." + "Install it with 'sudo apt install xclip' and restart SeedPass.", ) from exc - raise + raise ClipboardUnavailableError( + "No clipboard mechanism available. Install a supported clipboard tool or " + "run SeedPass with --no-clipboard." + ) from exc def copy_to_clipboard(text: str, timeout: int) -> bool: - """Copy text to the clipboard and clear after timeout seconds if unchanged. + """Copy text to the clipboard and clear after ``timeout`` seconds if unchanged.""" - Returns True if the text was successfully copied, False otherwise. - """ - - try: - _ensure_clipboard() - except pyperclip.PyperclipException as exc: - warning = ( - "Clipboard unavailable: " - + str(exc) - + "\nSeedPass secret mode requires clipboard support. " - "Install xclip or disable secret mode to view secrets." - ) - logger.warning(warning) - print(warning) - return False + _ensure_clipboard() pyperclip.copy(text) @@ -51,3 +47,6 @@ def copy_to_clipboard(text: str, timeout: int) -> bool: timer.daemon = True timer.start() return True + + +__all__ = ["copy_to_clipboard", "ClipboardUnavailableError"]