Merge branch 'simp/r3-23-g4' into simp/integration3
This commit is contained in:
+14
-40
@@ -10,15 +10,11 @@ from collections.abc import Callable
|
||||
|
||||
_BACKSPACE_CHARS = {"\b", "\x7f"}
|
||||
_ENTER_CHARS = {"\r", "\n"}
|
||||
_EOF_CHARS = {"\x04", "\x1a"}
|
||||
_EOF_CHARS = {"\x04", "\x1a", ""} # "" == stream closed
|
||||
|
||||
|
||||
def _collect_masked_input(
|
||||
read_char: Callable[[], str],
|
||||
write: Callable[[str], object],
|
||||
prompt: str,
|
||||
*,
|
||||
mask: str = "*",
|
||||
read_char: Callable[[], str], write: Callable[[str], object], prompt: str, *, mask: str = "*",
|
||||
) -> str:
|
||||
"""Read one secret line while writing a mask character per typed char."""
|
||||
value: list[str] = []
|
||||
@@ -26,9 +22,6 @@ def _collect_masked_input(
|
||||
|
||||
while True:
|
||||
ch = read_char()
|
||||
if ch == "":
|
||||
write("\r\n")
|
||||
raise EOFError
|
||||
if ch in _ENTER_CHARS:
|
||||
write("\r\n")
|
||||
return "".join(value)
|
||||
@@ -44,10 +37,9 @@ def _collect_masked_input(
|
||||
write("\b \b")
|
||||
continue
|
||||
if ch == "\x1b":
|
||||
# Ignore escape itself. Terminals commonly send escape-prefixed
|
||||
# navigation/delete sequences; they should not become secret text.
|
||||
# Terminals send escape-prefixed navigation/delete sequences; they must not become
|
||||
# secret text.
|
||||
continue
|
||||
|
||||
value.append(ch)
|
||||
if mask:
|
||||
write(mask)
|
||||
@@ -59,22 +51,12 @@ def masked_secret_prompt(prompt: str, *, mask: str = "*") -> str:
|
||||
Falls back to ``getpass.getpass`` when stdin/stdout are not interactive or when raw terminal
|
||||
handling is unavailable.
|
||||
"""
|
||||
stdin = sys.stdin
|
||||
stdout = sys.stdout
|
||||
|
||||
if not _stream_is_tty(stdin) or not _stream_is_tty(stdout):
|
||||
if not _stream_is_tty(sys.stdin) or not _stream_is_tty(sys.stdout):
|
||||
return getpass.getpass(prompt)
|
||||
|
||||
if os.name == "nt":
|
||||
try:
|
||||
return _masked_secret_prompt_windows(prompt, mask=mask)
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
raise
|
||||
except Exception:
|
||||
return getpass.getpass(prompt)
|
||||
|
||||
masked = _masked_secret_prompt_windows if os.name == "nt" else _masked_secret_prompt_posix
|
||||
try:
|
||||
return _masked_secret_prompt_posix(prompt, mask=mask)
|
||||
return masked(prompt, mask=mask)
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
raise
|
||||
except Exception:
|
||||
@@ -88,6 +70,11 @@ def _stream_is_tty(stream) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _write(text: str) -> None:
|
||||
sys.stdout.write(text)
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def _masked_secret_prompt_windows(prompt: str, *, mask: str) -> str:
|
||||
import msvcrt
|
||||
|
||||
@@ -98,29 +85,16 @@ def _masked_secret_prompt_windows(prompt: str, *, mask: str) -> str:
|
||||
return "\x1b"
|
||||
return ch
|
||||
|
||||
def write(text: str) -> None:
|
||||
sys.stdout.write(text)
|
||||
sys.stdout.flush()
|
||||
|
||||
return _collect_masked_input(read_char, write, prompt, mask=mask)
|
||||
return _collect_masked_input(read_char, _write, prompt, mask=mask)
|
||||
|
||||
|
||||
def _masked_secret_prompt_posix(prompt: str, *, mask: str) -> str:
|
||||
import termios
|
||||
import tty
|
||||
|
||||
fd = sys.stdin.fileno()
|
||||
old_attrs = termios.tcgetattr(fd)
|
||||
|
||||
def read_char() -> str:
|
||||
return sys.stdin.read(1)
|
||||
|
||||
def write(text: str) -> None:
|
||||
sys.stdout.write(text)
|
||||
sys.stdout.flush()
|
||||
|
||||
try:
|
||||
tty.setraw(fd)
|
||||
return _collect_masked_input(read_char, write, prompt, mask=mask)
|
||||
return _collect_masked_input(lambda: sys.stdin.read(1), _write, prompt, mask=mask)
|
||||
finally:
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, old_attrs)
|
||||
|
||||
+176
-282
@@ -15,49 +15,26 @@ from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
# NOTE: the Bitwarden backend (``agent.secret_sources.bitwarden``) pulls in
|
||||
# ``cryptography`` at module-import time. On Windows the resulting
|
||||
# ``cryptography._rust.pyd`` is mapped into the running process — and when
|
||||
# that process is ``hermes update``, the self-lock preflight detects the
|
||||
# loaded native module and defers (#86781). Keep the backend import lazy:
|
||||
# this module is registered parse-time from ``hermes_cli.main`` and must not
|
||||
# touch ``bw`` until a handler actually runs.
|
||||
#
|
||||
# ``_BWS_VERSION`` is duplicated here (as a plain string) so ``register_cli``
|
||||
# can render the ``install --help`` text without importing the backend.
|
||||
# ``agent.secret_sources.bitwarden._BWS_VERSION`` is the source of truth;
|
||||
# bump both together when pinning a new bws release.
|
||||
# The Bitwarden backend pulls in ``cryptography`` at import time; on Windows that mapped native
|
||||
# module makes the ``hermes update`` self-lock preflight defer. This module is registered
|
||||
# parse-time from ``hermes_cli.main``, so the backend import stays lazy (nothing touches ``bw``
|
||||
# until a handler runs) and ``_BWS_VERSION`` is duplicated here for the ``install --help`` text.
|
||||
# ``agent.secret_sources.bitwarden._BWS_VERSION`` is the source of truth; bump both together.
|
||||
_BWS_VERSION = "2.0.0"
|
||||
|
||||
from hermes_cli._secrets_common import (
|
||||
arg,
|
||||
cfg_str,
|
||||
cli_version,
|
||||
disable_secret_source,
|
||||
flag,
|
||||
print_status_panel,
|
||||
print_table,
|
||||
prompt_index,
|
||||
register_subcommands,
|
||||
require_enabled,
|
||||
rotate_token,
|
||||
secret_cli_env,
|
||||
section_cfg,
|
||||
arg, cfg_str, cli_version, disable_secret_source, flag, print_status_panel, print_table,
|
||||
prompt_index, register_subcommands, require_enabled, rotate_token, secret_cli_env, section_cfg,
|
||||
yn,
|
||||
)
|
||||
from hermes_cli.config import (
|
||||
get_env_path,
|
||||
load_config,
|
||||
save_config,
|
||||
save_env_value,
|
||||
)
|
||||
from hermes_cli.config import get_env_path, load_config, save_config, save_env_value
|
||||
from hermes_cli.secret_prompt import masked_secret_prompt
|
||||
|
||||
# Old names kept bound: tests monkeypatch ``secrets_cli._bws_version``.
|
||||
_bws_version = cli_version
|
||||
_yn = yn
|
||||
|
||||
|
||||
_DEFAULT_TOKEN_ENV = "BWS_ACCESS_TOKEN"
|
||||
_NOT_BSM_TOKEN_WARNING = (
|
||||
"[yellow]Warning: token doesn't start with '0.' — usually that means "
|
||||
"you pasted something other than a BSM access token.[/yellow]"
|
||||
@@ -71,17 +48,12 @@ _NOT_BSM_TOKEN_WARNING_CONTINUING = (
|
||||
def _load_bw():
|
||||
"""Import ``agent.secret_sources.bitwarden`` on first use (crypto payload)."""
|
||||
from agent.secret_sources import bitwarden as _bw
|
||||
|
||||
return _bw
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""PEP 562 module-level lazy resolver.
|
||||
|
||||
Existing callers (and upstream tests) monkeypatch attributes on ``hermes_cli.secrets_cli.bw``
|
||||
directly. Resolving that attribute at module-import time would re-import ``cryptography``
|
||||
eagerly — the very self-lock we are preventing (#86781).
|
||||
"""
|
||||
"""PEP 562 lazy ``bw`` attribute (tests monkeypatch ``secrets_cli.bw``); an eager binding
|
||||
would re-import ``cryptography`` at import time."""
|
||||
if name == "bw":
|
||||
return _load_bw()
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -125,47 +97,85 @@ def register_cli(parent_parser: argparse.ArgumentParser) -> None:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def cmd_setup(args: argparse.Namespace) -> int:
|
||||
bw = _load_bw()
|
||||
console = Console()
|
||||
console.print(
|
||||
Panel.fit(
|
||||
"[bold]Bitwarden Secrets Manager setup[/bold]\n\n"
|
||||
"Need an access token? In the Bitwarden web app:\n"
|
||||
" Secrets Manager → Machine accounts → [your account] →\n"
|
||||
" Access tokens → Create access token\n\n"
|
||||
"Copy the token (starts with [cyan]0.[/cyan]…) — it cannot be retrieved later.",
|
||||
border_style="cyan",
|
||||
)
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ binary
|
||||
def _step(console: Console, n: int, title: str) -> None:
|
||||
console.print()
|
||||
console.print("[bold]Step 1[/bold] Install the bws CLI")
|
||||
console.print(f"[bold]Step {n}[/bold] {title}")
|
||||
|
||||
|
||||
def _setup_binary(bw, console: Console) -> Optional[Path]:
|
||||
"""Step 1: locate or download bws; None (after printing) on failure."""
|
||||
_step(console, 1, "Install the bws CLI")
|
||||
try:
|
||||
binary = bw.find_bws(install_if_missing=False)
|
||||
if binary is None:
|
||||
console.print(" No bws on PATH — downloading…")
|
||||
binary = bw.install_bws()
|
||||
version = _bws_version(binary)
|
||||
console.print(f" [green]✓[/green] {binary} ({version})")
|
||||
console.print(f" [green]✓[/green] {binary} ({_bws_version(binary)})")
|
||||
return binary
|
||||
except Exception as exc: # noqa: BLE001
|
||||
console.print(f" [red]✗ Could not install bws: {exc}[/red]")
|
||||
console.print(
|
||||
" Manual install: "
|
||||
"https://github.com/bitwarden/sdk-sm/releases"
|
||||
)
|
||||
console.print(" Manual install: https://github.com/bitwarden/sdk-sm/releases")
|
||||
return None
|
||||
|
||||
|
||||
def _missing_noninteractive_flags(args: argparse.Namespace) -> list[str]:
|
||||
"""Setup flags a no-TTY run must supply (BWS_SERVER_URL env substitutes for --server-url)."""
|
||||
provided = {
|
||||
"--access-token": args.access_token,
|
||||
"--server-url": (args.server_url or "").strip() or os.environ.get("BWS_SERVER_URL", ""),
|
||||
"--project-id": args.project_id}
|
||||
return [flag for flag, value in provided.items() if not (value and value.strip())]
|
||||
|
||||
|
||||
def _setup_token(args: argparse.Namespace, console: Console, token_env: str) -> Optional[str]:
|
||||
"""Step 2: take the token from ``--access-token`` or a masked prompt and persist it."""
|
||||
_step(console, 2, "Provide your access token")
|
||||
token = (args.access_token or "").strip() or masked_secret_prompt(f" Paste access token ({token_env}): ").strip()
|
||||
if not token:
|
||||
console.print(" [red]Empty token, aborting.[/red]")
|
||||
return None
|
||||
if not token.startswith("0."):
|
||||
console.print(_NOT_BSM_TOKEN_WARNING_CONTINUING)
|
||||
save_env_value(token_env, token)
|
||||
os.environ[token_env] = token # so the test fetch below sees it
|
||||
console.print(f" [green]✓[/green] stored in {get_env_path()} as {token_env}")
|
||||
return token
|
||||
|
||||
|
||||
def _setup_project(binary: Path, token: str, console: Console, server_url: str) -> Optional[str]:
|
||||
"""Step 4: list projects and let the user pick one; None (after printing) when none usable."""
|
||||
_step(console, 4, "Pick a project")
|
||||
projects = _list_projects(binary, token, console, server_url=server_url)
|
||||
if projects is None:
|
||||
return None
|
||||
if not projects:
|
||||
console.print(" [yellow]No projects visible to this machine account.[/yellow]")
|
||||
console.print(" In the Bitwarden web app, open the machine account → Projects tab "
|
||||
"and grant it access to at least one project.")
|
||||
return None
|
||||
print_table(console, (("#", {"style": "cyan", "width": 4}), "Name", ("ID", {"style": "dim"})),
|
||||
((str(i), p.get("name", "?"), p.get("id", "?")) for i, p in enumerate(projects, 1)))
|
||||
idx = prompt_index(console, f" Select project [1-{len(projects)}]: ", len(projects))
|
||||
return projects[idx - 1]["id"]
|
||||
|
||||
|
||||
def cmd_setup(args: argparse.Namespace) -> int:
|
||||
bw = _load_bw()
|
||||
console = Console()
|
||||
console.print(Panel.fit(
|
||||
"[bold]Bitwarden Secrets Manager setup[/bold]\n\n"
|
||||
"Need an access token? In the Bitwarden web app:\n"
|
||||
" Secrets Manager → Machine accounts → [your account] →\n"
|
||||
" Access tokens → Create access token\n\n"
|
||||
"Copy the token (starts with [cyan]0.[/cyan]…) — it cannot be retrieved later.",
|
||||
border_style="cyan"))
|
||||
|
||||
binary = _setup_binary(bw, console)
|
||||
if binary is None:
|
||||
return 1
|
||||
|
||||
# -- non-interactive guard --
|
||||
if not sys.stdin.isatty():
|
||||
# BWS_SERVER_URL env var is accepted as a non-interactive substitute for --server-url.
|
||||
provided = {
|
||||
"--access-token": args.access_token,
|
||||
"--server-url": (args.server_url or "").strip() or os.environ.get("BWS_SERVER_URL", ""),
|
||||
"--project-id": args.project_id,
|
||||
}
|
||||
missing = [flag for flag, value in provided.items() if not (value and value.strip())]
|
||||
missing = _missing_noninteractive_flags(args)
|
||||
if missing:
|
||||
console.print(
|
||||
f" [red]Non-interactive mode (no TTY) requires all setup flags.[/red]\n"
|
||||
@@ -174,79 +184,34 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
||||
" hermes secrets bitwarden setup \\\n"
|
||||
" --access-token '0.xxx' \\\n"
|
||||
" --server-url 'https://vault.bitwarden.com' \\\n"
|
||||
" --project-id 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'"
|
||||
)
|
||||
" --project-id 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'")
|
||||
return 1
|
||||
|
||||
# ------------------------------------------------------------------- token
|
||||
console.print()
|
||||
console.print("[bold]Step 2[/bold] Provide your access token")
|
||||
cfg = load_config()
|
||||
secrets_cfg = cfg.setdefault("secrets", {}).setdefault("bitwarden", {})
|
||||
token_env = secrets_cfg.get("access_token_env", "BWS_ACCESS_TOKEN")
|
||||
|
||||
token = (args.access_token or "").strip()
|
||||
if not token:
|
||||
token = masked_secret_prompt(f" Paste access token ({token_env}): ").strip()
|
||||
if not token:
|
||||
console.print(" [red]Empty token, aborting.[/red]")
|
||||
token_env = secrets_cfg.get("access_token_env", _DEFAULT_TOKEN_ENV)
|
||||
token = _setup_token(args, console, token_env)
|
||||
if token is None:
|
||||
return 1
|
||||
if not token.startswith("0."):
|
||||
console.print(_NOT_BSM_TOKEN_WARNING_CONTINUING)
|
||||
|
||||
save_env_value(token_env, token)
|
||||
os.environ[token_env] = token # so the test fetch below sees it
|
||||
console.print(f" [green]✓[/green] stored in {get_env_path()} as {token_env}")
|
||||
|
||||
# ------------------------------------------------------------------ region
|
||||
console.print()
|
||||
console.print("[bold]Step 3[/bold] Pick a Bitwarden region")
|
||||
_step(console, 3, "Pick a Bitwarden region")
|
||||
server_url = _resolve_server_url(args, secrets_cfg, console)
|
||||
if server_url is None:
|
||||
return 1
|
||||
if server_url:
|
||||
console.print(f" [green]✓[/green] using {server_url}")
|
||||
else:
|
||||
console.print(
|
||||
" [green]✓[/green] using bws default "
|
||||
"(US Cloud, https://vault.bitwarden.com)"
|
||||
)
|
||||
console.print(f" [green]✓[/green] using {server_url}" if server_url
|
||||
else " [green]✓[/green] using bws default (US Cloud, https://vault.bitwarden.com)")
|
||||
|
||||
# ------------------------------------------------------------------- project
|
||||
project_given = bool(args.project_id and args.project_id.strip())
|
||||
if project_given:
|
||||
project_id = args.project_id.strip()
|
||||
else:
|
||||
console.print()
|
||||
console.print("[bold]Step 4[/bold] Pick a project")
|
||||
projects = _list_projects(binary, token, console, server_url=server_url)
|
||||
if projects is None:
|
||||
return 1
|
||||
if not projects:
|
||||
console.print(" [yellow]No projects visible to this machine account.[/yellow]")
|
||||
console.print(
|
||||
" In the Bitwarden web app, open the machine account → Projects tab "
|
||||
"and grant it access to at least one project."
|
||||
)
|
||||
project_id = (args.project_id or "").strip()
|
||||
project_given = bool(project_id)
|
||||
if not project_given:
|
||||
project_id = _setup_project(binary, token, console, server_url)
|
||||
if project_id is None:
|
||||
return 1
|
||||
|
||||
print_table(console, (("#", {"style": "cyan", "width": 4}), "Name", ("ID", {"style": "dim"})),
|
||||
((str(i), p.get("name", "?"), p.get("id", "?")) for i, p in enumerate(projects, 1)))
|
||||
|
||||
idx = prompt_index(console, f" Select project [1-{len(projects)}]: ", len(projects))
|
||||
project_id = projects[idx - 1]["id"]
|
||||
|
||||
# ------------------------------------------------------------------- test
|
||||
console.print()
|
||||
console.print(f"[bold]Step {4 if project_given else 5}[/bold] Test fetch")
|
||||
_step(console, 4 if project_given else 5, "Test fetch")
|
||||
try:
|
||||
secrets, warnings = bw.fetch_bitwarden_secrets(
|
||||
access_token=token,
|
||||
project_id=project_id,
|
||||
binary=binary,
|
||||
use_cache=False,
|
||||
server_url=server_url,
|
||||
)
|
||||
access_token=token, project_id=project_id, binary=binary, use_cache=False, server_url=server_url)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
console.print(f" [red]✗ Fetch failed: {exc}[/red]")
|
||||
return 1
|
||||
@@ -259,26 +224,18 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
||||
for w in warnings:
|
||||
console.print(f" [yellow]warning:[/yellow] {w}")
|
||||
|
||||
# ------------------------------------------------------------------- save
|
||||
secrets_cfg["enabled"] = True
|
||||
secrets_cfg["project_id"] = project_id
|
||||
secrets_cfg["server_url"] = server_url
|
||||
secrets_cfg.setdefault("access_token_env", token_env)
|
||||
secrets_cfg.setdefault("cache_ttl_seconds", 300)
|
||||
secrets_cfg.setdefault("override_existing", True)
|
||||
secrets_cfg.setdefault("auto_install", True)
|
||||
secrets_cfg.update(enabled=True, project_id=project_id, server_url=server_url)
|
||||
for key, default in (("access_token_env", token_env), ("cache_ttl_seconds", 300),
|
||||
("override_existing", True), ("auto_install", True)):
|
||||
secrets_cfg.setdefault(key, default)
|
||||
save_config(cfg)
|
||||
|
||||
console.print()
|
||||
console.print(
|
||||
"[green]✓ Bitwarden Secrets Manager is enabled.[/green] "
|
||||
"Secrets will be pulled at the start of every Hermes process."
|
||||
)
|
||||
console.print(
|
||||
" Status: [cyan]hermes secrets bitwarden status[/cyan]\n"
|
||||
" Refresh: [cyan]hermes secrets bitwarden sync[/cyan]\n"
|
||||
" Disable: [cyan]hermes secrets bitwarden disable[/cyan]"
|
||||
)
|
||||
console.print("[green]✓ Bitwarden Secrets Manager is enabled.[/green] "
|
||||
"Secrets will be pulled at the start of every Hermes process.")
|
||||
console.print(" Status: [cyan]hermes secrets bitwarden status[/cyan]\n"
|
||||
" Refresh: [cyan]hermes secrets bitwarden sync[/cyan]\n"
|
||||
" Disable: [cyan]hermes secrets bitwarden disable[/cyan]")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -300,31 +257,25 @@ def cmd_status(args: argparse.Namespace) -> int:
|
||||
bw_cfg = _bw_cfg(load_config())
|
||||
|
||||
enabled = bool(bw_cfg.get("enabled"))
|
||||
token_env = bw_cfg.get("access_token_env", "BWS_ACCESS_TOKEN")
|
||||
token_env = bw_cfg.get("access_token_env", _DEFAULT_TOKEN_ENV)
|
||||
project_id = bw_cfg.get("project_id", "")
|
||||
server_url = cfg_str(bw_cfg, "server_url")
|
||||
token = os.environ.get(token_env, "").strip()
|
||||
token_set = bool(token)
|
||||
binary = bw.find_bws(install_if_missing=False)
|
||||
token_validation, validation_messages = _token_validation_status(
|
||||
enabled=enabled,
|
||||
binary=binary,
|
||||
token=token,
|
||||
server_url=server_url,
|
||||
)
|
||||
enabled=enabled, binary=binary, token=token, server_url=server_url)
|
||||
|
||||
print_status_panel(console, "Bitwarden Secrets Manager", (
|
||||
("Enabled", _yn(enabled)),
|
||||
("Token env var", token_env),
|
||||
("Token in env", _yn(token_set)),
|
||||
("Token in env", _yn(bool(token))),
|
||||
("Token validation", token_validation),
|
||||
("Project ID", project_id or "[dim](unset)[/dim]"),
|
||||
("Server URL", server_url or "[dim]default (US Cloud, https://vault.bitwarden.com)[/dim]"),
|
||||
("Override existing", _yn(bool(bw_cfg.get("override_existing", False)))),
|
||||
("Cache TTL (s)", str(bw_cfg.get("cache_ttl_seconds", 300))),
|
||||
("Auto-install", _yn(bool(bw_cfg.get("auto_install", True)))),
|
||||
("bws binary",
|
||||
f"{binary} ({_bws_version(binary)})" if binary else "[yellow]not installed[/yellow]"),
|
||||
("bws binary", f"{binary} ({_bws_version(binary)})" if binary else "[yellow]not installed[/yellow]"),
|
||||
))
|
||||
for message in validation_messages:
|
||||
console.print(message)
|
||||
@@ -332,29 +283,22 @@ def cmd_status(args: argparse.Namespace) -> int:
|
||||
if not enabled:
|
||||
console.print("\n Run [cyan]hermes secrets bitwarden setup[/cyan] to enable.")
|
||||
return 0
|
||||
if not token_set:
|
||||
console.print(
|
||||
f"\n [yellow]Enabled but {token_env} is not set — Hermes will skip BSM "
|
||||
"and warn on next startup.[/yellow]"
|
||||
)
|
||||
if not token:
|
||||
console.print(f"\n [yellow]Enabled but {token_env} is not set — Hermes will skip BSM "
|
||||
"and warn on next startup.[/yellow]")
|
||||
if not project_id:
|
||||
console.print(
|
||||
"\n [yellow]Enabled but no project_id — nothing to fetch.[/yellow]"
|
||||
)
|
||||
console.print("\n [yellow]Enabled but no project_id — nothing to fetch.[/yellow]")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_token(args: argparse.Namespace) -> int:
|
||||
"""Rotate the BSM access token without re-running the whole setup wizard.
|
||||
|
||||
Prompts for (or accepts via ``--access-token``) a new machine-account token, probes Bitwarden
|
||||
with it (unless ``--no-verify``), and only then persists it to .env — so a bad paste never
|
||||
bricks the working token.
|
||||
"""
|
||||
"""Rotate the BSM access token without re-running the whole setup wizard: probe Bitwarden
|
||||
with the new token (unless ``--no-verify``) and only then persist it, so a bad paste never
|
||||
bricks the working token."""
|
||||
bw = _load_bw()
|
||||
console = Console()
|
||||
bw_cfg = _bw_cfg(load_config())
|
||||
token_env = bw_cfg.get("access_token_env", "BWS_ACCESS_TOKEN")
|
||||
token_env = bw_cfg.get("access_token_env", _DEFAULT_TOKEN_ENV)
|
||||
server_url = cfg_str(bw_cfg, "server_url")
|
||||
|
||||
def verify(token: str) -> bool:
|
||||
@@ -364,30 +308,22 @@ def cmd_token(args: argparse.Namespace) -> int:
|
||||
return True
|
||||
binary = bw.find_bws(install_if_missing=True)
|
||||
if binary is None:
|
||||
console.print(
|
||||
"[red]bws binary not available — cannot verify. "
|
||||
"Re-run with --no-verify to store anyway.[/red]"
|
||||
)
|
||||
console.print("[red]bws binary not available — cannot verify. "
|
||||
"Re-run with --no-verify to store anyway.[/red]")
|
||||
return False
|
||||
console.print("Verifying against Bitwarden…")
|
||||
projects = _list_projects(binary, token, console, server_url=server_url)
|
||||
if projects is None:
|
||||
console.print(
|
||||
"[red]✗ New token was rejected — nothing was changed.[/red]"
|
||||
)
|
||||
console.print("[red]✗ New token was rejected — nothing was changed.[/red]")
|
||||
return False
|
||||
console.print(
|
||||
f"[green]✓ Token accepted[/green] "
|
||||
f"({len(projects)} project{'s' if len(projects) != 1 else ''} visible)."
|
||||
)
|
||||
console.print(f"[green]✓ Token accepted[/green] "
|
||||
f"({len(projects)} project{'s' if len(projects) != 1 else ''} visible).")
|
||||
project_id = str(bw_cfg.get("project_id", "") or "")
|
||||
if project_id and projects and project_id not in {p["id"] for p in projects}:
|
||||
console.print(
|
||||
f"[yellow]Warning: configured project {project_id} is not visible "
|
||||
"to this machine account. Grant it access in the Bitwarden web "
|
||||
"app or re-run `hermes secrets bitwarden setup` to pick a "
|
||||
"different project.[/yellow]"
|
||||
)
|
||||
"app or re-run `hermes secrets bitwarden setup` to pick a different project.[/yellow]")
|
||||
return True
|
||||
|
||||
return rotate_token(
|
||||
@@ -405,8 +341,7 @@ def cmd_token(args: argparse.Namespace) -> int:
|
||||
"[yellow]Note: the Bitwarden integration is currently disabled — "
|
||||
"run `hermes secrets bitwarden setup` (or set "
|
||||
"secrets.bitwarden.enabled: true) to turn it on.[/yellow]"
|
||||
),
|
||||
)
|
||||
))
|
||||
|
||||
|
||||
def cmd_sync(args: argparse.Namespace) -> int:
|
||||
@@ -416,25 +351,19 @@ def cmd_sync(args: argparse.Namespace) -> int:
|
||||
if not require_enabled(console, bw_cfg, "Bitwarden", "bitwarden"):
|
||||
return 1
|
||||
|
||||
token_env = bw_cfg.get("access_token_env", "BWS_ACCESS_TOKEN")
|
||||
token_env = bw_cfg.get("access_token_env", _DEFAULT_TOKEN_ENV)
|
||||
token = os.environ.get(token_env, "").strip()
|
||||
if not token:
|
||||
console.print(f"[red]{token_env} is not set.[/red]")
|
||||
return 1
|
||||
|
||||
project_id = bw_cfg.get("project_id", "")
|
||||
if not project_id:
|
||||
console.print("[red]No project_id configured.[/red]")
|
||||
return 1
|
||||
|
||||
server_url = cfg_str(bw_cfg, "server_url")
|
||||
|
||||
try:
|
||||
secrets, warnings = bw.fetch_bitwarden_secrets(
|
||||
access_token=token,
|
||||
project_id=project_id,
|
||||
use_cache=False,
|
||||
server_url=server_url,
|
||||
access_token=token, project_id=project_id, use_cache=False, server_url=cfg_str(bw_cfg, "server_url"),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
console.print(f"[red]Fetch failed: {exc}[/red]")
|
||||
@@ -448,28 +377,25 @@ def cmd_sync(args: argparse.Namespace) -> int:
|
||||
rows = []
|
||||
applied = 0
|
||||
for key in sorted(secrets):
|
||||
if key == token_env:
|
||||
rows.append((key, "[dim]skip (bootstrap token)[/dim]"))
|
||||
continue
|
||||
already = bool(os.environ.get(key))
|
||||
if already and not override:
|
||||
rows.append((key, "[dim]skip (already set)[/dim]"))
|
||||
continue
|
||||
if args.apply:
|
||||
if key == token_env:
|
||||
action = "[dim]skip (bootstrap token)[/dim]"
|
||||
elif already and not override:
|
||||
action = "[dim]skip (already set)[/dim]"
|
||||
elif args.apply:
|
||||
os.environ[key] = secrets[key]
|
||||
applied += 1
|
||||
rows.append((key, "[green]exported[/green]" + (" (overrode)" if already else "")))
|
||||
action = "[green]exported[/green]" + (" (overrode)" if already else "")
|
||||
else:
|
||||
rows.append((key, "[green]would export[/green]" + (" (overrides)" if already else "")))
|
||||
action = "[green]would export[/green]" + (" (overrides)" if already else "")
|
||||
rows.append((key, action))
|
||||
|
||||
print_table(console, (("Name", {"style": "cyan"}), "Action"), rows, warnings)
|
||||
|
||||
if not args.apply:
|
||||
console.print(
|
||||
"\n This was a dry-run — secrets are picked up automatically on the "
|
||||
"next [cyan]hermes[/cyan] invocation. Re-run with [cyan]--apply[/cyan] "
|
||||
"to export into the current shell instead."
|
||||
)
|
||||
console.print("\n This was a dry-run — secrets are picked up automatically on the "
|
||||
"next [cyan]hermes[/cyan] invocation. Re-run with [cyan]--apply[/cyan] "
|
||||
"to export into the current shell instead.")
|
||||
else:
|
||||
console.print(f"\n [green]Exported {applied} secret(s) into current process.[/green]")
|
||||
return 0
|
||||
@@ -481,8 +407,7 @@ def cmd_disable(args: argparse.Namespace) -> int:
|
||||
"[green]Disabled.[/green] Bitwarden secrets will NOT be pulled on the next "
|
||||
"Hermes invocation.\n"
|
||||
" Your access token is left in .env — remove it manually if you also want "
|
||||
"to revoke the credential.",
|
||||
)
|
||||
"to revoke the credential.")
|
||||
|
||||
|
||||
def cmd_install(args: argparse.Namespace) -> int:
|
||||
@@ -503,27 +428,19 @@ def cmd_install(args: argparse.Namespace) -> int:
|
||||
|
||||
|
||||
def _token_validation_status(
|
||||
*,
|
||||
enabled: bool,
|
||||
binary: Optional[Path],
|
||||
token: str,
|
||||
server_url: str = "",
|
||||
*, enabled: bool, binary: Optional[Path], token: str, server_url: str = "",
|
||||
) -> tuple[str, list[str]]:
|
||||
if not enabled:
|
||||
return "[dim]not checked[/dim] (integration disabled)", []
|
||||
if not token:
|
||||
return "[dim]not checked[/dim] (token missing)", []
|
||||
if binary is None:
|
||||
return "[dim]not checked[/dim] (bws not installed)", []
|
||||
for skipped, reason in ((not enabled, "integration disabled"), (not token, "token missing"),
|
||||
(binary is None, "bws not installed")):
|
||||
if skipped:
|
||||
return f"[dim]not checked[/dim] ({reason})", []
|
||||
|
||||
messages: list[str] = []
|
||||
if not token.startswith("0."):
|
||||
messages.append(_NOT_BSM_TOKEN_WARNING_CONTINUING)
|
||||
|
||||
capture = io.StringIO()
|
||||
probe_console = Console(file=capture, record=True, width=200)
|
||||
projects = _list_projects(binary, token, probe_console, server_url=server_url)
|
||||
if projects is None:
|
||||
probe_console = Console(file=io.StringIO(), record=True, width=200)
|
||||
if _list_projects(binary, token, probe_console, server_url=server_url) is None:
|
||||
details = probe_console.export_text(styles=False).strip()
|
||||
if details:
|
||||
messages.extend(line.rstrip() for line in details.splitlines())
|
||||
@@ -531,6 +448,20 @@ def _token_validation_status(
|
||||
return "[green]passed[/green]", messages
|
||||
|
||||
|
||||
# (substring of the lowercased bws error, follow-up hint) — first match wins.
|
||||
_PROJECT_LIST_HINTS = (
|
||||
(("invalid_client", "400 bad request"),
|
||||
" [yellow]'invalid_client' from the US identity endpoint usually "
|
||||
"means the token is for a different Bitwarden region. Re-run "
|
||||
"[cyan]hermes secrets bitwarden setup[/cyan] and pick EU or "
|
||||
"self-hosted at the region prompt, or set [cyan]secrets.bitwarden."
|
||||
"server_url[/cyan] in config.yaml.[/yellow]"),
|
||||
(("authorization", "invalid"),
|
||||
" [yellow]This usually means the access token is wrong or revoked. "
|
||||
"Double-check it in the Bitwarden web app.[/yellow]"),
|
||||
)
|
||||
|
||||
|
||||
def _list_projects(
|
||||
binary: Path, token: str, console: Console, *, server_url: str = ""
|
||||
) -> Optional[List[dict]]:
|
||||
@@ -542,11 +473,7 @@ def _list_projects(
|
||||
try:
|
||||
res = subprocess.run(
|
||||
[str(binary), "project", "list", "--output", "json"],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True, encoding='utf-8', errors='replace',
|
||||
timeout=15,
|
||||
)
|
||||
env=env, capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=15)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
console.print(f" [red]Couldn't list projects: {exc}[/red]")
|
||||
return None
|
||||
@@ -555,19 +482,10 @@ def _list_projects(
|
||||
err = (res.stderr or res.stdout).strip()[:300]
|
||||
console.print(f" [red]bws project list failed: {err}[/red]")
|
||||
lowered = err.lower()
|
||||
if "invalid_client" in lowered or "400 bad request" in lowered:
|
||||
console.print(
|
||||
" [yellow]'invalid_client' from the US identity endpoint usually "
|
||||
"means the token is for a different Bitwarden region. Re-run "
|
||||
"[cyan]hermes secrets bitwarden setup[/cyan] and pick EU or "
|
||||
"self-hosted at the region prompt, or set [cyan]secrets.bitwarden."
|
||||
"server_url[/cyan] in config.yaml.[/yellow]"
|
||||
)
|
||||
elif "authorization" in lowered or "invalid" in lowered:
|
||||
console.print(
|
||||
" [yellow]This usually means the access token is wrong or revoked. "
|
||||
"Double-check it in the Bitwarden web app.[/yellow]"
|
||||
)
|
||||
for needles, hint in _PROJECT_LIST_HINTS:
|
||||
if any(n in lowered for n in needles):
|
||||
console.print(hint)
|
||||
break
|
||||
return None
|
||||
|
||||
try:
|
||||
@@ -580,9 +498,7 @@ def _list_projects(
|
||||
return [p for p in data if isinstance(p, dict) and p.get("id")]
|
||||
|
||||
|
||||
# Canonical Bitwarden region endpoints. Keep in sync with what Bitwarden
|
||||
# publishes — these are stable but if a third region appears, add it here
|
||||
# and to the prompt below.
|
||||
# Canonical Bitwarden region endpoints; add a new region here and it appears in the prompt.
|
||||
_REGION_PRESETS = [
|
||||
("US Cloud (https://vault.bitwarden.com — bws default)", ""),
|
||||
("EU Cloud (https://vault.bitwarden.eu)", "https://vault.bitwarden.eu"),
|
||||
@@ -590,66 +506,44 @@ _REGION_PRESETS = [
|
||||
|
||||
|
||||
def _resolve_server_url(
|
||||
args: argparse.Namespace,
|
||||
secrets_cfg: dict,
|
||||
console: Console,
|
||||
args: argparse.Namespace, secrets_cfg: dict, console: Console,
|
||||
) -> Optional[str]:
|
||||
"""Pick a Bitwarden server URL for setup.
|
||||
|
||||
Resolution order: 1. ``--server-url`` CLI flag (non-interactive) 2. ``BWS_SERVER_URL`` env var
|
||||
(so users running with that already set in their shell don't have to re-enter it) 3. Existing
|
||||
``secrets.bitwarden.server_url`` value (for re-runs) 4. Interactive menu: US / EU / self-hosted
|
||||
"""
|
||||
"""Pick a Bitwarden server URL: ``--server-url``, then ``BWS_SERVER_URL``, then the existing
|
||||
``secrets.bitwarden.server_url``, then the interactive US / EU / self-hosted menu. None (after
|
||||
printing) when a custom URL is left empty."""
|
||||
if args.server_url and args.server_url.strip():
|
||||
return args.server_url.strip()
|
||||
|
||||
env_url = os.environ.get("BWS_SERVER_URL", "").strip()
|
||||
if env_url:
|
||||
console.print(
|
||||
f" Detected [cyan]BWS_SERVER_URL[/cyan]={env_url} in your shell — using it."
|
||||
)
|
||||
console.print(f" Detected [cyan]BWS_SERVER_URL[/cyan]={env_url} in your shell — using it.")
|
||||
return env_url
|
||||
|
||||
existing = cfg_str(secrets_cfg, "server_url")
|
||||
if existing:
|
||||
console.print(
|
||||
f" Existing config: [cyan]{existing}[/cyan]. "
|
||||
"Press Enter to keep, or pick a different option below."
|
||||
)
|
||||
console.print(f" Existing config: [cyan]{existing}[/cyan]. "
|
||||
"Press Enter to keep, or pick a different option below.")
|
||||
|
||||
table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2))
|
||||
table.add_column("#", style="cyan", width=4)
|
||||
table.add_column("Region / endpoint")
|
||||
for i, (label, _url) in enumerate(_REGION_PRESETS, 1):
|
||||
table.add_row(str(i), label)
|
||||
table.add_row(str(len(_REGION_PRESETS) + 1), "Self-hosted / custom URL")
|
||||
custom_idx = len(_REGION_PRESETS) + 1
|
||||
table.add_row(str(custom_idx), "Self-hosted / custom URL")
|
||||
console.print(table)
|
||||
|
||||
custom_idx = len(_REGION_PRESETS) + 1
|
||||
prompt = f" Select region [1-{custom_idx}]"
|
||||
if existing:
|
||||
prompt += " (Enter to keep current)"
|
||||
idx = prompt_index(
|
||||
console,
|
||||
prompt + ": ",
|
||||
custom_idx,
|
||||
allow_empty=bool(existing),
|
||||
empty_message=" [red]Enter a number.[/red]",
|
||||
)
|
||||
prompt = f" Select region [1-{custom_idx}]" + (" (Enter to keep current)" if existing else "")
|
||||
idx = prompt_index(console, prompt + ": ", custom_idx, allow_empty=bool(existing),
|
||||
empty_message=" [red]Enter a number.[/red]")
|
||||
if idx == 0:
|
||||
return existing
|
||||
if idx <= len(_REGION_PRESETS):
|
||||
return _REGION_PRESETS[idx - 1][1]
|
||||
custom = console.input(
|
||||
" Enter your Bitwarden server URL "
|
||||
"(e.g. https://vault.example.com): "
|
||||
).strip()
|
||||
custom = console.input(" Enter your Bitwarden server URL (e.g. https://vault.example.com): ").strip()
|
||||
if not custom:
|
||||
console.print(" [red]Empty URL, aborting.[/red]")
|
||||
return None
|
||||
if not custom.startswith(("http://", "https://")):
|
||||
console.print(
|
||||
" [yellow]Warning: URL doesn't start with http:// or "
|
||||
"https:// — bws may reject it.[/yellow]"
|
||||
)
|
||||
console.print(" [yellow]Warning: URL doesn't start with http:// or https:// — bws may reject it.[/yellow]")
|
||||
return custom
|
||||
|
||||
+37
-101
@@ -24,16 +24,9 @@ def _fail(msg: str, exit_code: int | None = None) -> int:
|
||||
return _FAILURE_EXIT
|
||||
|
||||
|
||||
def _read_message_body(
|
||||
positional: Optional[str],
|
||||
file_path: Optional[str],
|
||||
) -> Optional[str]:
|
||||
"""Resolve the message body from the positional arg, ``--file``, or piped stdin.
|
||||
|
||||
Order: explicit positional argument, then ``--file PATH`` / ``--file -`` (stdin), then piped
|
||||
stdin when not attached to a TTY. Returns ``None`` when nothing is available — callers must
|
||||
treat that as a usage error.
|
||||
"""
|
||||
def _read_message_body(positional: Optional[str], file_path: Optional[str]) -> Optional[str]:
|
||||
"""Resolve the message body: positional arg, then ``--file PATH`` / ``--file -`` (stdin), then
|
||||
piped stdin when not attached to a TTY. ``None`` when nothing is available (a usage error)."""
|
||||
if positional:
|
||||
return positional
|
||||
|
||||
@@ -52,33 +45,21 @@ def _read_message_body(
|
||||
f' hermes send --to telegram "optional caption MEDIA:{file_path}"\n'
|
||||
"Add [[as_document]] to deliver an image as an uncompressed file:\n"
|
||||
f' hermes send --to telegram "[[as_document]] MEDIA:{file_path}"',
|
||||
_USAGE_EXIT,
|
||||
)
|
||||
_USAGE_EXIT)
|
||||
except OSError as exc:
|
||||
_fail(f"hermes send: cannot read {file_path}: {exc}", _USAGE_EXIT)
|
||||
|
||||
# Piped input: only consume stdin when it is not a TTY. Reading from a
|
||||
# TTY would block the user in a half-broken "type your message" state,
|
||||
# which is a poor default for an ops CLI.
|
||||
# Reading from a TTY would block the user in a half-broken "type your message" state.
|
||||
return (sys.stdin.read() or None) if not sys.stdin.isatty() else None
|
||||
|
||||
|
||||
def _emit_result(
|
||||
result_json: str,
|
||||
*,
|
||||
json_mode: bool,
|
||||
quiet: bool,
|
||||
) -> int:
|
||||
"""Print the tool result in the requested format and return the exit code.
|
||||
|
||||
The underlying ``send_message_tool`` always returns a JSON string. We parse it, decide
|
||||
success/failure, and format accordingly.
|
||||
"""
|
||||
def _emit_result(result_json: str, *, json_mode: bool, quiet: bool) -> int:
|
||||
"""Print the ``send_message_tool`` JSON result in the requested format; return the exit code.
|
||||
Unknown / unexpected shapes are failures so scripts notice."""
|
||||
try:
|
||||
payload = json.loads(result_json) if result_json else {}
|
||||
except json.JSONDecodeError:
|
||||
# Shouldn't happen with the shared tool, but be defensive — pass the
|
||||
# raw string through so the user can still see what went wrong.
|
||||
# Pass the raw string through so the user can still see what went wrong.
|
||||
payload = {"error": "invalid JSON from send_message_tool", "raw": result_json}
|
||||
|
||||
if json_mode:
|
||||
@@ -89,21 +70,16 @@ def _emit_result(
|
||||
elif payload.get("success"):
|
||||
print(payload.get("note") or "sent")
|
||||
else:
|
||||
# Unknown shape — dump it so nothing is silently dropped.
|
||||
print(json.dumps(payload, indent=2))
|
||||
print(json.dumps(payload, indent=2)) # unknown shape — dump it, drop nothing
|
||||
|
||||
# Unknown / unexpected shapes are failures so scripts notice.
|
||||
if not payload.get("error") and (payload.get("skipped") or payload.get("success")):
|
||||
return _SUCCESS_EXIT
|
||||
return _FAILURE_EXIT
|
||||
|
||||
|
||||
def _list_targets(platform_filter: Optional[str], *, json_mode: bool) -> int:
|
||||
"""Print the channel directory (all configured targets across platforms).
|
||||
|
||||
Uses ``load_directory()`` for JSON and ``format_directory_for_display()`` for the human
|
||||
rendering the send_message tool shows the model, keeping the two surfaces identical.
|
||||
"""
|
||||
"""Print the channel directory (all configured targets across platforms), reusing the
|
||||
``format_directory_for_display`` rendering the send_message tool shows the model."""
|
||||
try:
|
||||
from gateway.channel_directory import format_directory_for_display, load_directory
|
||||
except Exception as exc:
|
||||
@@ -116,24 +92,16 @@ def _list_targets(platform_filter: Optional[str], *, json_mode: bool) -> int:
|
||||
|
||||
platforms = dict(raw.get("platforms") or {})
|
||||
|
||||
# Merge in configured-but-undiscovered platforms so `--list` never hides
|
||||
# a working send target. The directory only contains platforms the
|
||||
# gateway has discovered channels for; a platform configured via env /
|
||||
# config.yaml that has never run channel discovery (e.g. a fresh SimpleX
|
||||
# setup used only for outbound `hermes send`) would otherwise be
|
||||
# invisible, leaving users guessing at platform names.
|
||||
# Merge in configured-but-undiscovered platforms (e.g. a fresh SimpleX setup used only for
|
||||
# outbound sends) so `--list` never hides a working send target.
|
||||
try:
|
||||
from gateway.config import load_gateway_config
|
||||
|
||||
gw_config = load_gateway_config()
|
||||
for plat in gw_config.get_connected_platforms():
|
||||
for plat in load_gateway_config().get_connected_platforms():
|
||||
plat_name = getattr(plat, "value", str(plat))
|
||||
if plat_name not in ("local", "api_server", "webhook"):
|
||||
platforms.setdefault(plat_name, [])
|
||||
except Exception:
|
||||
# Directory contents alone are still useful; don't fail --list over
|
||||
# a config parse problem.
|
||||
pass
|
||||
pass # directory contents alone are still useful; don't fail --list on a config problem
|
||||
|
||||
if platform_filter:
|
||||
key = platform_filter.strip().lower()
|
||||
@@ -141,8 +109,7 @@ def _list_targets(platform_filter: Optional[str], *, json_mode: bool) -> int:
|
||||
if not filtered:
|
||||
return _fail(
|
||||
f"hermes send: no targets found for platform '{platform_filter}'. "
|
||||
f"Configured: {', '.join(sorted(platforms)) or '(none)'}"
|
||||
)
|
||||
f"Configured: {', '.join(sorted(platforms)) or '(none)'}")
|
||||
platforms = filtered
|
||||
|
||||
if json_mode:
|
||||
@@ -155,9 +122,7 @@ def _list_targets(platform_filter: Optional[str], *, json_mode: bool) -> int:
|
||||
print("channel discovery can populate ~/.hermes/channel_directory.json.")
|
||||
return _SUCCESS_EXIT
|
||||
|
||||
# Human display — when unfiltered, reuse the shared formatter the agent
|
||||
# already sees (passing the merged view so configured-but-undiscovered
|
||||
# platforms are listed too). When filtered, build a minimal view ourselves.
|
||||
# Unfiltered: the shared formatter over the merged view. Filtered: a minimal view of our own.
|
||||
if platform_filter is None:
|
||||
print(format_directory_for_display(platforms))
|
||||
return _SUCCESS_EXIT
|
||||
@@ -180,10 +145,7 @@ def _list_targets(platform_filter: Optional[str], *, json_mode: bool) -> int:
|
||||
|
||||
def _load_hermes_env() -> None:
|
||||
"""Populate ``os.environ`` from ``~/.hermes/.env`` AND bridge top-level ``config.yaml`` keys into
|
||||
the environment so the underlying gateway config loader sees platform credentials and home
|
||||
channel IDs.
|
||||
"""
|
||||
# Step 1: dotenv
|
||||
the environment so the gateway config loader sees platform credentials and home channels."""
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
except Exception:
|
||||
@@ -198,18 +160,14 @@ def _load_hermes_env() -> None:
|
||||
env_path = home / ".env"
|
||||
if load_dotenv and env_path.exists():
|
||||
try:
|
||||
# utf-8-sig strips a leading UTF-8 BOM if present (PowerShell 5.1
|
||||
# Set-Content -Encoding UTF8 / Notepad) and is a no-op for
|
||||
# BOM-less UTF-8. Plain "utf-8" would keep U+FEFF on the first
|
||||
# key name and silently drop it from os.environ under its
|
||||
# canonical name.
|
||||
# utf-8-sig strips a leading BOM (PowerShell 5.1 / Notepad); plain "utf-8" would keep
|
||||
# U+FEFF on the first key name and silently drop it from os.environ.
|
||||
load_dotenv(str(env_path), override=True, encoding="utf-8-sig")
|
||||
except UnicodeDecodeError:
|
||||
try:
|
||||
# utf-8-sig can't strip a BOM once we fall back to latin-1.
|
||||
import codecs
|
||||
import io
|
||||
|
||||
raw = env_path.read_bytes()
|
||||
if raw.startswith(codecs.BOM_UTF8):
|
||||
raw = raw[len(codecs.BOM_UTF8) :]
|
||||
@@ -219,17 +177,14 @@ def _load_hermes_env() -> None:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Step 2: bridge top-level config.yaml values into the environment so
|
||||
# gateway.config.load_gateway_config() sees them. Scalars only; don't
|
||||
# override values already in the env.
|
||||
# Bridge top-level config.yaml scalars into the environment (never overriding existing values).
|
||||
import os
|
||||
config_path = home / "config.yaml"
|
||||
if not config_path.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
# Presence-sensitive env bridge: raw read is deliberate — only keys
|
||||
# the user actually wrote get bridged. Overlay + expansion below.
|
||||
# Raw read is deliberate — only keys the user actually wrote get bridged.
|
||||
from hermes_cli.config import read_user_config_raw
|
||||
raw = read_user_config_raw(config_path)
|
||||
except Exception:
|
||||
@@ -241,8 +196,7 @@ def _load_hermes_env() -> None:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Managed scope: overlay administrator-pinned values before bridging to env,
|
||||
# so a managed top-level scalar wins here too. Fail-open via the helper.
|
||||
# Managed scope: administrator-pinned values win here too (fail-open via the helper).
|
||||
try:
|
||||
from hermes_cli import managed_scope
|
||||
raw = managed_scope.apply_managed_overlay(raw if isinstance(raw, dict) else {})
|
||||
@@ -259,18 +213,11 @@ def _load_hermes_env() -> None:
|
||||
|
||||
def cmd_send(args: argparse.Namespace) -> None:
|
||||
"""Entry point wired into the top-level argparse dispatcher."""
|
||||
_load_hermes_env() # the downstream gateway config loader reads credentials from os.environ
|
||||
|
||||
# Bridge ~/.hermes/.env and ~/.hermes/config.yaml into os.environ so the
|
||||
# gateway config loader (invoked downstream by send_message_tool and by
|
||||
# the channel directory) can see platform credentials and home channels.
|
||||
_load_hermes_env()
|
||||
|
||||
# --list short-circuits everything else.
|
||||
if getattr(args, "list_targets", False):
|
||||
# When `--list telegram` is used, argparse stores "telegram" in the
|
||||
# `message` positional (since list_targets takes no argument).
|
||||
platform_filter = getattr(args, "message", None)
|
||||
exit_code = _list_targets(platform_filter, json_mode=getattr(args, "json", False))
|
||||
if getattr(args, "list_targets", False): # --list short-circuits everything else
|
||||
# `hermes send --list telegram` lands "telegram" in the `message` positional.
|
||||
exit_code = _list_targets(getattr(args, "message", None), json_mode=getattr(args, "json", False))
|
||||
sys.exit(exit_code)
|
||||
|
||||
target = (getattr(args, "to", None) or "").strip()
|
||||
@@ -281,32 +228,25 @@ def cmd_send(args: argparse.Namespace) -> None:
|
||||
" hermes send --to telegram \"hello\"\n"
|
||||
" hermes send --to discord:#ops --file report.md\n"
|
||||
" hermes send --list # list available targets",
|
||||
_USAGE_EXIT,
|
||||
)
|
||||
_USAGE_EXIT)
|
||||
|
||||
message = _read_message_body(getattr(args, "message", None), getattr(args, "file", None))
|
||||
if message is None or not message.strip():
|
||||
_fail(
|
||||
"hermes send: no message provided. Pass text as a positional "
|
||||
"argument, use --file PATH, or pipe data via stdin.",
|
||||
_USAGE_EXIT,
|
||||
)
|
||||
_USAGE_EXIT)
|
||||
|
||||
# Optional: prepend a subject line. Useful for alerting scripts that
|
||||
# want a consistent header without inlining it into every call.
|
||||
# Optional subject line: a consistent header for alerting scripts.
|
||||
subject = getattr(args, "subject", None)
|
||||
if subject:
|
||||
message = f"{subject}\n\n{message.lstrip()}"
|
||||
|
||||
# Import lazily so `hermes send --help` stays fast and does not pull in
|
||||
# the full tool registry / gateway config stack.
|
||||
# Lazy import keeps `hermes send --help` fast (no tool registry / gateway config stack).
|
||||
from tools.send_message_tool import send_message_tool
|
||||
|
||||
# send_message_tool auto-loads gateway config + env and routes to the
|
||||
# appropriate platform adapter (bot-token path for Telegram/Discord/Slack/
|
||||
# Signal/SMS/WhatsApp; live-adapter path for plugin platforms).
|
||||
#
|
||||
# It expects the standard tool-call dict and returns a JSON string.
|
||||
# Routes to the platform adapter (bot-token path for built-ins, live-adapter path for plugin
|
||||
# platforms); takes the standard tool-call dict and returns a JSON string.
|
||||
result = send_message_tool({"action": "send", "target": target, "message": message})
|
||||
sys.exit(_emit_result(result, json_mode=getattr(args, "json", False), quiet=getattr(args, "quiet", False)))
|
||||
|
||||
@@ -320,19 +260,16 @@ _SEND_ARGUMENTS = (
|
||||
"Delivery target. Format: 'platform' (home channel), "
|
||||
"'platform:chat_id', 'platform:chat_id:thread_id', or "
|
||||
"'platform:#channel-name'. Examples: telegram, "
|
||||
"telegram:-1001234567890:17585, discord:#ops, slack:C0123ABCD, "
|
||||
"signal:+15551234567."
|
||||
"telegram:-1001234567890:17585, discord:#ops, slack:C0123ABCD, signal:+15551234567."
|
||||
),
|
||||
)),
|
||||
(("message",), dict(nargs="?", default=None, help="Message text. If omitted, read from --file or stdin.")),
|
||||
# Legacy / convenience positional removed — use --to for clarity.
|
||||
(("-f", "--file"), dict(
|
||||
metavar="PATH",
|
||||
default=None,
|
||||
help=(
|
||||
"Read message body from PATH (text only). Use '-' to force stdin. "
|
||||
"To send an image/document as an attachment, use MEDIA:<path> in "
|
||||
"the message text instead."
|
||||
"To send an image/document as an attachment, use MEDIA:<path> in the message text instead."
|
||||
),
|
||||
)),
|
||||
(("-s", "--subject"), dict(metavar="LINE", default=None, help="Prepend a subject/header line before the message body.")),
|
||||
@@ -369,8 +306,7 @@ def register_send_subparser(subparsers) -> argparse.ArgumentParser:
|
||||
"\n"
|
||||
"Exit codes: 0 ok, 1 delivery/backend error, 2 usage error."
|
||||
),
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
|
||||
for flags, kwargs in _SEND_ARGUMENTS:
|
||||
parser.add_argument(*flags, **kwargs)
|
||||
|
||||
+158
-268
@@ -1,10 +1,9 @@
|
||||
"""Interactive setup wizard for Hermes Agent (config lives in ~/.hermes/).
|
||||
|
||||
Independently-runnable sections: Model & Provider, Terminal Backend, Agent Settings,
|
||||
Messaging Platforms, Tools (TTS, web search, image generation, ...). Section bodies live in
|
||||
sibling modules (setup_tts, setup_terminal, setup_platforms, setup_summary, setup_migration,
|
||||
setup_quick) and are re-exported here; they resolve shared prompt/config helpers lazily through
|
||||
this module so test patches on ``hermes_cli.setup.<name>`` keep working.
|
||||
Independently-runnable sections: Model & Provider, Terminal Backend, Agent Settings, Messaging
|
||||
Platforms, Tools. Section bodies live in sibling setup_* modules and are re-exported here; they
|
||||
resolve shared prompt/config helpers lazily through this module so test patches on
|
||||
``hermes_cli.setup.<name>`` keep working.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
@@ -21,39 +20,46 @@ from typing import Callable
|
||||
from hermes_cli.curses_ui import MenuNavigationEvent, MenuNavigationStart
|
||||
from hermes_cli.nous_subscription import get_nous_subscription_features # noqa: F401 (re-export; patched by tests)
|
||||
from tools.tool_backend_helpers import managed_nous_tools_enabled # noqa: F401 (re-export; patched by tests)
|
||||
# Config helpers are re-exported (tests patch them on this module). display_hermes_home is
|
||||
# imported lazily at call sites (stale-module safety during hermes update).
|
||||
from hermes_cli.config import (
|
||||
cfg_get, DEFAULT_CONFIG, get_hermes_home, get_config_path, get_env_path, load_config, save_config,
|
||||
save_env_value, remove_env_value, get_env_value, ensure_hermes_home,
|
||||
)
|
||||
from hermes_cli.colors import Colors, color
|
||||
from hermes_cli.cli_output import print_error, print_info, print_success, print_warning
|
||||
from hermes_cli.secret_prompt import masked_secret_prompt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
|
||||
_DOCS_BASE = "https://hermes-agent.nousresearch.com/docs"
|
||||
_BRACKETED_PASTE_PATTERN = re.compile(r"\x1b\[\s*200~|\x1b\[\s*201~")
|
||||
|
||||
|
||||
# Config helpers (re-exported; tests patch them on this module). display_hermes_home is
|
||||
# imported lazily at call sites (stale-module safety during hermes update).
|
||||
from hermes_cli.config import ( # noqa: E402
|
||||
cfg_get, DEFAULT_CONFIG, get_hermes_home, get_config_path, get_env_path, load_config, save_config,
|
||||
save_env_value, remove_env_value, get_env_value, ensure_hermes_home,
|
||||
)
|
||||
from hermes_cli.colors import Colors, color # noqa: E402
|
||||
|
||||
|
||||
def print_header(title: str):
|
||||
"""Print a section header."""
|
||||
def print_header(title: str, *, gap: bool = False):
|
||||
"""Print a section header (``gap`` adds an extra blank line before it)."""
|
||||
if gap:
|
||||
print()
|
||||
print()
|
||||
print(color(f"◆ {title}", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
|
||||
from hermes_cli.cli_output import print_error, print_info, print_success, print_warning # noqa: E402
|
||||
from hermes_cli.secret_prompt import masked_secret_prompt # noqa: E402
|
||||
|
||||
|
||||
def _info(*lines: str | None) -> None:
|
||||
"""print_info each line in order; ``None`` emits a bare blank ``print()``."""
|
||||
for line in lines:
|
||||
print() if line is None else print_info(line)
|
||||
|
||||
|
||||
def _sub_dict(parent: dict, key: str) -> dict:
|
||||
"""``parent[key]`` as a dict, replacing a missing or non-dict value with ``{}``."""
|
||||
child = parent.get(key)
|
||||
if not isinstance(child, dict):
|
||||
child = parent[key] = {}
|
||||
return child
|
||||
|
||||
|
||||
def _current_reasoning_effort(config: dict) -> str:
|
||||
agent_cfg = config.get("agent")
|
||||
if isinstance(agent_cfg, dict):
|
||||
@@ -62,20 +68,13 @@ def _current_reasoning_effort(config: dict) -> str:
|
||||
|
||||
|
||||
def _set_reasoning_effort(config: dict, effort: str) -> None:
|
||||
agent_cfg = config.get("agent")
|
||||
if not isinstance(agent_cfg, dict):
|
||||
agent_cfg = {}
|
||||
config["agent"] = agent_cfg
|
||||
agent_cfg["reasoning_effort"] = effort
|
||||
_sub_dict(config, "agent")["reasoning_effort"] = effort
|
||||
|
||||
|
||||
def is_interactive_stdin() -> bool:
|
||||
"""Return True when stdin looks like a usable interactive TTY."""
|
||||
stdin = getattr(sys, "stdin", None)
|
||||
if stdin is None:
|
||||
return False
|
||||
try:
|
||||
return bool(stdin.isatty())
|
||||
return bool(sys.stdin.isatty())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@@ -96,48 +95,33 @@ def print_noninteractive_setup_guidance(reason: str | None = None) -> None:
|
||||
"Run 'hermes setup' in an interactive terminal to use the full wizard.", None)
|
||||
|
||||
|
||||
_BRACKETED_PASTE_PATTERN = re.compile(r"\x1b\[\s*200~|\x1b\[\s*201~")
|
||||
|
||||
|
||||
def _sanitize_pasted_input(value: str) -> str:
|
||||
"""Strip terminal bracketed-paste control markers from pasted text."""
|
||||
if not isinstance(value, str) or not value:
|
||||
return value
|
||||
return _BRACKETED_PASTE_PATTERN.sub("", value)
|
||||
return _BRACKETED_PASTE_PATTERN.sub("", value) if isinstance(value, str) and value else value
|
||||
|
||||
|
||||
def prompt(question: str, default: str = None, password: bool = False) -> str:
|
||||
"""Prompt for input with optional default."""
|
||||
display = f"{question} [{default}]: " if default else f"{question}: "
|
||||
|
||||
display = color(f"{question} [{default}]: " if default else f"{question}: ", Colors.YELLOW)
|
||||
try:
|
||||
if password:
|
||||
value = masked_secret_prompt(color(display, Colors.YELLOW))
|
||||
value = masked_secret_prompt(display)
|
||||
else:
|
||||
from hermes_cli.cli_output import line_input
|
||||
|
||||
value = line_input(color(display, Colors.YELLOW))
|
||||
|
||||
cleaned = _sanitize_pasted_input(value)
|
||||
return cleaned.strip() or default or ""
|
||||
value = line_input(display)
|
||||
return _sanitize_pasted_input(value).strip() or default or ""
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Setup navigation (Escape cancels, Left arrow goes back) — a ContextVar state
|
||||
# machine shared with the curses menus.
|
||||
# =============================================================================
|
||||
# ── Setup navigation (Escape cancels, Left arrow goes back): a ContextVar state machine shared
|
||||
# with the curses menus. ──
|
||||
|
||||
|
||||
class _SetupControlFlow(BaseException):
|
||||
"""Bypass provider error handlers that intentionally catch ``Exception``.
|
||||
|
||||
Provider setup has broad compatibility boundaries around network, plugin and credential
|
||||
integrations; navigation must cross them unchanged so the outer state machine can replay
|
||||
the prior prompt.
|
||||
"""
|
||||
"""Bypass provider error handlers that intentionally catch ``Exception`` so navigation reaches
|
||||
the outer state machine unchanged and it can replay the prior prompt."""
|
||||
|
||||
|
||||
class _SetupCancelled(_SetupControlFlow):
|
||||
@@ -156,29 +140,22 @@ class _SetupNavigationState:
|
||||
"""Per-invocation navigation state for the synchronous setup wizard."""
|
||||
|
||||
def __init__(self, *, section_index: int = -1, prompt_index: int = 0):
|
||||
self.section_index = section_index
|
||||
self.reset(section_index)
|
||||
self.prompt_index = prompt_index
|
||||
self.active_prompt_index = -1
|
||||
self.resolved_choices: list[object] = []
|
||||
self.replay_choices: list[object] = []
|
||||
|
||||
def reset(self, section_index: int = -1, replay: list | None = None) -> None:
|
||||
"""Rewind per-section counters (entering a section, or leaving the wizard)."""
|
||||
self.section_index = section_index
|
||||
self.prompt_index = 0
|
||||
self.active_prompt_index = -1
|
||||
self.resolved_choices = []
|
||||
self.replay_choices = copy.deepcopy(replay or [])
|
||||
self.resolved_choices: list[object] = []
|
||||
self.replay_choices: list[object] = copy.deepcopy(replay or [])
|
||||
|
||||
|
||||
_SETUP_NAVIGATION: ContextVar[_SetupNavigationState | None] = ContextVar(
|
||||
"hermes_setup_navigation", default=None
|
||||
)
|
||||
_SETUP_NAVIGATION: ContextVar[_SetupNavigationState | None] = ContextVar("hermes_setup_navigation", default=None)
|
||||
|
||||
|
||||
def _handle_setup_menu_navigation(
|
||||
event: MenuNavigationEvent, value: object = None
|
||||
) -> MenuNavigationStart | None:
|
||||
def _handle_setup_menu_navigation(event: MenuNavigationEvent, value: object = None) -> MenuNavigationStart | None:
|
||||
"""Translate shared curses menu events into setup control flow."""
|
||||
state = _SETUP_NAVIGATION.get()
|
||||
if state is None:
|
||||
@@ -187,25 +164,16 @@ def _handle_setup_menu_navigation(
|
||||
if state.section_index < 0:
|
||||
state.active_prompt_index = -1
|
||||
return MenuNavigationStart()
|
||||
state.active_prompt_index = state.prompt_index
|
||||
idx = state.active_prompt_index = state.prompt_index
|
||||
state.prompt_index += 1
|
||||
allow_back = state.section_index > 0 or state.active_prompt_index > 0
|
||||
if state.active_prompt_index < len(state.replay_choices):
|
||||
return MenuNavigationStart(
|
||||
allow_back=allow_back,
|
||||
replay_value=copy.deepcopy(state.replay_choices[state.active_prompt_index]),
|
||||
)
|
||||
allow_back = state.section_index > 0 or idx > 0
|
||||
if idx < len(state.replay_choices):
|
||||
return MenuNavigationStart(allow_back=allow_back, replay_value=copy.deepcopy(state.replay_choices[idx]))
|
||||
return MenuNavigationStart(allow_back=allow_back)
|
||||
if event is MenuNavigationEvent.RESOLVE:
|
||||
prompt_index = state.active_prompt_index
|
||||
if prompt_index < 0:
|
||||
return None
|
||||
resolved = copy.deepcopy(value)
|
||||
if prompt_index < len(state.resolved_choices):
|
||||
state.resolved_choices[prompt_index] = resolved
|
||||
del state.resolved_choices[prompt_index + 1 :]
|
||||
else:
|
||||
state.resolved_choices.append(resolved)
|
||||
if prompt_index >= 0: # replace this answer and drop every later one
|
||||
state.resolved_choices[prompt_index:] = [copy.deepcopy(value)]
|
||||
return None
|
||||
if event is MenuNavigationEvent.CANCEL:
|
||||
raise _SetupCancelled()
|
||||
@@ -228,15 +196,18 @@ def _setup_navigation_scope():
|
||||
|
||||
|
||||
def _run_setup_steps(steps: list[tuple[str, Callable[[], None]]]) -> None:
|
||||
"""Run setup sections with left-arrow navigation between choices.
|
||||
|
||||
Left arrow at a section's first choice returns to the previous section; from a later, nested
|
||||
choice it replays earlier selections invisibly and reopens only the preceding prompt.
|
||||
"""
|
||||
"""Run setup sections with left-arrow navigation: at a section's first choice it returns to
|
||||
the previous section; from a later choice it replays earlier selections invisibly and reopens
|
||||
only the preceding prompt."""
|
||||
state = _SETUP_NAVIGATION.get()
|
||||
section_index = 0
|
||||
answers_by_section: dict[int, list[object]] = {}
|
||||
replay_by_section: dict[int, list[object]] = {}
|
||||
|
||||
def _record_answers() -> None:
|
||||
if state is not None:
|
||||
answers_by_section[section_index] = copy.deepcopy(state.resolved_choices)
|
||||
|
||||
try:
|
||||
while section_index < len(steps):
|
||||
label, action = steps[section_index]
|
||||
@@ -245,8 +216,7 @@ def _run_setup_steps(steps: list[tuple[str, Callable[[], None]]]) -> None:
|
||||
try:
|
||||
action()
|
||||
except _SetupGoBack as navigation:
|
||||
if state is not None:
|
||||
answers_by_section[section_index] = copy.deepcopy(state.resolved_choices)
|
||||
_record_answers()
|
||||
if navigation.prompt_index > 0:
|
||||
previous_index = section_index
|
||||
target_prompt = navigation.prompt_index - 1
|
||||
@@ -254,8 +224,7 @@ def _run_setup_steps(steps: list[tuple[str, Callable[[], None]]]) -> None:
|
||||
previous_index = max(0, section_index - 1)
|
||||
target_prompt = max(0, len(answers_by_section.get(previous_index, [])) - 1)
|
||||
replay_by_section[previous_index] = copy.deepcopy(
|
||||
answers_by_section.get(previous_index, [])[:target_prompt]
|
||||
)
|
||||
answers_by_section.get(previous_index, [])[:target_prompt])
|
||||
print()
|
||||
if previous_index == section_index:
|
||||
print_info(f"Returning to the previous choice in {label}...")
|
||||
@@ -263,8 +232,7 @@ def _run_setup_steps(steps: list[tuple[str, Callable[[], None]]]) -> None:
|
||||
print_info(f"Returning to {steps[previous_index][0]}...")
|
||||
section_index = previous_index
|
||||
continue
|
||||
if state is not None:
|
||||
answers_by_section[section_index] = copy.deepcopy(state.resolved_choices)
|
||||
_record_answers()
|
||||
section_index += 1
|
||||
finally:
|
||||
if state is not None:
|
||||
@@ -274,17 +242,13 @@ def _run_setup_steps(steps: list[tuple[str, Callable[[], None]]]) -> None:
|
||||
def run_setup_action_with_navigation(
|
||||
label: str, action: Callable[[], None], *, cancelled_message: str = "Setup cancelled."
|
||||
) -> None:
|
||||
"""Run a setup-style menu flow with Escape and nested Left navigation.
|
||||
|
||||
Shared commands such as ``hermes model`` use the wizard's pickers outside ``run_setup_wizard``;
|
||||
this installs the navigation context for them and reuses the prompt replay state machine.
|
||||
"""
|
||||
"""Run a setup-style menu flow with Escape and nested Left navigation — for commands such as
|
||||
``hermes model`` that use the wizard's pickers outside ``run_setup_wizard``."""
|
||||
with _setup_navigation_scope():
|
||||
try:
|
||||
_run_setup_steps([(label, action)])
|
||||
except _SetupCancelled:
|
||||
print()
|
||||
print_info(cancelled_message)
|
||||
_info(None, cancelled_message)
|
||||
|
||||
|
||||
# ── Prompt primitives ──
|
||||
@@ -297,51 +261,37 @@ def _curses_prompt_choice(question: str, choices: list, default: int = 0, descri
|
||||
|
||||
|
||||
def prompt_choice(question: str, choices: list, default: int = 0, description: str | None = None) -> int:
|
||||
"""Prompt for a choice from a list with arrow key navigation.
|
||||
|
||||
Escape cancels an active setup wizard; outside setup it keeps the default. The curses
|
||||
component owns its own numbered fallback, so a cancel result must never be mistaken for a
|
||||
request to open another prompt. Ctrl+C exits the wizard.
|
||||
"""
|
||||
"""Prompt for a choice from a list with arrow key navigation. Escape cancels an active setup
|
||||
wizard; outside setup it keeps the default (the curses component owns its own numbered
|
||||
fallback, so a cancel result must never open another prompt). Ctrl+C exits the wizard."""
|
||||
idx = _curses_prompt_choice(question, choices, default, description=description)
|
||||
if idx >= 0:
|
||||
if idx == default:
|
||||
_info(" Skipped (keeping current)", None)
|
||||
return default
|
||||
print()
|
||||
return idx
|
||||
|
||||
return default
|
||||
if idx < 0:
|
||||
return default
|
||||
if idx == default:
|
||||
_info(" Skipped (keeping current)", None)
|
||||
return default
|
||||
print()
|
||||
return idx
|
||||
|
||||
|
||||
def is_noninteractive() -> bool:
|
||||
"""True when no human is available to answer a prompt.
|
||||
|
||||
The dashboard/desktop spawn CLI actions with ``stdin=DEVNULL`` and ``HERMES_NONINTERACTIVE=1``
|
||||
(see ``hermes_cli/web_server.py``); there ``input()`` raises ``EOFError`` immediately, and a
|
||||
prompt that aborts on EOF kills the spawned action (desktop "restart gateway" failed this way
|
||||
when the Windows service was not installed yet). Honour the flag so callers fall back to their
|
||||
default.
|
||||
"""
|
||||
"""True when no human is available to answer a prompt: the dashboard/desktop spawn CLI actions
|
||||
with ``stdin=DEVNULL`` and ``HERMES_NONINTERACTIVE=1`` (``hermes_cli/web_server.py``), where a
|
||||
prompt that aborts on EOF would kill the spawned action — callers fall back to their default."""
|
||||
return os.environ.get("HERMES_NONINTERACTIVE", "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def prompt_yes_no(question: str, default: bool = True) -> bool:
|
||||
"""Prompt for yes/no. Ctrl+C exits, empty input returns default.
|
||||
|
||||
Non-interactive callers (``HERMES_NONINTERACTIVE=1`` or a closed/redirected stdin) have no
|
||||
one to answer, so fall back to ``default`` instead of aborting the whole process.
|
||||
"""
|
||||
"""Prompt for yes/no. Ctrl+C exits; empty input, ``HERMES_NONINTERACTIVE=1`` or a
|
||||
closed/redirected stdin return ``default`` instead of aborting the whole process."""
|
||||
if is_noninteractive():
|
||||
return default
|
||||
|
||||
# Inside setup, route binary selections through the curses menu so ESC and left-arrow work
|
||||
# consistently; every other caller keeps the traditional line prompt.
|
||||
if _SETUP_NAVIGATION.get() is not None:
|
||||
return _curses_prompt_choice(question, ["Yes", "No"], 0 if default else 1) == 0
|
||||
|
||||
default_str = "Y/n" if default else "y/N"
|
||||
|
||||
while True:
|
||||
try:
|
||||
value = input(color(f"{question} [{default_str}]: ", Colors.YELLOW)).strip().lower()
|
||||
@@ -353,29 +303,18 @@ def prompt_yes_no(question: str, default: bool = True) -> bool:
|
||||
# proceeds unattended instead of failing the whole command.
|
||||
print()
|
||||
return default
|
||||
|
||||
if not value:
|
||||
return default
|
||||
if value in {"y", "yes"}:
|
||||
return True
|
||||
if value in {"n", "no"}:
|
||||
return False
|
||||
answer = {"": default, "y": True, "yes": True, "n": False, "no": False}.get(value)
|
||||
if answer is not None:
|
||||
return answer
|
||||
print_error("Please enter 'y' or 'n'")
|
||||
|
||||
|
||||
def prompt_checklist(title: str, items: list, pre_selected: list = None) -> list:
|
||||
"""Multi-select checklist; returns the sorted indices of selected items.
|
||||
|
||||
``pre_selected`` indices start checked; Space toggles, Enter on the appended "Continue →"
|
||||
confirms, cancel keeps the pre-selection. Numbered fallback when curses is unavailable.
|
||||
"""
|
||||
if pre_selected is None:
|
||||
pre_selected = []
|
||||
|
||||
"""Multi-select checklist; returns the sorted indices of selected items. ``pre_selected``
|
||||
start checked; Space toggles, Enter confirms, cancel keeps the pre-selection."""
|
||||
from hermes_cli.curses_ui import curses_checklist
|
||||
|
||||
chosen = curses_checklist(title, items, set(pre_selected), cancel_returns=set(pre_selected))
|
||||
return sorted(chosen)
|
||||
pre = set(pre_selected or [])
|
||||
return sorted(curses_checklist(title, items, pre, cancel_returns=pre))
|
||||
|
||||
|
||||
def _prompt_api_key(var: dict):
|
||||
@@ -393,14 +332,17 @@ def _prompt_api_key(var: dict):
|
||||
if var.get("url"):
|
||||
print_info(f" Get your key at: {var['url']}")
|
||||
print()
|
||||
_prompt_and_save_env_var(var, " ✓ Saved", " Skipped (configure later with 'hermes setup')")
|
||||
|
||||
|
||||
def _prompt_and_save_env_var(var: dict, saved_msg: str, skipped_msg: str) -> None:
|
||||
"""Prompt for one env-var value (masked when secret); persist and confirm, or report the skip."""
|
||||
value = prompt(f" {var.get('prompt', var['name'])}", password=bool(var.get("password")))
|
||||
|
||||
if value:
|
||||
save_env_value(var["name"], value)
|
||||
print_success(" ✓ Saved")
|
||||
print_success(saved_msg)
|
||||
else:
|
||||
print_warning(" Skipped (configure later with 'hermes setup')")
|
||||
print_warning(skipped_msg)
|
||||
|
||||
|
||||
def _module_installed(name: str) -> bool:
|
||||
@@ -419,20 +361,13 @@ def _print_banner(*lines: str) -> None:
|
||||
print(color("└─────────────────────────────────────────────────────────┘", Colors.MAGENTA))
|
||||
|
||||
|
||||
# Tool categories and provider config are in tools_config.py (shared
|
||||
# between `hermes tools` and `hermes setup tools`).
|
||||
|
||||
|
||||
# ── Section 1: Model & Provider Configuration ──
|
||||
|
||||
|
||||
def setup_model_provider(config: dict, *, quick: bool = False):
|
||||
"""Configure the inference provider and default model.
|
||||
|
||||
Delegates to the ``hermes model`` flow (provider picker, credential prompting, model pick,
|
||||
persistence) so there is one code path — any provider added there is available here.
|
||||
*quick* skips credential rotation, vision and TTS (first-time quick setup).
|
||||
"""
|
||||
"""Configure the inference provider and default model via the ``hermes model`` flow (one code
|
||||
path — any provider added there is available here). *quick* is accepted for the first-time
|
||||
quick setup caller; rotation, vision and TTS keep safe defaults either way."""
|
||||
from hermes_cli.config import load_config, save_config
|
||||
print_header("Inference Provider")
|
||||
_info("Choose how to connect to your main chat model.",
|
||||
@@ -449,8 +384,8 @@ def setup_model_provider(config: dict, *, quick: bool = False):
|
||||
print_info("You can try again later with: hermes model")
|
||||
|
||||
# Re-sync from disk in place: cmd_model saved via its own load/save cycle and the wizard's
|
||||
# final save_config(config) must not clobber it with stale values (#4172). Rotation, vision
|
||||
# and TTS keep safe defaults (configure via `hermes auth add` / `hermes setup tts`).
|
||||
# final save_config(config) must not clobber it with stale values. Rotation, vision and TTS
|
||||
# keep safe defaults (configure via `hermes auth add` / `hermes setup tts`).
|
||||
config.clear()
|
||||
config.update(load_config())
|
||||
save_config(config)
|
||||
@@ -478,19 +413,23 @@ def _apply_default_agent_settings(config: dict):
|
||||
" Run `hermes setup agent` later to customize.")
|
||||
|
||||
|
||||
def _prompt_number(label: str, current, cast=int):
|
||||
"""Prompt for a number; ``None`` when the answer does not parse."""
|
||||
try:
|
||||
return cast(prompt(label, str(current)))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _prompt_int_setting(section: dict, key: str, label: str, current, accept) -> None:
|
||||
"""Prompt for an int; store it under *key* only when it parses and *accept* holds."""
|
||||
try:
|
||||
value = int(prompt(label, str(current)))
|
||||
if accept(value):
|
||||
section[key] = value
|
||||
except ValueError:
|
||||
pass
|
||||
value = _prompt_number(label, current)
|
||||
if value is not None and accept(value):
|
||||
section[key] = value
|
||||
|
||||
|
||||
_TOOL_PROGRESS_HELP = (
|
||||
"Tool Progress Display",
|
||||
"Controls how much tool activity is shown (CLI and messaging).",
|
||||
"Tool Progress Display", "Controls how much tool activity is shown (CLI and messaging).",
|
||||
" off — Silent, just the final response",
|
||||
" new — Show tool name only when it changes (less noise)",
|
||||
" all — Show every tool call with a short preview",
|
||||
@@ -499,14 +438,11 @@ _TOOL_PROGRESS_HELP = (
|
||||
)
|
||||
_SESSION_RESET_HELP = (
|
||||
"Messaging sessions (Telegram, Discord, etc.) accumulate context over time.",
|
||||
"Each message adds to the conversation history, which means growing API costs.",
|
||||
"",
|
||||
"Each message adds to the conversation history, which means growing API costs.", "",
|
||||
"To manage this, sessions can automatically reset after a period of inactivity",
|
||||
"or at a fixed time each day. When a reset happens, the agent saves important",
|
||||
"things to its persistent memory first — but the conversation context is cleared.",
|
||||
"",
|
||||
"You can also manually reset anytime by typing /reset in chat.",
|
||||
"",
|
||||
"things to its persistent memory first — but the conversation context is cleared.", "",
|
||||
"You can also manually reset anytime by typing /reset in chat.", "",
|
||||
)
|
||||
_SESSION_RESET_CHOICES = [
|
||||
"Inactivity + daily reset (reset whichever comes first)",
|
||||
@@ -530,21 +466,18 @@ def setup_agent_settings(config: dict):
|
||||
"Higher = more complex tasks, but costs more tokens.",
|
||||
f"Press Enter to keep {current_max}. Use 90 for most tasks or 150+ for open exploration.")
|
||||
|
||||
try:
|
||||
max_iter = int(prompt("Max iterations", current_max))
|
||||
if max_iter > 0:
|
||||
# config.yaml only; gateway/run.py derives HERMES_MAX_ITERATIONS from agent.max_turns.
|
||||
config.setdefault("agent", {})["max_turns"] = max_iter
|
||||
config.pop("max_turns", None)
|
||||
remove_env_value("HERMES_MAX_ITERATIONS")
|
||||
print_success(f"Max iterations set to {max_iter}")
|
||||
except ValueError:
|
||||
max_iter = _prompt_number("Max iterations", current_max)
|
||||
if max_iter is None:
|
||||
print_warning("Invalid number, keeping current value")
|
||||
elif max_iter > 0:
|
||||
# config.yaml only; gateway/run.py derives HERMES_MAX_ITERATIONS from agent.max_turns.
|
||||
config.setdefault("agent", {})["max_turns"] = max_iter
|
||||
config.pop("max_turns", None)
|
||||
remove_env_value("HERMES_MAX_ITERATIONS")
|
||||
print_success(f"Max iterations set to {max_iter}")
|
||||
|
||||
# ── Tool Progress Display ──
|
||||
print_info("")
|
||||
for line in _TOOL_PROGRESS_HELP:
|
||||
print_info(line)
|
||||
_info("", *_TOOL_PROGRESS_HELP)
|
||||
|
||||
current_mode = cfg_get(config, "display", "tool_progress", default="all")
|
||||
mode = prompt("Tool progress mode", current_mode)
|
||||
@@ -561,34 +494,29 @@ def setup_agent_settings(config: dict):
|
||||
"Higher threshold = compress later (use more context). Lower = compress sooner.")
|
||||
|
||||
config.setdefault("compression", {})["enabled"] = True
|
||||
|
||||
current_threshold = cfg_get(config, "compression", "threshold", default=0.50)
|
||||
try:
|
||||
threshold = float(prompt("Compression threshold (0.5-0.95)", str(current_threshold)))
|
||||
if 0.5 <= threshold <= 0.95:
|
||||
config["compression"]["threshold"] = threshold
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
threshold = _prompt_number("Compression threshold (0.5-0.95)", current_threshold, float)
|
||||
if threshold is not None and 0.5 <= threshold <= 0.95:
|
||||
config["compression"]["threshold"] = threshold
|
||||
print_success(f"Context compression threshold set to {config['compression'].get('threshold', 0.50)}")
|
||||
|
||||
# ── Session Reset Policy ──
|
||||
print_header("Session Reset Policy")
|
||||
for line in _SESSION_RESET_HELP:
|
||||
print_info(line)
|
||||
_info(*_SESSION_RESET_HELP)
|
||||
_prompt_session_reset(config.setdefault("session_reset", {}))
|
||||
save_config(config)
|
||||
|
||||
current_policy = config.get("session_reset", {})
|
||||
current_mode = current_policy.get("mode", "none")
|
||||
current_idle = current_policy.get("idle_minutes", 1440)
|
||||
current_hour = current_policy.get("at_hour", 4)
|
||||
|
||||
def _prompt_session_reset(reset_cfg: dict) -> None:
|
||||
"""Pick the session reset mode and its idle/daily parameters in place."""
|
||||
current_mode = reset_cfg.get("mode", "none")
|
||||
current_idle, current_hour = reset_cfg.get("idle_minutes", 1440), reset_cfg.get("at_hour", 4)
|
||||
default_reset = _SESSION_RESET_MODES.index(current_mode) if current_mode in _SESSION_RESET_MODES else 3
|
||||
reset_idx = prompt_choice("Session reset mode:", _SESSION_RESET_CHOICES, default_reset)
|
||||
|
||||
reset_cfg = config.setdefault("session_reset", {})
|
||||
mode = _SESSION_RESET_MODES[reset_idx] if 0 <= reset_idx < len(_SESSION_RESET_MODES) else None
|
||||
if mode is not None:
|
||||
reset_cfg["mode"] = mode
|
||||
if mode is None: # keep current settings
|
||||
return
|
||||
reset_cfg["mode"] = mode
|
||||
if mode in ("both", "idle"):
|
||||
_prompt_int_setting(reset_cfg, "idle_minutes", " Inactivity timeout (minutes)", current_idle, lambda v: v > 0)
|
||||
if mode in ("both", "daily"):
|
||||
@@ -597,15 +525,13 @@ def setup_agent_settings(config: dict):
|
||||
if mode == "none":
|
||||
print_info("Sessions will never auto-reset. Context is managed only by compression.")
|
||||
print_warning("Long conversations will grow in cost. Use /reset manually when needed.")
|
||||
elif mode is not None:
|
||||
else:
|
||||
print_success({
|
||||
"both": f"Sessions reset after {idle_now} min idle or daily at {hour_now}:00",
|
||||
"idle": f"Sessions reset after {idle_now} min of inactivity",
|
||||
"daily": f"Sessions reset daily at {hour_now}:00",
|
||||
}[mode])
|
||||
|
||||
save_config(config)
|
||||
|
||||
|
||||
# ── Section 5: Tool Configuration (delegates to unified tools_config.py) ──
|
||||
|
||||
@@ -621,16 +547,14 @@ def setup_tools(config: dict, first_install: bool = False):
|
||||
|
||||
|
||||
_SEND_CONSENT_EXPLAINER = (
|
||||
"",
|
||||
"Sending uploads each daily package to the Nous telemetry",
|
||||
"", "Sending uploads each daily package to the Nous telemetry",
|
||||
"service. Packages carry your profile-scoped install ID, a",
|
||||
"stable random UUID that identifies this profile across days",
|
||||
"(it contains no personal information and is reset by deleting",
|
||||
"the shared-metrics directory). Only packages whose entire",
|
||||
"collection period falls inside a recorded consent window are",
|
||||
"ever sent — data from before you opt in, or from any gap",
|
||||
"while sending was off, stays on this machine. Sending can be",
|
||||
"turned off again at any time.",
|
||||
"while sending was off, stays on this machine. Sending can be", "turned off again at any time.",
|
||||
)
|
||||
|
||||
|
||||
@@ -640,15 +564,7 @@ def setup_telemetry(config: dict):
|
||||
_info("Shared metrics contain only bounded counters and histograms.",
|
||||
"Collection is local. Sending them to Nous is a separate opt-in.")
|
||||
|
||||
telemetry = config.get("telemetry")
|
||||
if not isinstance(telemetry, dict):
|
||||
telemetry = {}
|
||||
config["telemetry"] = telemetry
|
||||
shared_metrics = telemetry.get("shared_metrics")
|
||||
if not isinstance(shared_metrics, dict):
|
||||
shared_metrics = {}
|
||||
telemetry["shared_metrics"] = shared_metrics
|
||||
|
||||
shared_metrics = _sub_dict(_sub_dict(config, "telemetry"), "shared_metrics")
|
||||
current = shared_metrics.get("enabled") is True
|
||||
shared_metrics["enabled"] = prompt_yes_no("Enable local shared metrics?", default=current)
|
||||
if not shared_metrics["enabled"]:
|
||||
@@ -663,8 +579,7 @@ def setup_telemetry(config: dict):
|
||||
return
|
||||
|
||||
print_success("Local shared metrics enabled.")
|
||||
for line in _SEND_CONSENT_EXPLAINER:
|
||||
print_info(line)
|
||||
_info(*_SEND_CONSENT_EXPLAINER)
|
||||
shared_metrics["send"] = prompt_yes_no("Send shared metrics to Nous?", default=shared_metrics.get("send") is True)
|
||||
_record_send_consent_change(enabled=shared_metrics["send"])
|
||||
if shared_metrics["send"]:
|
||||
@@ -674,20 +589,14 @@ def setup_telemetry(config: dict):
|
||||
|
||||
|
||||
def _record_send_consent_change(*, enabled: bool) -> None:
|
||||
"""Reconcile consent windows at the moment the user decides.
|
||||
|
||||
Same single writer as the relay and the sender, so wizard, relay and mid-pass callers cannot
|
||||
disagree; the relay would reconcile on its next hook anyway — this makes the effect immediate.
|
||||
"""
|
||||
"""Reconcile consent windows at the moment the user decides — same single writer as the relay
|
||||
and the sender, so wizard, relay and mid-pass callers cannot disagree."""
|
||||
try:
|
||||
from hermes_cli.observability.shared_metrics import SharedMetricsStore
|
||||
from hermes_cli.observability.shared_metrics_sender import reconcile_send_consent
|
||||
from hermes_cli.sqlite_util import write_txn
|
||||
|
||||
store = SharedMetricsStore()
|
||||
with store._connection() as connection:
|
||||
with write_txn(connection):
|
||||
reconcile_send_consent(connection, enabled)
|
||||
with SharedMetricsStore()._connection() as connection, write_txn(connection):
|
||||
reconcile_send_consent(connection, enabled)
|
||||
except Exception:
|
||||
# Never block the wizard on telemetry bookkeeping; the relay reconciles on the next hook.
|
||||
logger.debug("Unable to record shared-metrics consent change", exc_info=True)
|
||||
@@ -743,7 +652,7 @@ def run_setup_wizard(args):
|
||||
|
||||
|
||||
def _backup_config_file(config_path: Path) -> Path | None:
|
||||
"""Back up config.yaml before setup modifies it (#3522); None when absent or copy fails."""
|
||||
"""Back up config.yaml before setup modifies it; None when absent or copy fails."""
|
||||
if not config_path.exists():
|
||||
return None
|
||||
from datetime import datetime as _dt
|
||||
@@ -790,46 +699,32 @@ def _run_full_setup(config: dict, hermes_home, *, is_existing: bool, migration_r
|
||||
def _skip(key: str, label: str) -> bool:
|
||||
return migration_ran and _skip_configured_section(config, key, label)
|
||||
|
||||
def _model_step() -> None:
|
||||
if not _skip("model", "Model & Provider"):
|
||||
setup_model_provider(config)
|
||||
|
||||
def _terminal_step() -> None:
|
||||
if not _skip("terminal", "Terminal Backend"):
|
||||
setup_terminal_backend(config)
|
||||
|
||||
def _gateway_step() -> None:
|
||||
if not _skip("gateway", "Messaging Platforms"):
|
||||
setup_gateway(config)
|
||||
return
|
||||
|
||||
# A skipped (migrated) gateway section still needs its service so imported platforms
|
||||
# and cron jobs become active.
|
||||
from hermes_cli.gateway import ensure_gateway_service
|
||||
|
||||
ensure_gateway_service(context="setup")
|
||||
|
||||
def _tools_step() -> None:
|
||||
if not _skip("tools", "Tools"):
|
||||
setup_tools(config, first_install=not is_existing)
|
||||
def _step(key: str, label: str, run) -> tuple:
|
||||
return label, lambda: None if _skip(key, label) else run()
|
||||
|
||||
_run_setup_steps(
|
||||
[
|
||||
("Model & Provider", _model_step),
|
||||
("Terminal Backend", _terminal_step),
|
||||
("Messaging Platforms", _gateway_step),
|
||||
("Tools", _tools_step),
|
||||
]
|
||||
)
|
||||
_run_setup_steps([
|
||||
_step("model", "Model & Provider", lambda: setup_model_provider(config)),
|
||||
_step("terminal", "Terminal Backend", lambda: setup_terminal_backend(config)),
|
||||
("Messaging Platforms", _gateway_step),
|
||||
_step("tools", "Tools", lambda: setup_tools(config, first_install=not is_existing))])
|
||||
|
||||
|
||||
# First-time mode picker: (menu label, runner) — a None runner falls through to Full Setup.
|
||||
_FIRST_TIME_MODES = (
|
||||
("Quick Setup (Nous Portal) — free OAuth login, no API keys, model + tools (recommended)",
|
||||
lambda config, home, is_existing: _run_first_time_quick_setup(config, home, is_existing)),
|
||||
lambda *a: _run_first_time_quick_setup(*a)),
|
||||
("Full setup — configure every provider, tool & option yourself (bring your own keys)", None),
|
||||
("Blank Slate — everything off except the bare minimum; opt in to each capability",
|
||||
lambda config, home, is_existing: _run_blank_slate_setup(config, home, is_existing)),
|
||||
lambda *a: _run_blank_slate_setup(*a)),
|
||||
)
|
||||
|
||||
|
||||
@@ -867,16 +762,12 @@ def _run_setup_wizard_impl(args):
|
||||
|
||||
# Existing installation == a provider is configured
|
||||
from hermes_cli.auth import get_active_provider
|
||||
|
||||
is_existing = bool(
|
||||
get_env_value("OPENROUTER_API_KEY") or get_env_value("OPENAI_BASE_URL") or get_active_provider() is not None
|
||||
)
|
||||
_print_banner(
|
||||
"│ ⚕ Hermes Agent Setup Wizard │",
|
||||
"├─────────────────────────────────────────────────────────┤",
|
||||
"│ Let's configure your Hermes Agent installation. │",
|
||||
"│ Press Ctrl+C at any time to exit. │",
|
||||
)
|
||||
is_existing = bool(get_env_value("OPENROUTER_API_KEY") or get_env_value("OPENAI_BASE_URL")
|
||||
or get_active_provider() is not None)
|
||||
_print_banner("│ ⚕ Hermes Agent Setup Wizard │",
|
||||
"├─────────────────────────────────────────────────────────┤",
|
||||
"│ Let's configure your Hermes Agent installation. │",
|
||||
"│ Press Ctrl+C at any time to exit. │")
|
||||
|
||||
migration_ran = False
|
||||
if is_existing:
|
||||
@@ -886,8 +777,7 @@ def _run_setup_wizard_impl(args):
|
||||
if quick_requested:
|
||||
_run_setup_steps([("Quick Setup", lambda: _run_quick_setup(config, hermes_home))])
|
||||
return
|
||||
print()
|
||||
print_header("Reconfigure")
|
||||
print_header("Reconfigure", gap=True)
|
||||
print_success("You already have Hermes configured.")
|
||||
_info("Running the full wizard — each prompt shows your current value.",
|
||||
"Press Enter to keep it, or type a new value to change it.", "",
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
"""Which platform env vars the setup surfaces hide.
|
||||
|
||||
Hiding them is a *presentation* decision only. The env vars keep working through ``hermes config
|
||||
set``, ``.env``, and ``config.yaml``; the gateway reads them exactly as before. This module just
|
||||
says what a new user is asked during setup.
|
||||
Hiding is a *presentation* decision only: the vars keep working through ``hermes config set``,
|
||||
``.env`` and ``config.yaml``, and the gateway reads them exactly as before.
|
||||
"""
|
||||
|
||||
# Suffix match, so plugin adapters nobody enumerated (IRC, SimpleX, LINE, ntfy)
|
||||
# get the same treatment without a code change here.
|
||||
# Suffix match, so plugin adapters nobody enumerated (IRC, SimpleX, LINE, ntfy) get the same
|
||||
# treatment without a code change here.
|
||||
#
|
||||
# *_HOME_CHANNEL* the bot offers /sethome on the first chat
|
||||
# *_ALLOW_ALL_USERS defaults off; enabling it is a security decision
|
||||
# *_REPLY_TO_MODE cosmetic threading preference
|
||||
# *_REPLY_MODE same, Mattermost's spelling
|
||||
# *_REQUIRE_MENTION behavior toggle with a sane default
|
||||
# *_AUTO_THREAD same
|
||||
# *_FREE_RESPONSE_* per-channel tuning, done once the bot is in a server
|
||||
# *_ALLOWED_CHANNELS same
|
||||
# *_REPLY_TO_MODE / *_REPLY_MODE cosmetic threading preference (Mattermost spelling too)
|
||||
# *_REQUIRE_MENTION / *_AUTO_THREAD behavior toggles with sane defaults
|
||||
# *_FREE_RESPONSE_* / *_ALLOWED_CHANNELS per-channel tuning, done once the bot is in a server
|
||||
# *_PROXY only for networks that block the platform
|
||||
#
|
||||
# Allowlists (*_ALLOWED_USERS) deliberately stay visible: that IS the decision
|
||||
# a new user has to make, and the gateway denies everyone until it's set.
|
||||
# Allowlists (*_ALLOWED_USERS) deliberately stay visible: that IS the decision a new user has to
|
||||
# make, and the gateway denies everyone until it's set.
|
||||
SETUP_HIDDEN_ENV_SUFFIXES = (
|
||||
"_HOME_CHANNEL",
|
||||
"_HOME_CHANNEL_NAME",
|
||||
@@ -38,9 +34,7 @@ SETUP_HIDDEN_ENV_SUFFIXES = (
|
||||
|
||||
|
||||
def is_setup_hidden_env(name: str) -> bool:
|
||||
"""True when a var is self-configuring and shouldn't appear in setup forms.
|
||||
|
||||
Callers must still keep any var a platform lists as *required* — hiding a required credential
|
||||
would make that platform unconfigurable from the UI.
|
||||
"""
|
||||
"""True when a var is self-configuring and shouldn't appear in setup forms. Callers must still
|
||||
keep any var a platform lists as *required* — hiding a required credential would make that
|
||||
platform unconfigurable from the UI."""
|
||||
return name.endswith(SETUP_HIDDEN_ENV_SUFFIXES)
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Post-migration section-skip logic and the OpenClaw first-run migration flow.
|
||||
|
||||
Extracted from hermes_cli/setup.py, which re-exports the names it still uses.
|
||||
"""
|
||||
"""Post-migration section-skip logic and the OpenClaw first-run migration flow (setup.py
|
||||
re-exports the names it still uses; setup helpers are imported lazily so test patches apply)."""
|
||||
|
||||
import importlib.util
|
||||
import logging
|
||||
@@ -33,7 +31,6 @@ def _model_section_has_credentials(config: dict) -> bool:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
except Exception:
|
||||
@@ -44,8 +41,7 @@ def _model_section_has_credentials(config: dict) -> bool:
|
||||
# mirrors is_provider_explicitly_configured in auth.py.
|
||||
return any(get_env_value(v) for v in pconfig.api_key_env_vars if v != "CLAUDE_CODE_OAUTH_TOKEN")
|
||||
|
||||
def _any_openrouter_key() -> bool:
|
||||
return any(get_env_value(v) for v in _OPENROUTER_ENV_VARS)
|
||||
any_openrouter_key = any(get_env_value(v) for v in _OPENROUTER_ENV_VARS)
|
||||
|
||||
# Prefer the provider declared in config.yaml, avoids false positives from stray
|
||||
# env vars (GH_TOKEN, etc.) when the user has already picked a different provider.
|
||||
@@ -54,23 +50,17 @@ def _model_section_has_credentials(config: dict) -> bool:
|
||||
provider_id = (model_cfg.get("provider") or "").strip().lower()
|
||||
if provider_id in PROVIDER_REGISTRY and _has_key(PROVIDER_REGISTRY[provider_id]):
|
||||
return True
|
||||
if provider_id == "openrouter" and _any_openrouter_key():
|
||||
if provider_id == "openrouter" and any_openrouter_key:
|
||||
return True
|
||||
|
||||
# OpenRouter aggregator fallback (no provider declared in config).
|
||||
if _any_openrouter_key():
|
||||
if any_openrouter_key:
|
||||
return True
|
||||
|
||||
# Skip copilot in auto-detect: GH_TOKEN / GITHUB_TOKEN are commonly set for
|
||||
# git tooling. Mirrors resolve_provider in auth.py.
|
||||
# Skip copilot in auto-detect: GH_TOKEN / GITHUB_TOKEN are commonly set for git tooling.
|
||||
# Mirrors resolve_provider in auth.py.
|
||||
return any(_has_key(pconfig) for pid, pconfig in PROVIDER_REGISTRY.items() if pid != "copilot")
|
||||
|
||||
|
||||
def _gateway_platform_short_label(label: str) -> str:
|
||||
"""Strip trailing parenthetical qualifiers from a gateway platform label."""
|
||||
return label.split("(", 1)[0].strip() or label
|
||||
|
||||
|
||||
def _model_summary(config: dict) -> Optional[str]:
|
||||
if not _model_section_has_credentials(config):
|
||||
return None
|
||||
@@ -92,7 +82,8 @@ def _gateway_summary(config: dict) -> Optional[str]:
|
||||
# Any non-empty status other than "not configured" counts — WhatsApp ("enabled, not paired"),
|
||||
# Matrix ("configured + E2EE"), Signal ("partially configured") mean setup already started.
|
||||
configured = [
|
||||
_gateway_platform_short_label(plat["label"])
|
||||
# Trailing parenthetical qualifiers are stripped from the label.
|
||||
plat["label"].split("(", 1)[0].strip() or plat["label"]
|
||||
for plat in _all_platforms()
|
||||
if _platform_status(plat) and _platform_status(plat) != "not configured"
|
||||
]
|
||||
@@ -122,20 +113,15 @@ _SECTION_SUMMARIES = {
|
||||
|
||||
|
||||
def _get_section_config_summary(config: dict, section_key: str) -> Optional[str]:
|
||||
"""Return a short summary if a setup section is already configured, else None.
|
||||
|
||||
Used after OpenClaw migration to detect which sections can be skipped. ``get_env_value`` is
|
||||
reached through hermes_cli.setup so that test patches on ``setup_mod.get_env_value`` apply.
|
||||
"""
|
||||
"""Short summary if a setup section is already configured (post-OpenClaw-migration skip
|
||||
detection), else None. ``get_env_value`` is reached through hermes_cli.setup so test patches
|
||||
on ``setup_mod.get_env_value`` apply."""
|
||||
summarize = _SECTION_SUMMARIES.get(section_key)
|
||||
return summarize(config) if summarize else None
|
||||
|
||||
|
||||
def _skip_configured_section(config: dict, section_key: str, label: str) -> bool:
|
||||
"""Show an already-configured section summary and offer to skip.
|
||||
|
||||
Returns True if the user chose to skip, False if the section should run.
|
||||
"""
|
||||
"""Show an already-configured section summary and offer to skip; True when the user skips."""
|
||||
from hermes_cli.setup import print_success, prompt_yes_no
|
||||
summary = _get_section_config_summary(config, section_key)
|
||||
if not summary:
|
||||
@@ -162,8 +148,7 @@ def _load_openclaw_migration_module():
|
||||
if spec is None or spec.loader is None:
|
||||
return None
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
# Register in sys.modules so @dataclass can resolve the module
|
||||
# (Python 3.11+ requires this for dynamically loaded modules)
|
||||
# Registered in sys.modules so @dataclass can resolve the module (Python 3.11+ requirement).
|
||||
sys.modules[spec.name] = mod
|
||||
try:
|
||||
spec.loader.exec_module(mod)
|
||||
@@ -232,8 +217,7 @@ def _print_migration_preview(report: dict):
|
||||
if status == "migrated": # collect warnings for high-impact items
|
||||
kind_lower, dest_lower = kind.lower(), str(item.get("destination", "")).lower()
|
||||
warnings_shown.update(
|
||||
w for kw, w in _HIGH_IMPACT_KIND_KEYWORDS.items() if kw in kind_lower or kw in dest_lower
|
||||
)
|
||||
w for kw, w in _HIGH_IMPACT_KIND_KEYWORDS.items() if kw in kind_lower or kw in dest_lower)
|
||||
print()
|
||||
|
||||
if warnings_shown:
|
||||
@@ -255,6 +239,21 @@ def _run_migrator(mod, openclaw_dir: Path, hermes_home: Path, selected, *, execu
|
||||
).migrate()
|
||||
|
||||
|
||||
_FAILED = object()
|
||||
|
||||
|
||||
def _migration_step(label: str, log_label: str, fn):
|
||||
"""Run one migration phase; on failure warn ``"<label>: <exc>"``, log the trace and return
|
||||
``_FAILED`` (the caller aborts)."""
|
||||
from hermes_cli.setup import print_warning
|
||||
try:
|
||||
return fn()
|
||||
except Exception as e:
|
||||
print_warning(f"{label}: {e}")
|
||||
logger.debug(log_label, exc_info=True)
|
||||
return _FAILED
|
||||
|
||||
|
||||
def _offer_openclaw_migration(hermes_home: Path) -> bool:
|
||||
"""Detect ~/.openclaw and offer to migrate during first-time setup: dry-run preview first,
|
||||
execute only after explicit confirmation. Returns True iff migration ran successfully."""
|
||||
@@ -266,8 +265,7 @@ def _offer_openclaw_migration(hermes_home: Path) -> bool:
|
||||
if not openclaw_dir.is_dir() or not _OPENCLAW_SCRIPT.exists():
|
||||
return False
|
||||
|
||||
print()
|
||||
print_header("OpenClaw Installation Detected")
|
||||
print_header("OpenClaw Installation Detected", gap=True)
|
||||
_info(f"Found OpenClaw data at {openclaw_dir}",
|
||||
"Hermes can preview what would be imported before making any changes.", None)
|
||||
if not prompt_yes_no("Would you like to see what can be imported?", default=True):
|
||||
@@ -278,31 +276,28 @@ def _offer_openclaw_migration(hermes_home: Path) -> bool:
|
||||
if not get_config_path().exists():
|
||||
save_config(load_config())
|
||||
|
||||
try:
|
||||
mod = _load_openclaw_migration_module()
|
||||
if mod is None:
|
||||
print_warning("Could not load migration script.")
|
||||
return False
|
||||
except Exception as e:
|
||||
print_warning(f"Could not load migration script: {e}")
|
||||
logger.debug("OpenClaw migration module load error", exc_info=True)
|
||||
mod = _migration_step("Could not load migration script", "OpenClaw migration module load error",
|
||||
_load_openclaw_migration_module)
|
||||
if mod is None:
|
||||
print_warning("Could not load migration script.")
|
||||
if mod is None or mod is _FAILED:
|
||||
return False
|
||||
|
||||
# ── Phase 1: Dry-run preview (overwrite=True shows everything, including conflicts) ──
|
||||
try:
|
||||
def _preview():
|
||||
selected = mod.resolve_selected_options(None, None, preset="full")
|
||||
preview_report = _run_migrator(mod, openclaw_dir, hermes_home, selected, execute=False, overwrite=True)
|
||||
except Exception as e:
|
||||
print_warning(f"Migration preview failed: {e}")
|
||||
logger.debug("OpenClaw migration preview error", exc_info=True)
|
||||
return selected, _run_migrator(mod, openclaw_dir, hermes_home, selected, execute=False, overwrite=True)
|
||||
|
||||
previewed = _migration_step("Migration preview failed", "OpenClaw migration preview error", _preview)
|
||||
if previewed is _FAILED:
|
||||
return False
|
||||
selected, preview_report = previewed
|
||||
|
||||
preview_count = preview_report.get("summary", {}).get("migrated", 0)
|
||||
if preview_count == 0:
|
||||
_info(None, "Nothing to import from OpenClaw.")
|
||||
return False
|
||||
print()
|
||||
print_header(f"Migration Preview — {preview_count} item(s) would be imported")
|
||||
print_header(f"Migration Preview — {preview_count} item(s) would be imported", gap=True)
|
||||
_info("No changes have been made yet. Review the list below:", None)
|
||||
_print_migration_preview(preview_report)
|
||||
|
||||
@@ -312,28 +307,25 @@ def _offer_openclaw_migration(hermes_home: Path) -> bool:
|
||||
"Use --dry-run to preview again, or --preset minimal for a lighter import.")
|
||||
return False
|
||||
|
||||
# overwrite=False so existing Hermes configs are preserved. The user saw the
|
||||
# preview; conflicts are skipped by default.
|
||||
try:
|
||||
report = _run_migrator(mod, openclaw_dir, hermes_home, selected, execute=True, overwrite=False)
|
||||
except Exception as e:
|
||||
print_warning(f"Migration failed: {e}")
|
||||
logger.debug("OpenClaw migration error", exc_info=True)
|
||||
# overwrite=False so existing Hermes configs are preserved. The user saw the preview;
|
||||
# conflicts are skipped by default.
|
||||
report = _migration_step("Migration failed", "OpenClaw migration error", lambda: _run_migrator(
|
||||
mod, openclaw_dir, hermes_home, selected, execute=True, overwrite=False))
|
||||
if report is _FAILED:
|
||||
return False
|
||||
|
||||
summary = report.get("summary", {})
|
||||
migrated, skipped = summary.get("migrated", 0), summary.get("skipped", 0)
|
||||
conflicts, errors = summary.get("conflict", 0), summary.get("error", 0)
|
||||
print()
|
||||
for count, printer, text in (
|
||||
(migrated, print_success, f"Imported {migrated} item(s) from OpenClaw."),
|
||||
(conflicts, print_info,
|
||||
f"Skipped {conflicts} item(s) that already exist in Hermes (use hermes claw migrate --overwrite to force)."),
|
||||
(skipped, print_info, f"Skipped {skipped} item(s) (not found or unchanged)."),
|
||||
(errors, print_warning, f"{errors} item(s) had errors — check the migration report."),
|
||||
for key, printer, text in (
|
||||
("migrated", print_success, "Imported {n} item(s) from OpenClaw."),
|
||||
("conflict", print_info,
|
||||
"Skipped {n} item(s) that already exist in Hermes (use hermes claw migrate --overwrite to force)."),
|
||||
("skipped", print_info, "Skipped {n} item(s) (not found or unchanged)."),
|
||||
("error", print_warning, "{n} item(s) had errors — check the migration report."),
|
||||
):
|
||||
count = summary.get(key, 0)
|
||||
if count:
|
||||
printer(text)
|
||||
printer(text.format(n=count))
|
||||
|
||||
output_dir = report.get("output_dir")
|
||||
if output_dir:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Messaging-platform setup wizards (Telegram, BlueBubbles, QQ Bot, webhooks) and the
|
||||
``hermes setup gateway`` flow. Extracted from hermes_cli/setup.py; setup.py re-exports
|
||||
the public names, and tests monkeypatch prompt/print/env helpers on hermes_cli.setup."""
|
||||
"""Messaging-platform setup wizards (Telegram, BlueBubbles, webhooks) and the ``hermes setup
|
||||
gateway`` flow. setup.py re-exports the public names, and tests monkeypatch prompt/print/env
|
||||
helpers on hermes_cli.setup, so those are imported lazily per function."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
@@ -9,6 +9,7 @@ from pathlib import Path
|
||||
logger = logging.getLogger("hermes_cli.setup")
|
||||
|
||||
_TELEGRAM_BOT_TOKEN_RE = re.compile(r"^\d+:[A-Za-z0-9_-]{30,}$")
|
||||
_RULE = "━" * 50
|
||||
|
||||
|
||||
def _is_valid_telegram_bot_token(token: str) -> bool:
|
||||
@@ -17,9 +18,7 @@ def _is_valid_telegram_bot_token(token: str) -> bool:
|
||||
|
||||
def _profile_name_from_hermes_home(hermes_home) -> str | None:
|
||||
"""Return the active profile name when HERMES_HOME is a profile dir."""
|
||||
if hermes_home.parent.name == "profiles":
|
||||
return hermes_home.name
|
||||
return None
|
||||
return hermes_home.name if hermes_home.parent.name == "profiles" else None
|
||||
|
||||
|
||||
def _setup_telegram_auto_result():
|
||||
@@ -47,10 +46,21 @@ def _declines_reconfigure(env_var: str, label: str, question: str) -> bool:
|
||||
return not prompt_yes_no(question, False)
|
||||
|
||||
|
||||
def _save_if_set(env_var: str, value: str) -> None:
|
||||
from hermes_cli.setup import save_env_value
|
||||
def _save_prompted(env_var: str, question: str, *, password: bool = False, success_msg: str | None = None,
|
||||
skip_msg: str | None = None, transform=None) -> str:
|
||||
"""Prompt, persist the (optionally transformed) answer when non-empty, and report either way.
|
||||
|
||||
``success_msg`` may reference ``{value}``. Returns the raw answer ("" when skipped).
|
||||
"""
|
||||
from hermes_cli.setup import print_success, print_warning, prompt, save_env_value
|
||||
value = prompt(question, password=password)
|
||||
if value:
|
||||
save_env_value(env_var, value)
|
||||
save_env_value(env_var, transform(value) if transform else value)
|
||||
if success_msg:
|
||||
print_success(success_msg.format(value=value))
|
||||
elif skip_msg:
|
||||
print_warning(skip_msg)
|
||||
return value
|
||||
|
||||
|
||||
def _save_allowlist(env_var: str, users: str, success_msg: str) -> None:
|
||||
@@ -77,13 +87,10 @@ def _prompt_telegram_bot_token() -> str | None:
|
||||
print_info("Create a bot via @BotFather on Telegram")
|
||||
while True:
|
||||
token = prompt("Telegram bot token", password=True)
|
||||
if not token:
|
||||
return None
|
||||
if not _is_valid_telegram_bot_token(token):
|
||||
print_error("Invalid token format. Expected: <numeric_id>:<alphanumeric_hash> "
|
||||
"(e.g., 123456789:ABCdefGHI-jklMNOpqrSTUvwxYZ)")
|
||||
continue
|
||||
return token
|
||||
if not token or _is_valid_telegram_bot_token(token):
|
||||
return token or None
|
||||
print_error("Invalid token format. Expected: <numeric_id>:<alphanumeric_hash> "
|
||||
"(e.g., 123456789:ABCdefGHI-jklMNOpqrSTUvwxYZ)")
|
||||
|
||||
|
||||
def _telegram_allowlist_nudge() -> None:
|
||||
@@ -109,18 +116,14 @@ def _obtain_telegram_token():
|
||||
" [2] Manual",
|
||||
" Create a bot via @BotFather yourself and paste the token.", None)
|
||||
|
||||
choice = prompt("Choice [1/2]", default="1")
|
||||
token = None
|
||||
setup_result = None
|
||||
|
||||
if choice.strip() == "1":
|
||||
token = setup_result = None
|
||||
if prompt("Choice [1/2]", default="1").strip() == "1":
|
||||
setup_result = _setup_telegram_auto_result()
|
||||
if setup_result:
|
||||
token = setup_result.token
|
||||
if not _is_valid_telegram_bot_token(token):
|
||||
print_error("Automatic setup returned an invalid Telegram bot token.")
|
||||
token = None
|
||||
setup_result = None
|
||||
token = setup_result = None
|
||||
if not token:
|
||||
_info(None, "Falling back to manual setup...", None)
|
||||
|
||||
@@ -131,8 +134,7 @@ def _obtain_telegram_token():
|
||||
|
||||
def _setup_telegram():
|
||||
"""Configure Telegram bot credentials and allowlist."""
|
||||
from hermes_cli.setup import print_header, print_info, print_success, prompt, prompt_yes_no
|
||||
from hermes_cli.setup import _info, save_env_value
|
||||
from hermes_cli.setup import _info, print_info, print_header, print_success, prompt, prompt_yes_no, save_env_value
|
||||
print_header("Telegram")
|
||||
if _declines_reconfigure("TELEGRAM_BOT_TOKEN", "Telegram", "Reconfigure Telegram?"):
|
||||
_telegram_allowlist_nudge()
|
||||
@@ -156,11 +158,7 @@ def _setup_telegram():
|
||||
print_success(f"Detected your Telegram user ID: {detected_id}")
|
||||
if prompt_yes_no("Allow this Telegram account to use the bot?", True):
|
||||
extra = prompt("Additional allowed user IDs (comma-separated, optional)")
|
||||
ids = [detected_id]
|
||||
for uid in extra.replace(" ", "").split(","):
|
||||
if uid and uid not in ids:
|
||||
ids.append(uid)
|
||||
allowed_users = ",".join(ids)
|
||||
allowed_users = ",".join(dict.fromkeys([detected_id, *filter(None, extra.replace(" ", "").split(","))]))
|
||||
if allowed_users is None:
|
||||
allowed_users = prompt("Allowed user IDs (comma-separated, leave empty for open access)")
|
||||
|
||||
@@ -177,19 +175,17 @@ def _setup_telegram():
|
||||
first_user_id = allowed_users.split(",")[0].strip() if allowed_users else ""
|
||||
if not first_user_id:
|
||||
print_info(" You can also set this later by typing /set-home in your Telegram chat.")
|
||||
_save_if_set("TELEGRAM_HOME_CHANNEL", prompt("Home channel ID (leave empty to set later)"))
|
||||
_save_prompted("TELEGRAM_HOME_CHANNEL", "Home channel ID (leave empty to set later)")
|
||||
elif prompt_yes_no(f"Use your user ID ({first_user_id}) as the home channel?", True):
|
||||
save_env_value("TELEGRAM_HOME_CHANNEL", first_user_id)
|
||||
print_success(f"Telegram home channel set to {first_user_id}")
|
||||
else:
|
||||
q = "Home channel ID (or leave empty to set later with /set-home in Telegram)"
|
||||
_save_if_set("TELEGRAM_HOME_CHANNEL", prompt(q))
|
||||
_save_prompted("TELEGRAM_HOME_CHANNEL", "Home channel ID (or leave empty to set later with /set-home in Telegram)")
|
||||
|
||||
|
||||
def _setup_bluebubbles():
|
||||
"""Configure BlueBubbles iMessage gateway."""
|
||||
from hermes_cli.setup import print_header, print_info, print_success, print_warning, prompt
|
||||
from hermes_cli.setup import _info, prompt_yes_no, save_env_value
|
||||
from hermes_cli.setup import _info, print_header, print_info, print_success, prompt, prompt_yes_no
|
||||
print_header("BlueBubbles (iMessage)")
|
||||
if _declines_reconfigure("BLUEBUBBLES_SERVER_URL", "BlueBubbles", "Reconfigure BlueBubbles?"):
|
||||
return
|
||||
@@ -200,15 +196,14 @@ def _setup_bluebubbles():
|
||||
" Download: https://bluebubbles.app/", None,
|
||||
"In BlueBubbles Server → Settings → API, note your Server URL and Password.", None)
|
||||
|
||||
for label, env_var, secret, what in (
|
||||
("BlueBubbles server URL (e.g. http://192.168.1.10:1234)", "BLUEBUBBLES_SERVER_URL", False, "Server URL"),
|
||||
("BlueBubbles server password", "BLUEBUBBLES_PASSWORD", True, "Password"),
|
||||
for label, env_var, secret, what, transform in (
|
||||
("BlueBubbles server URL (e.g. http://192.168.1.10:1234)", "BLUEBUBBLES_SERVER_URL", False, "Server URL",
|
||||
lambda v: v.rstrip("/")),
|
||||
("BlueBubbles server password", "BLUEBUBBLES_PASSWORD", True, "Password", None),
|
||||
):
|
||||
value = prompt(label, password=secret)
|
||||
if not value:
|
||||
print_warning(f"{what} is required — skipping BlueBubbles setup")
|
||||
if not _save_prompted(env_var, label, password=secret, transform=transform,
|
||||
skip_msg=f"{what} is required — skipping BlueBubbles setup"):
|
||||
return
|
||||
save_env_value(env_var, value.rstrip("/") if env_var == "BLUEBUBBLES_SERVER_URL" else value)
|
||||
print_success("BlueBubbles credentials saved")
|
||||
|
||||
_info(None, "🔒 Security: Restrict who can message your bot",
|
||||
@@ -221,7 +216,7 @@ def _setup_bluebubbles():
|
||||
|
||||
_info(None, "📬 Home Channel: phone or email for cron job delivery and notifications.",
|
||||
" You can also set this later with /set-home in your iMessage chat.")
|
||||
_save_if_set("BLUEBUBBLES_HOME_CHANNEL", prompt("Home channel address (leave empty to set later)"))
|
||||
_save_prompted("BLUEBUBBLES_HOME_CHANNEL", "Home channel address (leave empty to set later)")
|
||||
|
||||
_info(None, "Advanced settings (defaults are fine for most setups):")
|
||||
if prompt_yes_no("Configure webhook listener settings?", False):
|
||||
@@ -247,13 +242,9 @@ def _setup_webhooks():
|
||||
_info(" Full guide: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/webhooks/", None)
|
||||
|
||||
_save_port("WEBHOOK_PORT", prompt("Webhook port (default 8644)"), "8644")
|
||||
|
||||
secret = prompt("Global HMAC secret (shared across all routes)", password=True)
|
||||
if secret:
|
||||
save_env_value("WEBHOOK_SECRET", secret)
|
||||
print_success("Webhook secret saved")
|
||||
else:
|
||||
print_warning("No secret set — you must configure per-route secrets in config.yaml")
|
||||
_save_prompted("WEBHOOK_SECRET", "Global HMAC secret (shared across all routes)", password=True,
|
||||
success_msg="Webhook secret saved",
|
||||
skip_msg="No secret set — you must configure per-route secrets in config.yaml")
|
||||
|
||||
save_env_value("WEBHOOK_ENABLED", "true")
|
||||
print()
|
||||
@@ -291,8 +282,7 @@ def _warn_missing_home_channels() -> None:
|
||||
from hermes_cli.setup import get_env_value, _info, print_warning
|
||||
missing_home = [
|
||||
plat for plat, token_var, home_vars in _HOME_CHANNEL_CHECKS
|
||||
if get_env_value(token_var) and not any(get_env_value(v) for v in home_vars)
|
||||
]
|
||||
if get_env_value(token_var) and not any(get_env_value(v) for v in home_vars)]
|
||||
if not missing_home:
|
||||
return
|
||||
print()
|
||||
@@ -364,7 +354,7 @@ def setup_gateway(config: dict):
|
||||
any_messaging = any(_is_progress(_platform_status(p)) for p in _all_platforms())
|
||||
if any_messaging:
|
||||
print()
|
||||
print_info("━" * 50)
|
||||
print_info(_RULE)
|
||||
print_success("Messaging platforms configured!")
|
||||
_warn_missing_home_channels()
|
||||
|
||||
@@ -373,7 +363,6 @@ def setup_gateway(config: dict):
|
||||
# `hermes setup gateway`). Gating it on messaging config left install-then-import machines
|
||||
# with cron jobs and bot tokens but no process to serve them.
|
||||
from hermes_cli.gateway import _is_service_running, supports_systemd_services, ensure_gateway_service
|
||||
|
||||
supports_systemd = supports_systemd_services()
|
||||
|
||||
print()
|
||||
@@ -383,4 +372,4 @@ def setup_gateway(config: dict):
|
||||
# Not running: install (if needed) and start, no questions asked.
|
||||
ensure_gateway_service(context="setup")
|
||||
|
||||
print_info("━" * 50)
|
||||
print_info(_RULE)
|
||||
|
||||
+131
-172
@@ -1,7 +1,6 @@
|
||||
"""Streamlined setup flows extracted from hermes_cli/setup.py: the Nous Portal
|
||||
one-shot (`hermes portal`), first-time quick setup, Blank Slate setup and the
|
||||
`--quick` missing-items pass. Names from setup.py are imported lazily per
|
||||
function so test patches on ``hermes_cli.setup`` take effect."""
|
||||
"""Streamlined setup flows: the Nous Portal one-shot (`hermes portal`), first-time quick setup,
|
||||
Blank Slate setup and the `--quick` missing-items pass. Names from setup.py are imported lazily
|
||||
per function so test patches on ``hermes_cli.setup`` take effect."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
@@ -13,65 +12,62 @@ logger = logging.getLogger("hermes_cli.setup")
|
||||
# (env-var name substring, platform label, emoji) — order matters: first match wins.
|
||||
_MESSAGING_PLATFORMS = (("TELEGRAM", "Telegram", "📱"), ("DISCORD", "Discord", "💬"), ("SLACK", "Slack", "💼"))
|
||||
|
||||
_BLANK_SLATE_DONE_LINES = (
|
||||
" Seed skills: hermes skills opt-in --sync", " Add MCP servers: hermes mcp add",
|
||||
)
|
||||
|
||||
def _reload_config_into(config: dict) -> None:
|
||||
"""Re-sync the in-memory config dict from disk after a sub-flow that saved
|
||||
via its own load/save cycle, so a later save_config(config) can't clobber it."""
|
||||
|
||||
def _reload_config_into(config: dict, *, dict_only: bool = False) -> None:
|
||||
"""Re-sync the in-memory config dict from disk after a sub-flow that saved via its own
|
||||
load/save cycle, so a later save_config(config) can't clobber it."""
|
||||
from hermes_cli.setup import load_config
|
||||
_refreshed = load_config()
|
||||
config.clear()
|
||||
config.update(_refreshed)
|
||||
refreshed = load_config()
|
||||
if not dict_only or isinstance(refreshed, dict):
|
||||
config.clear()
|
||||
config.update(refreshed)
|
||||
|
||||
|
||||
def _prompt_and_save_env_var(var: dict, saved_msg: str, skipped_msg: str) -> None:
|
||||
"""Prompt for one env-var value (masked when secret); persist and confirm, or report the skip."""
|
||||
from hermes_cli.setup import print_success, print_warning, prompt, save_env_value
|
||||
value = prompt(f" {var.get('prompt', var['name'])}", password=bool(var.get("password")))
|
||||
if value:
|
||||
save_env_value(var["name"], value)
|
||||
print_success(saved_msg)
|
||||
else:
|
||||
print_warning(skipped_msg)
|
||||
def _run_nous_flow(config: dict, *, context: str, cancel_exc: tuple, cancel_lines: tuple, print_error) -> bool:
|
||||
"""Run ``_model_flow_nous`` (login, model pick, provider switch, Tool Gateway opt-in) — the
|
||||
single source of truth shared with ``hermes model``. False when cancelled or failed (the
|
||||
message is already printed)."""
|
||||
from hermes_cli.setup import _info
|
||||
try:
|
||||
from hermes_cli.main import _model_flow_nous
|
||||
_model_flow_nous(config)
|
||||
return True
|
||||
except cancel_exc:
|
||||
# _login_nous raises SystemExit(130)/(1) on cancel/failure; the expired-session re-login
|
||||
# path inside _model_flow_nous only catches Exception, so SystemExit would kill the CLI.
|
||||
_info(*cancel_lines)
|
||||
except Exception as exc:
|
||||
logger.debug("_model_flow_nous error during %s: %s", context, exc)
|
||||
print_error(exc)
|
||||
return False
|
||||
|
||||
|
||||
def _run_portal_one_shot(config: dict) -> None:
|
||||
"""One-shot Nous Portal setup (``hermes setup --portal`` / ``hermes portal``).
|
||||
|
||||
Login, model pick, provider switch and Tool Gateway opt-in are all delegated to
|
||||
``_model_flow_nous`` — the same flow quick setup and ``hermes model`` use for Nous — so
|
||||
there is one source of truth and ``hermes portal`` always offers a picker.
|
||||
"""
|
||||
from hermes_cli.setup import color, Colors, _info, load_config, print_error, print_info, print_success
|
||||
print()
|
||||
print(color("┌─────────────────────────────────────────────────────────┐", Colors.MAGENTA))
|
||||
print(color("│ ⚕ Hermes Setup — Nous Portal (one-shot) │", Colors.MAGENTA))
|
||||
print(color("└─────────────────────────────────────────────────────────┘", Colors.MAGENTA))
|
||||
"""One-shot Nous Portal setup (``hermes setup --portal`` / ``hermes portal``)."""
|
||||
from hermes_cli.setup import _info, _print_banner, print_error, print_info, print_success
|
||||
_print_banner("│ ⚕ Hermes Setup — Nous Portal (one-shot) │")
|
||||
_info(None, " One subscription, 300+ models, plus the Tool Gateway:",
|
||||
" web search, image generation, TTS, browser automation",
|
||||
" — all routed through your Nous Portal sub.", None,
|
||||
" Sign up: https://portal.nousresearch.com/manage-subscription", None)
|
||||
|
||||
try:
|
||||
from hermes_cli.main import _model_flow_nous
|
||||
_model_flow_nous(config)
|
||||
except (KeyboardInterrupt, EOFError, SystemExit):
|
||||
# _login_nous raises SystemExit(130)/(1) on cancel/failure; the expired-session re-login
|
||||
# path inside _model_flow_nous only catches Exception, so SystemExit would kill the CLI.
|
||||
_info(None, " Setup cancelled.", " You can retry later with `hermes portal`.")
|
||||
return
|
||||
except Exception as exc:
|
||||
logger.debug("_model_flow_nous error during `hermes portal`: %s", exc)
|
||||
def _on_error(exc: Exception) -> None:
|
||||
print()
|
||||
print_error(f" Nous Portal setup encountered an error: {exc}")
|
||||
print_info(" You can retry later with `hermes portal`.")
|
||||
|
||||
if not _run_nous_flow(config, context="`hermes portal`", cancel_exc=(KeyboardInterrupt, EOFError, SystemExit),
|
||||
cancel_lines=(None, " Setup cancelled.", " You can retry later with `hermes portal`."),
|
||||
print_error=_on_error):
|
||||
return
|
||||
|
||||
# Re-sync from disk so a caller's later save_config(config) can't clobber the login save.
|
||||
try:
|
||||
_refreshed = load_config()
|
||||
if isinstance(_refreshed, dict):
|
||||
config.clear()
|
||||
config.update(_refreshed)
|
||||
_reload_config_into(config, dict_only=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -81,33 +77,26 @@ def _run_portal_one_shot(config: dict) -> None:
|
||||
|
||||
|
||||
def _run_first_time_quick_setup(config: dict, hermes_home, is_existing: bool):
|
||||
"""Streamlined first-time setup via Nous Portal: OAuth, model, terminal & messaging.
|
||||
|
||||
Everything else gets sensible defaults; customize later via ``hermes setup
|
||||
<section>`` or switch providers with ``hermes model``.
|
||||
"""
|
||||
"""Streamlined first-time setup via Nous Portal: OAuth, model, terminal & messaging;
|
||||
everything else gets defaults."""
|
||||
from hermes_cli.setup import (
|
||||
_apply_default_agent_settings, _info, print_header, print_info, _print_macos_fda_tip,
|
||||
_print_setup_summary, print_success, print_warning, prompt_choice, save_config, setup_gateway,
|
||||
setup_terminal_backend,
|
||||
)
|
||||
# Step 1: Nous Portal — OAuth login + model selection (provider set to "nous" by the save).
|
||||
print()
|
||||
print_header("Nous Portal")
|
||||
print_header("Nous Portal", gap=True)
|
||||
_info("One subscription, 300+ models, plus the Tool Gateway:",
|
||||
" web search, image generation, TTS, browser automation.",
|
||||
"Sign up: https://portal.nousresearch.com/manage-subscription", None)
|
||||
try:
|
||||
from hermes_cli.main import _model_flow_nous
|
||||
_model_flow_nous(config)
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
_info(None, "Nous Portal setup cancelled.")
|
||||
except Exception as exc:
|
||||
logger.debug("_model_flow_nous error during quick setup: %s", exc)
|
||||
|
||||
def _on_error(exc: Exception) -> None:
|
||||
print_warning(f"Nous Portal setup encountered an error: {exc}")
|
||||
print_info("You can try again later with: hermes model")
|
||||
|
||||
# The wizard's later save_config(config) must not clobber the login/model save (#4172).
|
||||
_run_nous_flow(config, context="quick setup", cancel_exc=(KeyboardInterrupt, EOFError),
|
||||
cancel_lines=(None, "Nous Portal setup cancelled."), print_error=_on_error)
|
||||
# The wizard's later save_config(config) must not clobber the login/model save.
|
||||
_reload_config_into(config)
|
||||
|
||||
# Step 2: Terminal Backend; Step 3: defaults for everything else.
|
||||
@@ -125,9 +114,8 @@ def _run_first_time_quick_setup(config: dict, hermes_home, is_existing: bool):
|
||||
setup_gateway(config)
|
||||
save_config(config)
|
||||
else:
|
||||
# Messaging skipped — still install/start the gateway service so cron
|
||||
# jobs run and platforms come alive as soon as tokens are added later
|
||||
# (e.g. via `hermes import` from another machine).
|
||||
# Messaging skipped — still install/start the gateway service so cron jobs run and
|
||||
# platforms come alive as soon as tokens are added later (e.g. via `hermes import`).
|
||||
from hermes_cli.gateway import ensure_gateway_service
|
||||
ensure_gateway_service(context="setup")
|
||||
|
||||
@@ -143,19 +131,14 @@ def _run_first_time_quick_setup(config: dict, hermes_home, is_existing: bool):
|
||||
|
||||
|
||||
def _print_macos_fda_tip() -> None:
|
||||
"""One-time macOS onboarding tip: a single Full Disk Access grant kills
|
||||
every per-folder permission prompt, permanently (issue #52010 follow-up).
|
||||
|
||||
Uses the same prompt-free probe as doctor's check_macos_full_disk_access
|
||||
(the TCC db dir is FDA-gated but probing it never triggers a dialog).
|
||||
Silent on non-macOS and when FDA is already granted or indeterminate.
|
||||
"""
|
||||
"""One-time macOS tip: one Full Disk Access grant kills every per-folder prompt. Same
|
||||
prompt-free probe as doctor's check_macos_full_disk_access (the FDA-gated TCC dir never
|
||||
triggers a dialog); silent on non-macOS and when FDA is granted or indeterminate."""
|
||||
from hermes_cli.setup import _info
|
||||
if sys.platform != "darwin":
|
||||
return
|
||||
tcc_dir = Path.home() / "Library" / "Application Support" / "com.apple.TCC"
|
||||
try:
|
||||
os.listdir(tcc_dir)
|
||||
os.listdir(Path.home() / "Library" / "Application Support" / "com.apple.TCC")
|
||||
return # already granted — nothing to teach
|
||||
except PermissionError:
|
||||
pass
|
||||
@@ -164,40 +147,29 @@ def _print_macos_fda_tip() -> None:
|
||||
_info(None, " macOS tip: silence ALL folder permission prompts with one switch —",
|
||||
" System Settings → Privacy & Security → Full Disk Access → enable",
|
||||
" your terminal (and Hermes.app if you use Desktop), or run:",
|
||||
" open \"x-apple.systempreferences:com.apple.preference" ".security?Privacy_AllFiles\"",
|
||||
" open \"x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles\"",
|
||||
" The grant is permanent — it survives every Hermes update.")
|
||||
|
||||
|
||||
def _blank_slate_minimal_toolsets(config: dict):
|
||||
"""Write the minimal toolset state for a Blank Slate install.
|
||||
|
||||
Only ``file``, ``terminal``, ``vision`` and ``skills`` stay on: ``read_file``
|
||||
can't read images (it points at ``vision_analyze``), and the always-seeded
|
||||
``hermes-agent`` skill needs ``skill_view``. Two layers enforce the selection:
|
||||
1. ``platform_toolsets["cli"]`` — an explicit configurable-key list the resolver
|
||||
treats as authoritative (``has_explicit_config``), so defaults aren't re-expanded.
|
||||
2. ``agent.disabled_toolsets`` — a global hard-suppression list applied last in
|
||||
``_get_platform_tools``, overriding even the non-configurable platform-toolset
|
||||
recovery that would re-add e.g. ``kanban``. Users re-enable via ``hermes tools``
|
||||
(rewrites ``platform_toolsets``) or by editing ``agent.disabled_toolsets``.
|
||||
"""
|
||||
"""Write the minimal toolset state for a Blank Slate install: only ``file``, ``terminal``,
|
||||
``vision`` (``read_file`` can't read images) and ``skills`` (the seeded ``hermes-agent`` skill
|
||||
needs ``skill_view``) stay on. Two layers enforce it: ``platform_toolsets["cli"]`` (explicit,
|
||||
so defaults aren't re-expanded) and ``agent.disabled_toolsets`` (hard-suppression applied last
|
||||
in ``_get_platform_tools``, overriding the recovery that would re-add e.g. ``kanban``)."""
|
||||
keep = {"file", "terminal", "vision", "skills"}
|
||||
config.setdefault("platform_toolsets", {})["cli"] = sorted(keep)
|
||||
|
||||
try:
|
||||
from toolsets import TOOLSETS
|
||||
from hermes_cli.tools_config import CONFIGURABLE_TOOLSETS, _get_plugin_toolset_keys
|
||||
|
||||
all_keys = {k for k, _, _ in CONFIGURABLE_TOOLSETS}
|
||||
all_keys.update(_get_plugin_toolset_keys())
|
||||
# Plain TOOLSETS entries catch recovered toolsets like ``kanban``. Skip
|
||||
# "hermes-*" platform composites, "includes" groupings, and posture toolsets
|
||||
# (session-level picks by agent/coding_context.py — disabling them would make
|
||||
# model_tools subtract terminal/read_file from the minimal surface, #57315).
|
||||
# Plain TOOLSETS entries catch recovered toolsets like ``kanban``. Skip "hermes-*" platform
|
||||
# composites, "includes" groupings, and posture toolsets (session-level picks by
|
||||
# agent/coding_context.py — disabling them would subtract terminal/read_file).
|
||||
for k, tdef in TOOLSETS.items():
|
||||
if k.startswith("hermes-"):
|
||||
continue
|
||||
if isinstance(tdef, dict) and (tdef.get("includes") or tdef.get("posture")):
|
||||
if k.startswith("hermes-") or (isinstance(tdef, dict) and (tdef.get("includes") or tdef.get("posture"))):
|
||||
continue
|
||||
all_keys.add(k)
|
||||
|
||||
@@ -209,9 +181,7 @@ def _blank_slate_minimal_toolsets(config: dict):
|
||||
|
||||
|
||||
def _blank_slate_minimize_config(config: dict):
|
||||
"""Turn OFF the optional config features (compression, memory/profile capture,
|
||||
checkpoints, smart routing, auto session reset; quiet display). All opt-in
|
||||
afterwards via ``hermes setup agent`` / ``hermes config set``."""
|
||||
"""Turn OFF every optional config feature; all opt back in via ``hermes setup agent``."""
|
||||
config.setdefault("agent", {})["max_turns"] = 90
|
||||
config.setdefault("compression", {})["enabled"] = False
|
||||
mem = config.setdefault("memory", {})
|
||||
@@ -223,17 +193,30 @@ def _blank_slate_minimize_config(config: dict):
|
||||
config.setdefault("display", {})["tool_progress"] = "all"
|
||||
|
||||
|
||||
def _set_bundled_skills_opt_out(opt_out: bool, log_label: str, on_success=None, on_error=None) -> None:
|
||||
"""Record the bundled-skills opt-out marker and sync (essential skills are always seeded);
|
||||
``on_success(sync_result)`` / ``on_error(exc)`` report the outcome."""
|
||||
try:
|
||||
from tools.skills_sync import set_bundled_skills_opt_out, sync_skills
|
||||
set_bundled_skills_opt_out(opt_out)
|
||||
result = sync_skills(quiet=True)
|
||||
if on_success is not None:
|
||||
on_success(result)
|
||||
except Exception as exc:
|
||||
logger.debug("blank-slate %s error: %s", log_label, exc)
|
||||
if on_error is not None:
|
||||
on_error(exc)
|
||||
|
||||
|
||||
def _run_blank_slate_setup(config: dict, hermes_home, is_existing: bool):
|
||||
"""Blank Slate setup — essentials only (provider/model, file + terminal), everything
|
||||
else OFF; then either finish now or walk through opting capabilities back in.
|
||||
Nothing is enabled that the user did not explicitly choose."""
|
||||
"""Blank Slate setup — essentials only, everything else OFF; then finish now or walk through
|
||||
opting capabilities back in. Nothing is enabled that the user did not explicitly choose."""
|
||||
from hermes_cli.setup import (
|
||||
_blank_slate_minimal_toolsets, _blank_slate_minimize_config, _blank_slate_walkthrough, _info,
|
||||
print_header, print_info, _print_setup_summary, print_success, prompt_choice, save_config,
|
||||
setup_model_provider, setup_terminal_backend,
|
||||
)
|
||||
print()
|
||||
print_header("Blank Slate Setup")
|
||||
print_header("Blank Slate Setup", gap=True)
|
||||
_info("Everything starts OFF. First we force-enable only what's required",
|
||||
"to run an agent, then you choose whether to stop there or walk",
|
||||
"through enabling more — opting in to exactly what you want.", "",
|
||||
@@ -262,33 +245,25 @@ def _run_blank_slate_setup(config: dict, hermes_home, is_existing: bool):
|
||||
print_info(" Compression, memory, checkpoints, smart routing: off")
|
||||
|
||||
# The fork: stop here, or walk through enabling things
|
||||
print()
|
||||
print_header("How far do you want to go?")
|
||||
print_header("How far do you want to go?", gap=True)
|
||||
path = prompt_choice("Your minimal agent is ready. What next?", [
|
||||
"Start with everything disabled — finish now (most minimal)",
|
||||
"Walk through all configurations — opt in to tools, skills, plugins, MCP",
|
||||
], 0)
|
||||
|
||||
if path == 0:
|
||||
save_config(config)
|
||||
# Blank Slate means no bundled skills; record the opt-out so future
|
||||
# `hermes update` runs don't re-inject them. Essential skills (the
|
||||
# `hermes-agent` operating manual) are still seeded by the sync.
|
||||
try:
|
||||
from tools.skills_sync import set_bundled_skills_opt_out, sync_skills
|
||||
set_bundled_skills_opt_out(True)
|
||||
sync_skills(quiet=True)
|
||||
except Exception as exc:
|
||||
logger.debug("blank-slate skill opt-out error: %s", exc)
|
||||
print()
|
||||
print_success("Blank Slate setup complete — minimal agent ready.")
|
||||
_info("Enable anything later, on demand:", " Enable tools: hermes tools",
|
||||
" Seed skills: hermes skills opt-in --sync", " Add MCP servers: hermes mcp add",
|
||||
" Enable plugins: hermes plugins", " Tune agent settings: hermes setup agent", None)
|
||||
_print_setup_summary(config, hermes_home)
|
||||
if path != 0:
|
||||
_blank_slate_walkthrough(config, hermes_home)
|
||||
return
|
||||
|
||||
_blank_slate_walkthrough(config, hermes_home)
|
||||
save_config(config)
|
||||
# Blank Slate means no bundled skills; record the opt-out so future `hermes update` runs
|
||||
# don't re-inject them.
|
||||
_set_bundled_skills_opt_out(True, "skill opt-out")
|
||||
print()
|
||||
print_success("Blank Slate setup complete — minimal agent ready.")
|
||||
_info("Enable anything later, on demand:", " Enable tools: hermes tools", *_BLANK_SLATE_DONE_LINES,
|
||||
" Enable plugins: hermes plugins", " Tune agent settings: hermes setup agent", None)
|
||||
_print_setup_summary(config, hermes_home)
|
||||
|
||||
|
||||
def _blank_slate_walkthrough(config: dict, hermes_home):
|
||||
@@ -298,35 +273,28 @@ def _blank_slate_walkthrough(config: dict, hermes_home):
|
||||
save_config, setup_gateway,
|
||||
)
|
||||
# Bundled skills — default to NONE, offer to seed all
|
||||
print()
|
||||
print_header("Bundled Skills")
|
||||
print_header("Bundled Skills", gap=True)
|
||||
print_info("Blank Slate ships with NO bundled skills by default.")
|
||||
seed_skills = prompt_yes_no(
|
||||
"Seed the full bundled skill catalog? (No = start with zero skills)", default=False
|
||||
seed_skills = prompt_yes_no("Seed the full bundled skill catalog? (No = start with zero skills)", default=False)
|
||||
|
||||
def _seeded(result) -> None:
|
||||
copied = len(result.get("copied", [])) if isinstance(result, dict) else 0
|
||||
print_success(f"Seeded {copied} bundled skills.")
|
||||
|
||||
def _opted_out(_result) -> None:
|
||||
_info("No skills seeded (except the essential `hermes-agent`",
|
||||
"skill). A .no-bundled-skills marker keeps future",
|
||||
"`hermes update` runs from re-injecting them. Opt back in any",
|
||||
"time with `hermes skills opt-in --sync`.")
|
||||
|
||||
# Seeding first clears any stale opt-out marker; declining sets it (essential skills still seed).
|
||||
_set_bundled_skills_opt_out(
|
||||
not seed_skills, "skill handling", on_success=_seeded if seed_skills else _opted_out,
|
||||
on_error=lambda exc: print_warning(f"Skill setup step encountered an error: {exc}"),
|
||||
)
|
||||
try:
|
||||
from tools.skills_sync import set_bundled_skills_opt_out, sync_skills
|
||||
if seed_skills:
|
||||
# Make sure no stale opt-out marker blocks the seed, then sync.
|
||||
set_bundled_skills_opt_out(False)
|
||||
result = sync_skills(quiet=True)
|
||||
copied = len(result.get("copied", [])) if isinstance(result, dict) else 0
|
||||
print_success(f"Seeded {copied} bundled skills.")
|
||||
else:
|
||||
set_bundled_skills_opt_out(True)
|
||||
# Essential skills (`hermes-agent`) are still seeded for an opted-out profile.
|
||||
sync_skills(quiet=True)
|
||||
_info("No skills seeded (except the essential `hermes-agent`",
|
||||
"skill). A .no-bundled-skills marker keeps future",
|
||||
"`hermes update` runs from re-injecting them. Opt back in any",
|
||||
"time with `hermes skills opt-in --sync`.")
|
||||
except Exception as exc:
|
||||
logger.debug("blank-slate skill handling error: %s", exc)
|
||||
print_warning(f"Skill setup step encountered an error: {exc}")
|
||||
|
||||
# Walk through enabling additional tools
|
||||
print()
|
||||
print_header("Tools")
|
||||
print_header("Tools", gap=True)
|
||||
_info("Pick exactly which additional toolsets to turn on.",
|
||||
"(file and terminal are already on; leave the rest off if you want", " the most minimal agent.)")
|
||||
if prompt_yes_no("Open the tool selector to enable more tools?", default=False):
|
||||
@@ -340,21 +308,17 @@ def _blank_slate_walkthrough(config: dict, hermes_home):
|
||||
else:
|
||||
print_info("Keeping the minimal toolset. Add tools later with `hermes tools`.")
|
||||
|
||||
# Built-in plugins (off unless chosen)
|
||||
print()
|
||||
print_header("Plugins")
|
||||
if prompt_yes_no("Review and enable built-in plugins now?", default=False):
|
||||
print_info("Manage plugins with `hermes plugins list` / `hermes plugins install`.")
|
||||
else:
|
||||
print_info("No plugins enabled. Add later with `hermes plugins`.")
|
||||
|
||||
# MCP servers (off unless chosen)
|
||||
print()
|
||||
print_header("MCP Servers")
|
||||
if prompt_yes_no("Add an MCP server now?", default=False):
|
||||
print_info("Add servers with `hermes mcp add <name> --url ... | --command ...`.")
|
||||
else:
|
||||
print_info("No MCP servers configured. Add later with `hermes mcp add`.")
|
||||
# Built-in plugins and MCP servers (off unless chosen)
|
||||
for header, question, yes_msg, no_msg in (
|
||||
("Plugins", "Review and enable built-in plugins now?",
|
||||
"Manage plugins with `hermes plugins list` / `hermes plugins install`.",
|
||||
"No plugins enabled. Add later with `hermes plugins`."),
|
||||
("MCP Servers", "Add an MCP server now?",
|
||||
"Add servers with `hermes mcp add <name> --url ... | --command ...`.",
|
||||
"No MCP servers configured. Add later with `hermes mcp add`."),
|
||||
):
|
||||
print_header(header, gap=True)
|
||||
print_info(yes_msg if prompt_yes_no(question, default=False) else no_msg)
|
||||
|
||||
# Optional messaging gateway
|
||||
print()
|
||||
@@ -365,8 +329,8 @@ def _blank_slate_walkthrough(config: dict, hermes_home):
|
||||
|
||||
print()
|
||||
print_success("Blank Slate setup complete — minimal agent ready.")
|
||||
_info(" Enable more tools: hermes tools", " Seed skills: hermes skills opt-in --sync",
|
||||
" Add MCP servers: hermes mcp add", " Tune agent settings: hermes setup agent", None)
|
||||
_info(" Enable more tools: hermes tools", *_BLANK_SLATE_DONE_LINES,
|
||||
" Tune agent settings: hermes setup agent", None)
|
||||
|
||||
_print_setup_summary(config, hermes_home)
|
||||
|
||||
@@ -375,11 +339,10 @@ def _run_quick_setup(config: dict, hermes_home):
|
||||
"""Quick setup — only configure items that are missing."""
|
||||
from hermes_cli.setup import (
|
||||
color, Colors, _info, print_header, print_info, _print_setup_summary, print_success,
|
||||
_prompt_api_key, prompt_checklist, save_config,
|
||||
_prompt_and_save_env_var, _prompt_api_key, prompt_checklist, save_config,
|
||||
)
|
||||
from hermes_cli.config import (get_missing_env_vars, get_missing_config_fields, check_config_version)
|
||||
print()
|
||||
print_header("Quick Setup — Missing Items Only")
|
||||
print_header("Quick Setup — Missing Items Only", gap=True)
|
||||
|
||||
# Check what's missing
|
||||
missing_required = [v for v in get_missing_env_vars(required_only=False) if v.get("is_required")]
|
||||
@@ -407,13 +370,10 @@ def _run_quick_setup(config: dict, hermes_home):
|
||||
_prompt_and_save_env_var(var, f" Saved {var['name']}", f" Skipped {var['name']}")
|
||||
|
||||
missing_tools = [v for v in missing_optional if v.get("category") == "tool"]
|
||||
missing_messaging = [
|
||||
v for v in missing_optional if v.get("category") == "messaging" and not v.get("advanced")
|
||||
]
|
||||
missing_messaging = [v for v in missing_optional if v.get("category") == "messaging" and not v.get("advanced")]
|
||||
|
||||
if missing_tools: # checklist, then the API-key screen for each pick
|
||||
print()
|
||||
print_header("Tool API Keys")
|
||||
print_header("Tool API Keys", gap=True)
|
||||
labels = []
|
||||
for var in missing_tools:
|
||||
tools = var.get("tools", [])
|
||||
@@ -423,8 +383,7 @@ def _run_quick_setup(config: dict, hermes_home):
|
||||
_prompt_api_key(missing_tools[idx])
|
||||
|
||||
if missing_messaging: # checklist, then prompt for each selected platform's vars
|
||||
print()
|
||||
print_header("Messaging Platforms")
|
||||
print_header("Messaging Platforms", gap=True)
|
||||
_info("Connect Hermes to messaging apps to chat from anywhere.",
|
||||
"You can configure these later with 'hermes setup gateway'.")
|
||||
# Group by platform in first-seen order; vars matching no platform are dropped.
|
||||
|
||||
Reference in New Issue
Block a user