Files
SeedPass/src/seedpass/cli/__init__.py
T
thePR0M3TH3AN 96f64d00bc tui3: guard against non-interactive stdin with TTY check
Add _stdin_is_tty() helper and check at start of _launch_tui3_or_exit.
When stdin is not a TTY, emit a clear error message and exit 1 instead
of propagating an EOF exception from seed setup prompts.
Add test for non-interactive stdin error path; patch _stdin_is_tty in
existing tests that expect tui3 launch to proceed.

Fixes SeedPass-6d26
2026-03-09 12:45:34 -04:00

442 lines
14 KiB
Python

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 <group> --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()