from __future__ import annotations import importlib import importlib.util import importlib.metadata import json import subprocess import sys from typing import Optional import typer from .common import _get_entry_service, _get_services from seedpass.core.errors import SeedPassError from constants import GUI_BACKEND_CONFIG from seedpass.tui_v2.app import check_tui2_runtime, launch_tui2 from seedpass.tui_v3 import check_tui3_runtime, launch_tui3 app = typer.Typer( help=( "SeedPass command line interface. " "Run `seedpass capabilities` for a deterministic feature map " "and `seedpass --help` for group-specific commands, " "including document I/O and entry graph links." ), invoke_without_command=True, ) # Global option shared across all commands fingerprint_option = typer.Option( None, "--fingerprint", "-f", help="Specify which seed profile to use", ) no_clipboard_option = typer.Option( False, "--no-clipboard", help="Disable clipboard support and print secrets instead", ) deterministic_totp_option = typer.Option( False, "--deterministic-totp", help="Derive TOTP secrets deterministically", ) legacy_tui_option = typer.Option( False, "--legacy-tui", help="Use legacy interactive TUI explicitly", ) tui_version_option = typer.Option( "v3", "--tui", help="Select TUI version: legacy, v2, v3", ) # Sub command groups from . import entry, vault, nostr, config, fingerprint, util, api, agent, semantic from .capabilities import register_capabilities_command app.add_typer(entry.app, name="entry") app.add_typer(vault.app, name="vault") app.add_typer(nostr.app, name="nostr") app.add_typer(config.app, name="config") app.add_typer(fingerprint.app, name="fingerprint") app.add_typer(util.app, name="util") app.add_typer(api.app, name="api") app.add_typer(agent.app, name="agent") app.add_typer(semantic.app, name="semantic") register_capabilities_command(app) def _get_cli_version() -> str: """Return installed package version, falling back to a dev label.""" try: return importlib.metadata.version("seedpass") except importlib.metadata.PackageNotFoundError: return "dev" def _version_callback(value: bool) -> None: """Print version and exit early when --version is provided.""" if not value: return typer.echo(f"SeedPass {_get_cli_version()}") raise typer.Exit() def run() -> None: """Invoke the CLI, handling SeedPass errors gracefully.""" try: app() except SeedPassError as exc: typer.echo(str(exc), err=True) raise typer.Exit(1) from exc def _gui_backend_available() -> bool: """Return True if a platform-specific BeeWare backend is installed.""" for pkg in ("toga_gtk", "toga_winforms", "toga_cocoa"): if importlib.util.find_spec(pkg) is not None: return True return False def _prime_tui2_service( ctx: typer.Context, ) -> tuple[Optional[dict[str, object]], Optional[Exception]]: """Initialize entry service before Textual mounts. PasswordManager initialization may prompt for credentials. Doing that before entering Textual prevents hidden prompt stalls that appear as a blank screen. """ try: from seedpass.core.api import ( ProfileService, ConfigService, NostrService, SemanticIndexService, SyncService, UtilityService, VaultService, ) entry_service = _get_entry_service(ctx) pm = getattr(entry_service, "_manager", None) return { "entry": entry_service, "profile": ProfileService(pm) if pm is not None else None, "config": ConfigService(pm) if pm is not None else None, "nostr": NostrService(pm) if pm is not None else None, "sync": SyncService(pm) if pm is not None else None, "utility": UtilityService(pm) if pm is not None else None, "vault": VaultService(pm) if pm is not None else None, "semantic": SemanticIndexService(pm) if pm is not None else None, }, None except Exception as exc: # pragma: no cover - defensive runtime path return None, exc def _launch_legacy_tui(*, fingerprint: Optional[str]) -> typer.Exit: """Launch legacy interactive TUI and return Typer exit.""" tui = importlib.import_module("main") legacy_main = getattr(tui, "main") # Ensure fallback from newer subcommands (e.g., tui2/tui3) does not leak # unsupported argv into legacy argparse. try: return typer.Exit(legacy_main(argv=[], fingerprint=fingerprint)) except TypeError: return typer.Exit(legacy_main(fingerprint=fingerprint)) def _stdin_is_tty() -> bool: return sys.stdin.isatty() def _launch_tui3_or_exit( *, fingerprint: Optional[str], services: Optional[dict[str, object]] = None, ) -> None: if not _stdin_is_tty(): typer.echo( "seedpass tui3: requires an interactive terminal (stdin is not a TTY).", err=True, ) raise typer.Exit(1) kwargs: dict[str, object] = {"fingerprint": fingerprint} if services is not None: kwargs.update( { "entry_service_factory": lambda: services["entry"], "profile_service_factory": lambda: services["profile"], "config_service_factory": lambda: services["config"], "nostr_service_factory": lambda: services["nostr"], "sync_service_factory": lambda: services["sync"], "utility_service_factory": lambda: services["utility"], "vault_service_factory": lambda: services["vault"], "semantic_service_factory": lambda: services["semantic"], } ) launched = launch_tui3(**kwargs) if launched: return typer.echo( "TUI v3 runtime unavailable. Run `seedpass tui3 --check` for diagnostics, " "`seedpass tui2` to launch TUI v2, or `seedpass legacy` for the legacy interface.", err=True, ) raise typer.Exit(1) @app.callback(invoke_without_command=True) def main( ctx: typer.Context, version: bool = typer.Option( False, "--version", callback=_version_callback, is_eager=True, help="Show SeedPass version and exit", ), fingerprint: Optional[str] = fingerprint_option, no_clipboard: bool = no_clipboard_option, deterministic_totp: bool = deterministic_totp_option, legacy_tui: bool = legacy_tui_option, tui_version: str = tui_version_option, ) -> None: """SeedPass CLI entry point. When called without a subcommand this launches the interactive TUI. Use ``seedpass capabilities --format json`` for machine-readable command and security feature discovery. """ ctx.obj = { "version": version, "fingerprint": fingerprint, "no_clipboard": no_clipboard, "deterministic_totp": deterministic_totp, "legacy_tui": legacy_tui, "tui_version": tui_version, } if ctx.invoked_subcommand is None: if legacy_tui or tui_version == "legacy": raise _launch_legacy_tui(fingerprint=fingerprint) if tui_version == "v3": _launch_tui3_or_exit(fingerprint=fingerprint) return services, preflight_error = _prime_tui2_service(ctx) if preflight_error is not None: typer.echo( f"TUI {tui_version} preflight failed: {preflight_error}", err=True, ) raise typer.Exit(1) launched = launch_tui2( fingerprint=fingerprint, entry_service_factory=lambda: services["entry"], profile_service_factory=lambda: services["profile"], config_service_factory=lambda: services["config"], nostr_service_factory=lambda: services["nostr"], sync_service_factory=lambda: services["sync"], utility_service_factory=lambda: services["utility"], vault_service_factory=lambda: services["vault"], semantic_service_factory=lambda: services["semantic"], ) if launched: return typer.echo( "TUI v2 runtime unavailable. Run `seedpass tui2 --check` for diagnostics, " "or use `seedpass legacy` for the legacy interface." ) raise typer.Exit(1) @app.command("lock") def root_lock(ctx: typer.Context) -> None: """Lock the vault for the active profile.""" vault_service, _profile, _sync = _get_services(ctx) vault_service.lock() typer.echo("locked") @app.command("tui2") def tui2( ctx: typer.Context, check: bool = typer.Option( False, "--check", help="Check whether TUI v2 runtime dependencies are available", ), fallback_legacy: bool = typer.Option( True, "--fallback-legacy/--no-fallback-legacy", help="Fall back to legacy TUI when TUI v2 runtime is unavailable", ), ) -> None: """Launch the Textual-based TUI v2 (search/edit/graph workflows).""" if check: typer.echo(json.dumps(check_tui2_runtime(), indent=2, sort_keys=True)) return fingerprint = (ctx.obj or {}).get("fingerprint") services, preflight_error = _prime_tui2_service(ctx) if preflight_error is not None: if fallback_legacy: typer.echo( "TUI v2 preflight failed; falling back to legacy TUI. " f"Details: {preflight_error}" ) raise _launch_legacy_tui(fingerprint=fingerprint) typer.echo( f"TUI v2 preflight failed: {preflight_error}", err=True, ) raise typer.Exit(1) launched = launch_tui2( fingerprint=fingerprint, entry_service_factory=lambda: services["entry"], profile_service_factory=lambda: services["profile"], config_service_factory=lambda: services["config"], nostr_service_factory=lambda: services["nostr"], sync_service_factory=lambda: services["sync"], utility_service_factory=lambda: services["utility"], vault_service_factory=lambda: services["vault"], semantic_service_factory=lambda: services["semantic"], ) if launched: return if fallback_legacy: typer.echo( "TUI v2 runtime unavailable; falling back to legacy TUI. " "Run `seedpass tui2 --check` for diagnostics." ) raise _launch_legacy_tui(fingerprint=fingerprint) typer.echo( "TUI v2 runtime unavailable. Install `textual` and retry.", err=True, ) raise typer.Exit(1) @app.command("tui3") def tui3( ctx: typer.Context, check: bool = typer.Option( False, "--check", help="Check whether TUI v3 runtime dependencies are available", ), ) -> None: """Launch the current TUI v3 interface.""" if check: typer.echo(json.dumps(check_tui3_runtime(), indent=2, sort_keys=True)) return fingerprint = (ctx.obj or {}).get("fingerprint") _launch_tui3_or_exit(fingerprint=fingerprint) @app.command("legacy") def legacy_tui(ctx: typer.Context) -> None: """Launch the legacy interactive TUI explicitly.""" fingerprint = (ctx.obj or {}).get("fingerprint") raise _launch_legacy_tui(fingerprint=fingerprint) @app.command() def gui( install: bool = typer.Option( False, "--install", help="Attempt to install the BeeWare GUI backend if missing", ) ) -> None: """Launch the BeeWare GUI. If a platform specific backend is missing, inform the user how to install it. Using ``--install`` will attempt installation after confirmation. """ if not _gui_backend_available(): platform_key = "linux" if sys.platform.startswith("linux") else sys.platform backend_info = GUI_BACKEND_CONFIG.get(platform_key) if not backend_info: typer.echo( f"Unsupported platform '{sys.platform}' for BeeWare GUI.", err=True, ) raise typer.Exit(1) pkg = backend_info["pkg"] version = backend_info["version"] sha256 = backend_info["sha256"] if not install: typer.echo( f"BeeWare GUI backend not found. Please install {pkg} manually or rerun " "with '--install'.", err=True, ) raise typer.Exit(1) if not typer.confirm( f"Install {pkg}=={version} with hash verification?", default=False ): typer.echo("Installation cancelled.", err=True) raise typer.Exit(1) typer.echo( "SeedPass uses pinned versions and SHA256 hashes to verify the GUI backend " "and protect against tampered packages." ) try: subprocess.check_call( [ sys.executable, "-m", "pip", "install", "--require-hashes", f"{pkg}=={version}", f"--hash=sha256:{sha256}", ] ) typer.echo(f"Successfully installed {pkg}=={version}.") except subprocess.CalledProcessError as exc: typer.echo( "Secure installation failed. Please install the package manually " f"from a trusted source. Details: {exc}", err=True, ) raise typer.Exit(1) if not _gui_backend_available(): typer.echo( "BeeWare GUI backend still unavailable after installation attempt.", err=True, ) raise typer.Exit(1) from seedpass_gui.app import main main() if __name__ == "__main__": # pragma: no cover run()