diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index 68a31544c6..e794a3a1f6 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -147,6 +147,81 @@ def _copy_example_files(plugin_dir: Path, console) -> None: ) +def _prompt_plugin_env_vars(manifest: dict, console) -> None: + """Prompt for required environment variables declared in plugin.yaml. + + ``requires_env`` accepts two formats: + + Simple list (backwards-compatible):: + + requires_env: + - MY_API_KEY + + Rich list with metadata:: + + requires_env: + - name: MY_API_KEY + description: "API key for Acme service" + url: "https://acme.com/keys" + secret: true + + Already-set variables are skipped. Values are saved to ``~/.hermes/.env``. + """ + requires_env = manifest.get("requires_env") or [] + if not requires_env: + return + + from hermes_cli.config import get_env_value, save_env_value # noqa: F811 + + # Normalise to list-of-dicts + env_specs: list[dict] = [] + for entry in requires_env: + if isinstance(entry, str): + env_specs.append({"name": entry}) + elif isinstance(entry, dict) and entry.get("name"): + env_specs.append(entry) + + # Filter to only vars that aren't already set + missing = [s for s in env_specs if not get_env_value(s["name"])] + if not missing: + return + + plugin_name = manifest.get("name", "this plugin") + console.print(f"\n[bold]{plugin_name}[/bold] requires the following environment variables:\n") + + for spec in missing: + name = spec["name"] + desc = spec.get("description", "") + url = spec.get("url", "") + secret = spec.get("secret", False) + + label = f" {name}" + if desc: + label += f" — {desc}" + console.print(label) + if url: + console.print(f" [dim]Get yours at: {url}[/dim]") + + try: + if secret: + import getpass + value = getpass.getpass(f" {name}: ").strip() + else: + value = input(f" {name}: ").strip() + except (EOFError, KeyboardInterrupt): + console.print("\n[dim] Skipped (you can set these later in ~/.hermes/.env)[/dim]") + return + + if value: + save_env_value(name, value) + os.environ[name] = value + console.print(f" [green]✓[/green] Saved to ~/.hermes/.env") + else: + console.print(f" [dim] Skipped (set {name} in ~/.hermes/.env later)[/dim]") + + console.print() + + def _display_after_install(plugin_dir: Path, identifier: str) -> None: """Show after-install.md if it exists, otherwise a default message.""" from rich.console import Console @@ -306,6 +381,12 @@ def cmd_install(identifier: str, force: bool = False) -> None: # Copy .example files to their real names (e.g. config.yaml.example → config.yaml) _copy_example_files(target, console) + # Re-read manifest from installed location (for env var prompting) + installed_manifest = _read_manifest(target) + + # Prompt for required environment variables before showing after-install docs + _prompt_plugin_env_vars(installed_manifest, console) + _display_after_install(target, identifier) console.print("[dim]Restart the gateway for the plugin to take effect:[/dim]") diff --git a/tests/test_plugins_cmd.py b/tests/test_plugins_cmd.py index 492f94ad0f..b3d3eb7b65 100644 --- a/tests/test_plugins_cmd.py +++ b/tests/test_plugins_cmd.py @@ -443,3 +443,115 @@ class TestCopyExampleFiles: # Should have printed a warning assert any("Warning" in str(c) for c in console.print.call_args_list) + + +class TestPromptPluginEnvVars: + """Tests for _prompt_plugin_env_vars.""" + + def test_skips_when_no_requires_env(self): + from hermes_cli.plugins_cmd import _prompt_plugin_env_vars + from unittest.mock import MagicMock + + console = MagicMock() + _prompt_plugin_env_vars({}, console) + console.print.assert_not_called() + + def test_skips_already_set_vars(self, monkeypatch): + from hermes_cli.plugins_cmd import _prompt_plugin_env_vars + from unittest.mock import MagicMock, patch + + console = MagicMock() + with patch("hermes_cli.config.get_env_value", return_value="already-set"): + _prompt_plugin_env_vars({"requires_env": ["MY_KEY"]}, console) + # No prompt should appear — all vars are set + console.print.assert_not_called() + + def test_prompts_for_missing_var_simple_format(self): + from hermes_cli.plugins_cmd import _prompt_plugin_env_vars + from unittest.mock import MagicMock, patch + + console = MagicMock() + manifest = { + "name": "test_plugin", + "requires_env": ["MY_API_KEY"], + } + + with patch("hermes_cli.config.get_env_value", return_value=None), \ + patch("builtins.input", return_value="sk-test-123"), \ + patch("hermes_cli.config.save_env_value") as mock_save: + _prompt_plugin_env_vars(manifest, console) + + mock_save.assert_called_once_with("MY_API_KEY", "sk-test-123") + + def test_prompts_for_missing_var_rich_format(self): + from hermes_cli.plugins_cmd import _prompt_plugin_env_vars + from unittest.mock import MagicMock, patch + + console = MagicMock() + manifest = { + "name": "langfuse_tracing", + "requires_env": [ + { + "name": "LANGFUSE_PUBLIC_KEY", + "description": "Public key", + "url": "https://langfuse.com", + "secret": False, + }, + ], + } + + with patch("hermes_cli.config.get_env_value", return_value=None), \ + patch("builtins.input", return_value="pk-lf-123"), \ + patch("hermes_cli.config.save_env_value") as mock_save: + _prompt_plugin_env_vars(manifest, console) + + mock_save.assert_called_once_with("LANGFUSE_PUBLIC_KEY", "pk-lf-123") + # Should show url hint + printed = " ".join(str(c) for c in console.print.call_args_list) + assert "langfuse.com" in printed + + def test_secret_uses_getpass(self): + from hermes_cli.plugins_cmd import _prompt_plugin_env_vars + from unittest.mock import MagicMock, patch + + console = MagicMock() + manifest = { + "name": "test", + "requires_env": [{"name": "SECRET_KEY", "secret": True}], + } + + with patch("hermes_cli.config.get_env_value", return_value=None), \ + patch("getpass.getpass", return_value="s3cret") as mock_gp, \ + patch("hermes_cli.config.save_env_value"): + _prompt_plugin_env_vars(manifest, console) + + mock_gp.assert_called_once() + + def test_empty_input_skips(self): + from hermes_cli.plugins_cmd import _prompt_plugin_env_vars + from unittest.mock import MagicMock, patch + + console = MagicMock() + manifest = {"name": "test", "requires_env": ["OPTIONAL_VAR"]} + + with patch("hermes_cli.config.get_env_value", return_value=None), \ + patch("builtins.input", return_value=""), \ + patch("hermes_cli.config.save_env_value") as mock_save: + _prompt_plugin_env_vars(manifest, console) + + mock_save.assert_not_called() + + def test_keyboard_interrupt_skips_gracefully(self): + from hermes_cli.plugins_cmd import _prompt_plugin_env_vars + from unittest.mock import MagicMock, patch + + console = MagicMock() + manifest = {"name": "test", "requires_env": ["KEY1", "KEY2"]} + + with patch("hermes_cli.config.get_env_value", return_value=None), \ + patch("builtins.input", side_effect=KeyboardInterrupt), \ + patch("hermes_cli.config.save_env_value") as mock_save: + _prompt_plugin_env_vars(manifest, console) + + # Should not crash, and not save anything + mock_save.assert_not_called()