feat(plugins): prompt for required env vars during hermes plugins install

Read requires_env from plugin.yaml after install and interactively
prompt for any missing environment variables, saving them to
~/.hermes/.env.

Supports two manifest formats:

  Simple (backwards-compatible):
    requires_env:
      - MY_API_KEY

  Rich (with metadata):
    requires_env:
      - name: MY_API_KEY
        description: "API key for Acme"
        url: "https://acme.com/keys"
        secret: true

Already-set variables are skipped. Empty input skips gracefully.
Secret values use getpass (hidden input). Ctrl+C aborts remaining
prompts without error.
This commit is contained in:
kshitijk4poor 2026-04-06 14:17:43 +05:30 committed by Teknium
parent 539629923c
commit 9201370c7e
2 changed files with 193 additions and 0 deletions

View file

@ -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]")

View file

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