diff --git a/src/pyra/cli.py b/src/pyra/cli.py index a6456c8..f95e459 100644 --- a/src/pyra/cli.py +++ b/src/pyra/cli.py @@ -165,6 +165,7 @@ def plugin_list() -> None: @click.argument("name") def plugin_install(name: str) -> None: """Install a bundled plugin to ~/.pyra/plugins/.""" + import questionary from pyra.plugins.install import get_bundled_plugins_dir, install_bundled_plugin from pyra.utils.paths import pyra_home @@ -173,12 +174,55 @@ def plugin_install(name: str) -> None: try: install_bundled_plugin(name, bundled_dir, plugins_dir) console.print(f"[green]Installed:[/green] {name}") - console.print(f" Enable: [dim]pyra plugin enable {name}[/dim]") - console.print(f" Configure: [dim]pyra plugin setup {name}[/dim]") except FileNotFoundError as exc: console.print(f"[red]Error:[/red] {exc}") + return except Exception as exc: console.print(f"[red]Install failed:[/red] {exc}") + return + + try: + configure_now = questionary.confirm(f"Configure {name} now?", default=True).ask() + except (KeyboardInterrupt, EOFError): + return + if not configure_now: + console.print(f" Configure later: [dim]pyra plugin setup {name}[/dim]") + console.print(f" Enable: [dim]pyra plugin enable {name}[/dim]") + return + + from pyra.plugins.loader import load_plugin_by_name + from pyra.vault.writer import set_key as _set_key + + p = load_plugin_by_name(name, plugins_dir) + if p is None: + console.print(f"[red]Could not load {name} for setup.[/red]") + return + try: + p.setup(console, _set_key) + except (KeyboardInterrupt, EOFError): + console.print("[dim]Setup cancelled.[/dim]") + return + except Exception as exc: + console.print(f"[red]Setup error:[/red] {exc}") + return + + try: + enable_now = questionary.confirm(f"Enable {name} now?", default=True).ask() + except (KeyboardInterrupt, EOFError): + enable_now = False + if enable_now: + from pyra.config.manager import load_config, save_config + try: + cfg = load_config() + if name not in cfg.plugins.enabled: + cfg.plugins.enabled.append(name) + save_config(cfg) + console.print(f"[green]Enabled:[/green] {name}") + except Exception as exc: + console.print(f"[yellow]Could not enable automatically:[/yellow] {exc}") + console.print(f" Enable manually: [dim]pyra plugin enable {name}[/dim]") + + console.print(f"[dim]Run [bold]pyra daemon start[/bold] to bring {name} online.[/dim]") @plugin.command("enable") diff --git a/src/pyra/setup/wizard.py b/src/pyra/setup/wizard.py index eb9ab3f..23201a8 100644 --- a/src/pyra/setup/wizard.py +++ b/src/pyra/setup/wizard.py @@ -7,7 +7,7 @@ from rich.console import Console from rich.panel import Panel from rich.text import Text -from pyra.config.manager import save_config +from pyra.config.manager import load_config, save_config from pyra.config.schema import GeneralConfig, ProviderConfig, PyraConfig from pyra.setup.providers import PROVIDERS, Provider, get_provider from pyra.utils.paths import pyra_home, safe_chmod @@ -167,6 +167,7 @@ def run_setup() -> None: _delete_draft() _suggest_plugins(use_cases) + _offer_telegram_setup_if_selected(use_cases) console.print() console.print(Panel( @@ -238,6 +239,68 @@ def _suggest_plugins(use_cases: list[str]) -> None: )) +def _offer_telegram_setup_if_selected(use_cases: list[str]) -> None: + """If the user chose 'Communication bots', offer to install and configure telegram_bot.""" + relevant = any( + "telegram_bot" in _USE_CASE_PLUGINS.get(uc, []) + for uc in use_cases + ) + if not relevant: + return + + console.print() + try: + answer = questionary.confirm( + "Set up the Telegram bot for remote access to Pyra?", default=True + ).ask() + except (KeyboardInterrupt, EOFError): + return + if not answer: + console.print( + " [dim]You can do this later: pyra plugin install telegram_bot[/dim]" + ) + return + + from pyra.plugins.install import get_bundled_plugins_dir, install_bundled_plugin + from pyra.plugins.loader import load_plugin_by_name + from pyra.utils.paths import pyra_home + from pyra.vault.writer import set_key + + bundled_dir = get_bundled_plugins_dir() + plugins_dir = pyra_home() / "plugins" + + try: + install_bundled_plugin("telegram_bot", bundled_dir, plugins_dir) + except Exception as exc: + console.print(f"[red]Could not install telegram_bot:[/red] {exc}") + return + + p = load_plugin_by_name("telegram_bot", plugins_dir) + if p is None: + console.print("[red]Could not load telegram_bot for setup.[/red]") + return + + try: + p.setup(console, set_key) + except (KeyboardInterrupt, EOFError): + console.print("[dim]Telegram setup skipped.[/dim]") + return + except Exception as exc: + console.print(f"[red]Telegram setup error:[/red] {exc}") + return + + try: + cfg = load_config() + if "telegram_bot" not in cfg.plugins.enabled: + cfg.plugins.enabled.append("telegram_bot") + save_config(cfg) + console.print("[green]Telegram bot enabled.[/green]") + except Exception: + console.print( + " [dim]Enable manually: pyra plugin enable telegram_bot[/dim]" + ) + + def _choose_provider() -> Provider: local = [p for p in PROVIDERS if p.group == "Local"] cloud = [p for p in PROVIDERS if p.group == "Cloud"] diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 4b01d11..e3b002a 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -137,3 +137,47 @@ def test_main_skips_setup_when_config_exists(tmp_pyra_home, monkeypatch): def test_config_slash_command_registered(): from pyra.chat.session import _STATIC_COMMANDS assert "/config" in _STATIC_COMMANDS + + +# ── plugin install ──────────────────────────────────────────────────────────── + +def test_plugin_install_decline_setup(tmp_pyra_home, monkeypatch): + """Declining 'Configure now?' shows manual instructions and exits cleanly.""" + from unittest.mock import MagicMock + import questionary + + monkeypatch.setattr( + "pyra.plugins.install.install_bundled_plugin", lambda *a, **kw: None + ) + monkeypatch.setattr( + questionary, "confirm", + lambda *a, **kw: MagicMock(ask=lambda: False), + ) + + runner = CliRunner() + result = runner.invoke(main, ["plugin", "install", "telegram_bot"]) + assert result.exit_code == 0 + assert "Installed" in result.output + assert "Configure later" in result.output + + +def test_plugin_install_error_does_not_prompt(tmp_pyra_home, monkeypatch): + """If install fails, the configure prompt is never shown.""" + from unittest.mock import MagicMock + import questionary + + monkeypatch.setattr( + "pyra.plugins.install.install_bundled_plugin", + lambda *a, **kw: (_ for _ in ()).throw(FileNotFoundError("not found")), + ) + confirm_calls = [] + monkeypatch.setattr( + questionary, "confirm", + lambda *a, **kw: confirm_calls.append(1) or MagicMock(ask=lambda: False), + ) + + runner = CliRunner() + result = runner.invoke(main, ["plugin", "install", "telegram_bot"]) + assert result.exit_code == 0 + assert "Error" in result.output + assert len(confirm_calls) == 0 # prompt never reached