from pathlib import Path from typing import Optional, List import json import typer import sys from seedpass.core.manager import PasswordManager from seedpass.core.entry_types import EntryType from seedpass.core.api import ( VaultService, ProfileService, SyncService, EntryService, ConfigService, UtilityService, NostrService, ChangePasswordRequest, UnlockRequest, BackupParentSeedRequest, ProfileSwitchRequest, ProfileRemoveRequest, ) import uvicorn from . import api as api_module import importlib import importlib.util import subprocess app = typer.Typer( help="SeedPass command line interface", invoke_without_command=True, ) # Global option shared across all commands fingerprint_option = typer.Option( None, "--fingerprint", "-f", help="Specify which seed profile to use", ) # Sub command groups entry_app = typer.Typer(help="Manage individual entries") vault_app = typer.Typer(help="Manage the entire vault") nostr_app = typer.Typer(help="Interact with Nostr relays") config_app = typer.Typer(help="Get or set configuration values") fingerprint_app = typer.Typer(help="Manage seed profiles") util_app = typer.Typer(help="Utility commands") api_app = typer.Typer(help="Run the API server") 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") def _get_pm(ctx: typer.Context) -> PasswordManager: """Return a PasswordManager optionally selecting a fingerprint.""" fp = ctx.obj.get("fingerprint") if fp is None: pm = PasswordManager() else: pm = PasswordManager(fingerprint=fp) return pm def _get_services( ctx: typer.Context, ) -> tuple[VaultService, ProfileService, SyncService]: """Return service layer instances for the current context.""" pm = _get_pm(ctx) return VaultService(pm), ProfileService(pm), SyncService(pm) def _get_entry_service(ctx: typer.Context) -> EntryService: pm = _get_pm(ctx) return EntryService(pm) def _get_config_service(ctx: typer.Context) -> ConfigService: pm = _get_pm(ctx) return ConfigService(pm) def _get_util_service(ctx: typer.Context) -> UtilityService: pm = _get_pm(ctx) return UtilityService(pm) def _get_nostr_service(ctx: typer.Context) -> NostrService: pm = _get_pm(ctx) return NostrService(pm) 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 @app.callback(invoke_without_command=True) def main(ctx: typer.Context, fingerprint: Optional[str] = fingerprint_option) -> None: """SeedPass CLI entry point. When called without a subcommand this launches the interactive TUI. """ ctx.obj = {"fingerprint": fingerprint} if ctx.invoked_subcommand is None: tui = importlib.import_module("main") raise typer.Exit(tui.main(fingerprint=fingerprint)) @entry_app.command("list") def entry_list( ctx: typer.Context, sort: str = typer.Option( "index", "--sort", help="Sort by 'index', 'label', or 'updated'" ), kind: Optional[str] = typer.Option(None, "--kind", help="Filter by entry type"), archived: bool = typer.Option(False, "--archived", help="Include archived"), ) -> None: """List entries in the vault.""" service = _get_entry_service(ctx) entries = service.list_entries( sort_by=sort, filter_kind=kind, include_archived=archived ) for idx, label, username, url, is_archived in entries: line = f"{idx}: {label}" if username: line += f" ({username})" if url: line += f" {url}" if is_archived: line += " [archived]" typer.echo(line) @entry_app.command("search") def entry_search( ctx: typer.Context, query: str, kind: List[str] = typer.Option( None, "--kind", "-k", help="Filter by entry kinds (can be repeated)", ), ) -> None: """Search entries.""" service = _get_entry_service(ctx) kinds = list(kind) if kind else None results = service.search_entries(query, kinds=kinds) if not results: typer.echo("No matching entries found") return for idx, label, username, url, _arch, etype in results: line = f"{idx}: {etype.value.replace('_', ' ').title()} - {label}" if username: line += f" ({username})" if url: line += f" {url}" typer.echo(line) @entry_app.command("get") 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) 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) @entry_app.command("add") def entry_add( ctx: typer.Context, label: str, length: int = typer.Option(12, "--length"), username: Optional[str] = typer.Option(None, "--username"), url: Optional[str] = typer.Option(None, "--url"), no_special: bool = typer.Option( False, "--no-special", help="Exclude special characters", is_flag=True ), allowed_special_chars: Optional[str] = typer.Option( None, "--allowed-special-chars", help="Explicit set of special characters" ), special_mode: Optional[str] = typer.Option( None, "--special-mode", help="Special character mode", ), exclude_ambiguous: bool = typer.Option( False, "--exclude-ambiguous", help="Exclude ambiguous characters", is_flag=True, ), min_uppercase: Optional[int] = typer.Option(None, "--min-uppercase"), min_lowercase: Optional[int] = typer.Option(None, "--min-lowercase"), min_digits: Optional[int] = typer.Option(None, "--min-digits"), min_special: Optional[int] = typer.Option(None, "--min-special"), ) -> None: """Add a new password entry and output its index.""" service = _get_entry_service(ctx) kwargs = {} if no_special: kwargs["include_special_chars"] = False if allowed_special_chars is not None: kwargs["allowed_special_chars"] = allowed_special_chars if special_mode is not None: kwargs["special_mode"] = special_mode if exclude_ambiguous: kwargs["exclude_ambiguous"] = True if min_uppercase is not None: kwargs["min_uppercase"] = min_uppercase if min_lowercase is not None: kwargs["min_lowercase"] = min_lowercase if min_digits is not None: kwargs["min_digits"] = min_digits if min_special is not None: kwargs["min_special"] = min_special index = service.add_entry(label, length, username, url, **kwargs) typer.echo(str(index)) @entry_app.command("add-totp") def entry_add_totp( ctx: typer.Context, label: str, index: Optional[int] = typer.Option(None, "--index", help="Derivation index"), secret: Optional[str] = typer.Option(None, "--secret", help="Import secret"), period: int = typer.Option(30, "--period", help="TOTP period in seconds"), digits: int = typer.Option(6, "--digits", help="Number of TOTP digits"), ) -> None: """Add a TOTP entry and output the otpauth URI.""" service = _get_entry_service(ctx) uri = service.add_totp( label, index=index, secret=secret, period=period, digits=digits, ) typer.echo(uri) @entry_app.command("add-ssh") def entry_add_ssh( ctx: typer.Context, label: str, index: Optional[int] = typer.Option(None, "--index", help="Derivation index"), notes: str = typer.Option("", "--notes", help="Entry notes"), ) -> None: """Add an SSH key entry and output its index.""" service = _get_entry_service(ctx) idx = service.add_ssh_key( label, index=index, notes=notes, ) typer.echo(str(idx)) @entry_app.command("add-pgp") def entry_add_pgp( ctx: typer.Context, label: str, index: Optional[int] = typer.Option(None, "--index", help="Derivation index"), key_type: str = typer.Option("ed25519", "--key-type", help="Key type"), user_id: str = typer.Option("", "--user-id", help="User ID"), notes: str = typer.Option("", "--notes", help="Entry notes"), ) -> None: """Add a PGP key entry and output its index.""" service = _get_entry_service(ctx) idx = service.add_pgp_key( label, index=index, key_type=key_type, user_id=user_id, notes=notes, ) typer.echo(str(idx)) @entry_app.command("add-nostr") def entry_add_nostr( ctx: typer.Context, label: str, index: Optional[int] = typer.Option(None, "--index", help="Derivation index"), notes: str = typer.Option("", "--notes", help="Entry notes"), ) -> None: """Add a Nostr key entry and output its index.""" service = _get_entry_service(ctx) idx = service.add_nostr_key( label, index=index, notes=notes, ) typer.echo(str(idx)) @entry_app.command("add-seed") def entry_add_seed( ctx: typer.Context, label: str, index: Optional[int] = typer.Option(None, "--index", help="Derivation index"), words: int = typer.Option(24, "--words", help="Word count"), notes: str = typer.Option("", "--notes", help="Entry notes"), ) -> None: """Add a derived seed phrase entry and output its index.""" service = _get_entry_service(ctx) idx = service.add_seed( label, index=index, words=words, notes=notes, ) typer.echo(str(idx)) @entry_app.command("add-key-value") def entry_add_key_value( ctx: typer.Context, label: str, key: str = typer.Option(..., "--key", help="Key name"), value: str = typer.Option(..., "--value", help="Stored value"), notes: str = typer.Option("", "--notes", help="Entry notes"), ) -> None: """Add a key/value entry and output its index.""" service = _get_entry_service(ctx) idx = service.add_key_value(label, key, value, notes=notes) typer.echo(str(idx)) @entry_app.command("add-managed-account") def entry_add_managed_account( ctx: typer.Context, label: str, index: Optional[int] = typer.Option(None, "--index", help="Derivation index"), notes: str = typer.Option("", "--notes", help="Entry notes"), ) -> None: """Add a managed account seed entry and output its index.""" service = _get_entry_service(ctx) idx = service.add_managed_account( label, index=index, notes=notes, ) typer.echo(str(idx)) @entry_app.command("modify") def entry_modify( ctx: typer.Context, entry_id: int, label: Optional[str] = typer.Option(None, "--label"), username: Optional[str] = typer.Option(None, "--username"), url: Optional[str] = typer.Option(None, "--url"), notes: Optional[str] = typer.Option(None, "--notes"), period: Optional[int] = typer.Option( None, "--period", help="TOTP period in seconds" ), digits: Optional[int] = typer.Option(None, "--digits", help="TOTP digits"), key: Optional[str] = typer.Option(None, "--key", help="New key"), value: Optional[str] = typer.Option(None, "--value", help="New value"), ) -> None: """Modify an existing entry.""" service = _get_entry_service(ctx) try: service.modify_entry( entry_id, username=username, url=url, notes=notes, label=label, period=period, digits=digits, key=key, value=value, ) except ValueError as e: typer.echo(str(e)) sys.stdout.flush() raise typer.Exit(code=1) @entry_app.command("archive") def entry_archive(ctx: typer.Context, entry_id: int) -> None: """Archive an entry.""" service = _get_entry_service(ctx) service.archive_entry(entry_id) typer.echo(str(entry_id)) @entry_app.command("unarchive") def entry_unarchive(ctx: typer.Context, entry_id: int) -> None: """Restore an archived entry.""" service = _get_entry_service(ctx) service.restore_entry(entry_id) typer.echo(str(entry_id)) @entry_app.command("totp-codes") def entry_totp_codes(ctx: typer.Context) -> None: """Display all current TOTP codes.""" service = _get_entry_service(ctx) service.display_totp_codes() @entry_app.command("export-totp") def entry_export_totp( ctx: typer.Context, file: str = typer.Option(..., help="Output file") ) -> None: """Export all TOTP secrets to a JSON file.""" service = _get_entry_service(ctx) data = service.export_totp_entries() Path(file).write_text(json.dumps(data, indent=2)) typer.echo(str(file)) @vault_app.command("export") def vault_export( ctx: typer.Context, file: str = typer.Option(..., help="Output file") ) -> None: """Export the vault profile to an encrypted file.""" vault_service, _profile, _sync = _get_services(ctx) data = vault_service.export_profile() Path(file).write_bytes(data) typer.echo(str(file)) @vault_app.command("import") def vault_import( ctx: typer.Context, file: str = typer.Option(..., help="Input file") ) -> None: """Import a vault profile from an encrypted file.""" vault_service, _profile, _sync = _get_services(ctx) data = Path(file).read_bytes() vault_service.import_profile(data) typer.echo(str(file)) @vault_app.command("change-password") def vault_change_password(ctx: typer.Context) -> None: """Change the master password used for encryption.""" vault_service, _profile, _sync = _get_services(ctx) old_pw = typer.prompt("Current password", hide_input=True) new_pw = typer.prompt("New password", hide_input=True, confirmation_prompt=True) try: vault_service.change_password( ChangePasswordRequest(old_password=old_pw, new_password=new_pw) ) except Exception as exc: # pragma: no cover - pass through errors typer.echo(f"Error: {exc}") raise typer.Exit(code=1) typer.echo("Password updated") @vault_app.command("unlock") def vault_unlock(ctx: typer.Context) -> None: """Unlock the vault for the active profile.""" vault_service, _profile, _sync = _get_services(ctx) password = typer.prompt("Master password", hide_input=True) try: resp = vault_service.unlock(UnlockRequest(password=password)) except Exception as exc: # pragma: no cover - pass through errors typer.echo(f"Error: {exc}") raise typer.Exit(code=1) typer.echo(f"Unlocked in {resp.duration:.2f}s") @vault_app.command("lock") def vault_lock(ctx: typer.Context) -> None: """Lock the vault and clear sensitive data from memory.""" vault_service, _profile, _sync = _get_services(ctx) vault_service.lock() typer.echo("locked") @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") @vault_app.command("stats") def vault_stats(ctx: typer.Context) -> None: """Display statistics about the current seed profile.""" vault_service, _profile, _sync = _get_services(ctx) stats = vault_service.stats() typer.echo(json.dumps(stats, indent=2)) @vault_app.command("reveal-parent-seed") def vault_reveal_parent_seed( ctx: typer.Context, file: Optional[str] = typer.Option( None, "--file", help="Save encrypted seed to this path" ), ) -> None: """Display the parent seed and optionally write an encrypted backup file.""" vault_service, _profile, _sync = _get_services(ctx) password = typer.prompt("Master password", hide_input=True) vault_service.backup_parent_seed( BackupParentSeedRequest(path=Path(file) if file else None, password=password) ) @nostr_app.command("sync") def nostr_sync(ctx: typer.Context) -> None: """Sync with configured Nostr relays.""" _vault, _profile, sync_service = _get_services(ctx) model = sync_service.sync() if model: typer.echo("Event IDs:") typer.echo(f"- manifest: {model.manifest_id}") for cid in model.chunk_ids: typer.echo(f"- chunk: {cid}") for did in model.delta_ids: typer.echo(f"- delta: {did}") else: typer.echo("Error: Failed to sync vault") @nostr_app.command("get-pubkey") def nostr_get_pubkey(ctx: typer.Context) -> None: """Display the active profile's npub.""" service = _get_nostr_service(ctx) npub = service.get_pubkey() typer.echo(npub) @nostr_app.command("list-relays") def nostr_list_relays(ctx: typer.Context) -> None: """Display configured Nostr relays.""" service = _get_nostr_service(ctx) relays = service.list_relays() for i, r in enumerate(relays, 1): typer.echo(f"{i}: {r}") @nostr_app.command("add-relay") def nostr_add_relay(ctx: typer.Context, url: str) -> None: """Add a relay URL.""" service = _get_nostr_service(ctx) try: service.add_relay(url) except Exception as exc: # pragma: no cover - pass through errors typer.echo(f"Error: {exc}") raise typer.Exit(code=1) typer.echo("Added") @nostr_app.command("remove-relay") def nostr_remove_relay(ctx: typer.Context, idx: int) -> None: """Remove a relay by index (1-based).""" service = _get_nostr_service(ctx) try: service.remove_relay(idx) except Exception as exc: # pragma: no cover - pass through errors typer.echo(f"Error: {exc}") raise typer.Exit(code=1) typer.echo("Removed") @config_app.command("get") def config_get(ctx: typer.Context, key: str) -> None: """Get a configuration value.""" service = _get_config_service(ctx) value = service.get(key) if value is None: typer.echo("Key not found") else: typer.echo(str(value)) @config_app.command("set") def config_set(ctx: typer.Context, key: str, value: str) -> None: """Set a configuration value.""" service = _get_config_service(ctx) try: val = ( [r.strip() for r in value.split(",") if r.strip()] if key == "relays" else value ) service.set(key, val) except KeyError: typer.echo("Unknown key") raise typer.Exit(code=1) except Exception as exc: # pragma: no cover - pass through errors typer.echo(f"Error: {exc}") raise typer.Exit(code=1) typer.echo("Updated") @config_app.command("toggle-secret-mode") def config_toggle_secret_mode(ctx: typer.Context) -> None: """Interactively enable or disable secret mode. When enabled, newly generated and retrieved passwords are copied to the clipboard instead of printed to the screen. """ service = _get_config_service(ctx) try: enabled = service.get_secret_mode_enabled() delay = service.get_clipboard_clear_delay() except Exception as exc: # pragma: no cover - pass through errors typer.echo(f"Error loading settings: {exc}") raise typer.Exit(code=1) typer.echo(f"Secret mode is currently {'ON' if enabled else 'OFF'}") choice = ( typer.prompt( "Enable secret mode? (y/n, blank to keep)", default="", show_default=False ) .strip() .lower() ) if choice in ("y", "yes"): enabled = True elif choice in ("n", "no"): enabled = False inp = typer.prompt( f"Clipboard clear delay in seconds [{delay}]", default="", show_default=False ).strip() if inp: try: delay = int(inp) if delay <= 0: typer.echo("Delay must be positive") raise typer.Exit(code=1) except ValueError: typer.echo("Invalid number") raise typer.Exit(code=1) try: service.set_secret_mode(enabled, delay) except Exception as exc: # pragma: no cover - pass through errors typer.echo(f"Error: {exc}") raise typer.Exit(code=1) status = "enabled" if enabled else "disabled" typer.echo(f"Secret mode {status}.") @config_app.command("toggle-offline") def config_toggle_offline(ctx: typer.Context) -> None: """Enable or disable offline mode.""" service = _get_config_service(ctx) try: enabled = service.get_offline_mode() except Exception as exc: # pragma: no cover - pass through errors typer.echo(f"Error loading settings: {exc}") raise typer.Exit(code=1) typer.echo(f"Offline mode is currently {'ON' if enabled else 'OFF'}") choice = ( typer.prompt( "Enable offline mode? (y/n, blank to keep)", default="", show_default=False ) .strip() .lower() ) if choice in ("y", "yes"): enabled = True elif choice in ("n", "no"): enabled = False try: service.set_offline_mode(enabled) except Exception as exc: # pragma: no cover - pass through errors typer.echo(f"Error: {exc}") raise typer.Exit(code=1) status = "enabled" if enabled else "disabled" typer.echo(f"Offline mode {status}.") @fingerprint_app.command("list") def fingerprint_list(ctx: typer.Context) -> None: """List available seed profiles.""" _vault, profile_service, _sync = _get_services(ctx) for fp in profile_service.list_profiles(): typer.echo(fp) @fingerprint_app.command("add") def fingerprint_add(ctx: typer.Context) -> None: """Create a new seed profile.""" _vault, profile_service, _sync = _get_services(ctx) profile_service.add_profile() @fingerprint_app.command("remove") def fingerprint_remove(ctx: typer.Context, fingerprint: str) -> None: """Remove a seed profile.""" _vault, profile_service, _sync = _get_services(ctx) profile_service.remove_profile(ProfileRemoveRequest(fingerprint=fingerprint)) @fingerprint_app.command("switch") def fingerprint_switch(ctx: typer.Context, fingerprint: str) -> None: """Switch to another seed profile.""" _vault, profile_service, _sync = _get_services(ctx) password = typer.prompt("Master password", hide_input=True) profile_service.switch_profile( ProfileSwitchRequest(fingerprint=fingerprint, password=password) ) @util_app.command("generate-password") def generate_password( ctx: typer.Context, length: int = 24, no_special: bool = typer.Option( False, "--no-special", help="Exclude special characters", is_flag=True ), allowed_special_chars: Optional[str] = typer.Option( None, "--allowed-special-chars", help="Explicit set of special characters" ), special_mode: Optional[str] = typer.Option( None, "--special-mode", help="Special character mode", ), exclude_ambiguous: bool = typer.Option( False, "--exclude-ambiguous", help="Exclude ambiguous characters", is_flag=True, ), min_uppercase: Optional[int] = typer.Option(None, "--min-uppercase"), min_lowercase: Optional[int] = typer.Option(None, "--min-lowercase"), min_digits: Optional[int] = typer.Option(None, "--min-digits"), min_special: Optional[int] = typer.Option(None, "--min-special"), ) -> None: """Generate a strong password.""" service = _get_util_service(ctx) kwargs = {} if no_special: kwargs["include_special_chars"] = False if allowed_special_chars is not None: kwargs["allowed_special_chars"] = allowed_special_chars if special_mode is not None: kwargs["special_mode"] = special_mode if exclude_ambiguous: kwargs["exclude_ambiguous"] = True if min_uppercase is not None: kwargs["min_uppercase"] = min_uppercase if min_lowercase is not None: kwargs["min_lowercase"] = min_lowercase if min_digits is not None: kwargs["min_digits"] = min_digits if min_special is not None: kwargs["min_special"] = min_special password = service.generate_password(length, **kwargs) typer.echo(password) @util_app.command("verify-checksum") def verify_checksum(ctx: typer.Context) -> None: """Verify the SeedPass script checksum.""" service = _get_util_service(ctx) service.verify_checksum() @util_app.command("update-checksum") def update_checksum(ctx: typer.Context) -> None: """Regenerate the script checksum file.""" service = _get_util_service(ctx) service.update_checksum() @api_app.command("start") def api_start(ctx: typer.Context, host: str = "127.0.0.1", port: int = 8000) -> None: """Start the SeedPass API server.""" token = api_module.start_server(ctx.obj.get("fingerprint")) typer.echo(f"API token: {token}") uvicorn.run(api_module.app, host=host, port=port) @api_app.command("stop") def api_stop(ctx: typer.Context, host: str = "127.0.0.1", port: int = 8000) -> None: """Stop the SeedPass API server.""" import requests try: requests.post( f"http://{host}:{port}/api/v1/shutdown", headers={"Authorization": f"Bearer {api_module._token}"}, timeout=2, ) except Exception as exc: # pragma: no cover - best effort typer.echo(f"Failed to stop server: {exc}") @app.command() def gui() -> None: """Launch the BeeWare GUI. If the platform specific backend is missing, attempt to install it and retry launching the GUI. """ if not _gui_backend_available(): if sys.platform.startswith("linux"): pkg = "toga-gtk" elif sys.platform == "win32": pkg = "toga-winforms" elif sys.platform == "darwin": pkg = "toga-cocoa" else: typer.echo( f"Unsupported platform '{sys.platform}' for BeeWare GUI.", err=True, ) raise typer.Exit(1) typer.echo(f"Attempting to install {pkg} for GUI support...") try: subprocess.check_call([sys.executable, "-m", "pip", "install", pkg]) typer.echo(f"Successfully installed {pkg}.") except subprocess.CalledProcessError as exc: typer.echo(f"Failed to install {pkg}: {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__": app()