Instructions to use Wellz1000/hermes-2 with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- HERMES
How to use Wellz1000/hermes-2 with HERMES:
# No code snippets available yet for this library. # To use this model, check the repository files and the library's documentation. # Want to help? PRs adding snippets are welcome at: # https://github.com/huggingface/huggingface.js
- Notebooks
- Google Colab
- Kaggle
File size: 23,093 Bytes
176d459 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 | """
``hermes photon ...`` CLI subcommands β registered by the plugin via
``ctx.register_cli_command()``.
Subcommands:
setup full first-time setup (device login + project + user + sidecar)
status show login + project + sidecar dep state
install-sidecar npm install inside plugins/platforms/photon/sidecar/
telemetry show or toggle Spectrum SDK telemetry (on/off)
The device-code login runs automatically as the first step of ``setup``;
there is no standalone ``login`` verb (matching how every other Hermes
gateway channel onboards through a single setup surface).
Photon uses the spectrum-ts gRPC stream for inbound β there is no webhook
to register, so there are no webhook subcommands.
"""
from __future__ import annotations
import argparse
import getpass
import os
import shutil
import subprocess
import sys
from pathlib import Path
from hermes_cli.colors import Colors, color
from . import auth as photon_auth
from .adapter import _NPM_ERROR_LOG_MAX_CHARS, sidecar_deps_installed
from .sidecar_paths import resolve_sidecar_dir
# Writable sidecar runtime dir (mirrors to HERMES_HOME on immutable
# installs β NS-606). All npm/setup work happens here. Resolved lazily on
# first use β resolve_sidecar_dir() probes the filesystem and may mirror
# files, side effects that must not fire at import time (e.g. when argparse
# wiring imports this module for `hermes --help`).
# Tests monkeypatch these module globals directly; the accessors honor a
# non-None value and only resolve/derive when unset.
_SIDECAR_DIR: Path | None = None
# Written on npm failure so check_requirements() can surface the root cause
# when called later (gateway start, hermes status). Cleared on success.
_NPM_ERROR_LOG: Path | None = None
def _sidecar_dir() -> Path:
"""Sidecar runtime dir, resolved once on first use (never at import)."""
global _SIDECAR_DIR
if _SIDECAR_DIR is None:
_SIDECAR_DIR = resolve_sidecar_dir()
return _SIDECAR_DIR
def _npm_error_log() -> Path:
"""Path of the persisted npm-failure log (derived from the sidecar dir)."""
if _NPM_ERROR_LOG is not None:
return _NPM_ERROR_LOG
return _sidecar_dir() / ".photon-npm-error.log"
# ---------------------------------------------------------------------------
# argparse wiring
def register_cli(parser: argparse.ArgumentParser) -> None:
"""Wire up `hermes photon ...` subcommands."""
subs = parser.add_subparsers(dest="photon_command", required=False)
p_setup = subs.add_parser(
"setup",
help="First-time setup (device login + project + user + sidecar)",
)
p_setup.add_argument("--project-name", default=None,
help="Project name (default: 'Hermes Agent')")
p_setup.add_argument("--phone", default=None,
help="Your E.164 phone number (e.g. +15551234567)")
p_setup.add_argument("--first-name", default=None)
p_setup.add_argument("--last-name", default=None)
p_setup.add_argument("--email", default=None)
p_setup.add_argument("--no-browser", action="store_true",
help="Don't try to open a browser for device login; print the URL only")
p_setup.add_argument("--skip-sidecar-install", action="store_true",
help="Skip `npm install` inside the sidecar directory")
subs.add_parser("status", help="Show login + project + sidecar dep state")
subs.add_parser("install-sidecar", help="Run npm install inside the sidecar directory")
p_telemetry = subs.add_parser(
"telemetry",
help="Show or toggle Spectrum SDK telemetry (on/off)",
)
p_telemetry.add_argument(
"state", nargs="?", choices=("on", "off"),
help="Turn telemetry on or off (omit to show the current state)",
)
parser.set_defaults(func=dispatch)
# ---------------------------------------------------------------------------
# Dispatch
def dispatch(args: argparse.Namespace) -> int:
sub = getattr(args, "photon_command", None)
if sub is None:
# No subcommand given β show status by default.
return _cmd_status(args)
if sub == "setup":
return _cmd_setup(args)
if sub == "status":
return _cmd_status(args)
if sub == "install-sidecar":
return _cmd_install_sidecar(args)
if sub == "telemetry":
return _cmd_telemetry(args)
print(f"unknown subcommand: {sub}", file=sys.stderr)
return 2
# ---------------------------------------------------------------------------
# Subcommand handlers
def _run_device_login(args: argparse.Namespace) -> int:
"""Run the RFC 8628 device-code login flow and persist the token.
Internal helper β invoked as the first step of ``setup``. There is
no standalone ``hermes photon login`` command; Photon onboards
through the single ``setup`` surface like every other channel.
"""
def _print_code(code):
target = code.verification_uri_complete or code.verification_uri
print()
print("ββ Photon device login ββββββββββββββββββββββββββββββββββββββββ")
print(f"β Open this URL: {target}")
print(f"β Enter the code: {code.user_code}")
print("β (waiting for approval β Ctrl-C to cancel)")
print("βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ")
print()
try:
token = photon_auth.login_device_flow(
open_browser=not args.no_browser,
on_user_code=_print_code,
)
except Exception as e:
print(f"login failed: {e}", file=sys.stderr)
return 1
# Don't print any portion of the token β even a prefix can help a
# shoulder-surfer or accidentally leak into a screen recording.
_ = token
print(f"β logged in β token saved to {photon_auth._auth_json_path()}")
return 0
def _cmd_setup(args: argparse.Namespace) -> int:
# 1. Login (skip if we already have a valid token).
token = photon_auth.load_photon_token()
if token:
# Validate the existing token β the dashboard token has a short TTL
# and can go stale between runs (observed: ~3-4 days). Reusing a
# stale token causes every management call to fail with 401 and
# leaves the operator confused about why setup "succeeds" but nothing
# works. Check upfront so we fail fast and fall back to fresh login.
print("[1/5] Checking existing Photon token...")
if photon_auth.check_photon_token_valid(token):
print(" β token is valid")
else:
print(" β token is stale (dashboard rejected it) β re-authenticating")
photon_auth.clear_photon_token()
token = None
if not token:
print("[1/5] No valid Photon token found β running device login...")
rc = _run_device_login(args)
if rc != 0:
return rc
token = photon_auth.load_photon_token()
if not token:
print("login completed but token was not stored", file=sys.stderr)
return 1
else:
print("[1/5] Reusing existing Photon token")
# 2. Find or create the "Hermes Agent" project.
name = args.project_name or photon_auth.DEFAULT_PROJECT_NAME
dashboard_id = photon_auth.load_dashboard_project_id()
try:
if dashboard_id:
print("[2/5] Reusing configured Photon project")
else:
existing = photon_auth.find_project_by_name(token, name)
if existing and existing.get("id"):
dashboard_id = existing["id"]
print(f"[2/5] Found existing project '{name}'")
else:
print(f"[2/5] Creating Photon project '{name}'...")
created = photon_auth.create_project(token, name=name)
dashboard_id = created.get("id")
print(" β project created")
except Exception as e:
print(f"project setup failed: {e}", file=sys.stderr)
return 1
if not dashboard_id:
print("could not resolve a Photon project id", file=sys.stderr)
return 1
# 3. Provision Spectrum credentials (runtime -> ~/.hermes/.env,
# ids -> auth.json). Spectrum is always enabled and provisioned at
# create-time, and the dashboard project id *is* the Spectrum project id
# (ids unified), so there's nothing to enable β the id we already have is
# the Spectrum id.
#
# On re-run we reuse an existing valid secret instead of regenerating.
# Regenerating invalidates the credential that a running sidecar holds
# in its process env, causing all outbound sends to fail with
# AuthenticationError until the gateway is restarted (GH #50755).
try:
print("[3/5] Provisioning Spectrum credentials...")
spectrum_id = dashboard_id
existing_id, existing_secret = photon_auth.load_project_credentials()
secret: str = ""
reused = False
if existing_id and existing_secret:
# Validate the existing credential with a lightweight API call.
try:
photon_auth.list_users(existing_id, existing_secret)
secret = existing_secret
reused = True
except Exception:
secret = "" # fall through to regeneration
if not secret:
secret = photon_auth.regenerate_project_secret(token, dashboard_id)
photon_auth.store_project_credentials(
spectrum_project_id=spectrum_id,
project_secret=secret,
dashboard_project_id=dashboard_id,
name=name,
)
# spectrum_id is an opaque non-secret id; safe to show.
if reused:
print(f" β Spectrum ready (project id {spectrum_id}) β existing credentials valid")
else:
print(f" β Spectrum ready (project id {spectrum_id}) β new secret saved")
print(
" β Project secret was regenerated. If the gateway is running, "
"restart it so the sidecar picks up the new secret:\n"
" hermes gateway restart"
)
except Exception as e:
print(f"spectrum provisioning failed: {e}", file=sys.stderr)
return 1
# 4. Register the operator's phone number as a Spectrum user (idempotent).
phone = args.phone or _prompt(
color(
"[4/5] Your iMessage phone number (E.164, e.g. +15551234567): ",
Colors.CYAN,
)
)
agent_number = None
registered_phone = None
registered_user_id = None
if not phone:
print(" Skipped user registration (no phone given). Re-run with --phone later.")
else:
# Name/email are optional and never prompted for β pass --first-name /
# --email if you want them sent to the dashboard.
first_name = args.first_name
email = args.email
try:
user, created = photon_auth.register_user_if_absent(
spectrum_id, secret,
phone_number=phone,
first_name=first_name,
last_name=args.last_name,
email=email,
)
except ValueError as e:
print(f" invalid phone number: {e}", file=sys.stderr)
return 1
except Exception as e:
print(f" user registration failed: {e}", file=sys.stderr)
return 1
print(" β phone registered" if created else " β phone already registered")
registered_phone = phone
registered_user_id = user.get("id")
# The number to text the agent is the user's assigned iMessage line
# (the dashboard's "TEXTS ON" column). On shared-number plans there is
# no dedicated entry in /lines, so this per-user field is the source of
# truth β and we already have it from the (reused) user object.
agent_number = photon_auth.user_assigned_line(user)
# Allowlist the operator and make their DM the cron home channel β
# otherwise the gateway denies their own inbound messages
# ("Unauthorized user") and has no default space for cron delivery.
_autoconfigure_access(phone)
# 5. Surface the agent's iMessage number (the number to text the agent).
if not agent_number:
# No per-user assignment β fall back to a dedicated line if the project
# has one provisioned in its line inventory.
try:
line = photon_auth.get_imessage_line(token, dashboard_id)
if line:
agent_number = line.get("phoneNumber")
except Exception as e:
print(f" (could not fetch the assigned line: {e})", file=sys.stderr)
if agent_number:
print()
print(color("ββ Your agent's iMessage number βββββββββββββββββββββββββββββββ", Colors.GREEN))
print(
color("β π± ", Colors.GREEN)
+ color(str(agent_number), Colors.GREEN, Colors.BOLD)
)
print(color("β Text this number from your phone to talk to your agent.", Colors.GREEN))
print(color("βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ", Colors.GREEN))
else:
print(" No iMessage line assigned yet β check the Photon dashboard.")
if registered_phone:
try:
photon_auth.store_user_numbers(
phone_number=registered_phone,
assigned_phone_number=agent_number,
user_id=str(registered_user_id) if registered_user_id else None,
dashboard_project_id=dashboard_id,
)
except Exception as e:
print(f" (could not save Photon status metadata: {e})", file=sys.stderr)
# 6. Sidecar deps (spectrum-ts).
if args.skip_sidecar_install:
print("[5/5] Skipping sidecar npm install (--skip-sidecar-install)")
else:
print("[5/5] Installing Node sidecar deps (spectrum-ts)...")
rc = _install_sidecar()
if rc != 0:
return rc
# 7. Ensure the photon platform is enabled in config.yaml so the
# gateway loads it on next start. Without this the channel stays
# disabled even after a successful provisioning run, silently
# keeping iMessage offline.
try:
from hermes_cli.config import write_platform_config_field
write_platform_config_field("photon", "enabled", True, raw=True)
print(" β photon platform enabled in config.yaml")
except Exception as e:
print(f" (could not enable Photon in config: {e})", file=sys.stderr)
print()
print("β Photon setup complete.")
print(" Start the gateway: hermes gateway start")
return 0
def _autoconfigure_access(phone: str) -> None:
"""Allowlist the operator and set their DM as the cron home channel.
Writes ``PHOTON_ALLOWED_USERS`` (so the gateway authorizes the operator's
own inbound messages instead of denying them) and ``PHOTON_HOME_CHANNEL``
(the default space for cron delivery) to the operator's E.164 number. Each
is only filled when unset, so a hand-tuned allowlist / home channel is
never clobbered on a re-run.
"""
try:
from hermes_cli.config import get_env_value, save_env_value
except ImportError:
return
for key, label in (
("PHOTON_ALLOWED_USERS", "allowlisted your number"),
("PHOTON_HOME_CHANNEL", "set your DM as the cron home channel"),
):
try:
if get_env_value(key):
print(f" {key} already set β leaving it as-is.")
continue
save_env_value(key, phone)
print(f" β {label} ({key})")
except Exception as e:
print(f" could not set {key}: {e}", file=sys.stderr)
def _cmd_status(_args: argparse.Namespace) -> int:
_refresh_status_numbers()
# Defer the credential rows to auth.print_credential_summary β its emit
# callback is the only sink that sees credential-derived strings, so
# cli.py keeps zero taint flow according to CodeQL.
photon_auth.print_credential_summary(print)
node_bin = os.getenv("PHOTON_NODE_BIN") or shutil.which("node")
sidecar_installed = sidecar_deps_installed()
print(f" node binary : {node_bin or 'β missing (install Node 18+)'}")
print(f" sidecar deps : {'β installed' if sidecar_installed else 'β run `hermes photon install-sidecar`'}")
print(f" telemetry : {'on' if _telemetry_enabled() else 'off'} (`hermes photon telemetry on|off`)")
return 0
def _refresh_status_numbers() -> None:
phone, assigned = photon_auth.load_user_numbers()
if phone and assigned:
return
spectrum_id, project_secret = photon_auth.load_project_credentials()
if not spectrum_id or not project_secret:
return
try:
photon_auth.refresh_user_numbers(spectrum_id, project_secret)
except Exception as e:
print(f" (could not refresh Photon user numbers: {e})", file=sys.stderr)
def _cmd_install_sidecar(_args: argparse.Namespace) -> int:
return _install_sidecar()
def _telemetry_enabled() -> bool:
"""Read PHOTON_TELEMETRY from the env / ~/.hermes/.env.
Mirrors the sidecar's truthy set (index.mjs) so the state shown here
always matches what the sidecar will actually do.
"""
try:
from hermes_cli.config import get_env_value
raw = get_env_value("PHOTON_TELEMETRY")
except ImportError:
raw = os.getenv("PHOTON_TELEMETRY")
return (raw or "").strip().lower() in ("1", "true", "yes", "on")
def _cmd_telemetry(args: argparse.Namespace) -> int:
state = getattr(args, "state", None)
if state is None:
print(f"Photon telemetry: {'on' if _telemetry_enabled() else 'off'}")
print(" Toggle with `hermes photon telemetry on` / `hermes photon telemetry off`.")
return 0
try:
from hermes_cli.config import save_env_value
save_env_value("PHOTON_TELEMETRY", "true" if state == "on" else "false")
except Exception as e:
print(f"could not save PHOTON_TELEMETRY: {e}", file=sys.stderr)
return 1
print(f"β Spectrum telemetry turned {state} (PHOTON_TELEMETRY in ~/.hermes/.env)")
print(" Restart the gateway for the sidecar to pick it up: hermes gateway restart")
return 0
def _install_sidecar() -> int:
npm = shutil.which("npm") or "npm"
if not shutil.which(npm):
print(
"npm is not on PATH. Install Node.js 18+ (https://nodejs.org/) "
"and re-run.",
file=sys.stderr,
)
return 1
# spectrum-ts is pinned exactly in package.json/package-lock.json because
# the SDK ships breaking majors (v2 removed defineFusorPlatform; v3
# reworked space construction; v5 split it into @spectrum-ts/* packages).
# Upgrades are deliberate: bump the pin, migrate sidecar/index.mjs, re-run
# the photon tests β never `@latest` (see README "Upgrading spectrum-ts").
# `npm ci` installs the committed lockfile verbatim; fall back to
# `npm install` when the lockfile is missing or drifted (e.g. a dev
# checkout mid-upgrade).
print(f" $ cd {_sidecar_dir()} && {npm} ci")
# stdout is not captured so npm progress prints to the terminal in real
# time. stderr is captured so we can persist the failure reason for
# check_requirements() to surface after the process exits.
proc = subprocess.run( # noqa: S603
[npm, "ci"],
cwd=str(_sidecar_dir()),
check=False,
stderr=subprocess.PIPE,
text=True,
)
if proc.stderr:
print(proc.stderr, end="", file=sys.stderr)
if proc.returncode != 0:
print(f" npm ci failed β falling back to: {npm} install")
proc = subprocess.run( # noqa: S603
[npm, "install"],
cwd=str(_sidecar_dir()),
check=False,
stderr=subprocess.PIPE,
text=True,
)
if proc.stderr:
print(proc.stderr, end="", file=sys.stderr)
if proc.returncode != 0:
print("npm install failed", file=sys.stderr)
# Bound to the same length check_requirements() truncates to on
# read, so the log file never holds more than what's ever surfaced.
error = (proc.stderr or "").strip()[:_NPM_ERROR_LOG_MAX_CHARS]
if error:
try:
_npm_error_log().write_text(error, encoding="utf-8")
except OSError:
pass
else:
try:
_npm_error_log().unlink()
except OSError:
pass
return proc.returncode
# ---------------------------------------------------------------------------
# Gateway-setup entry point
#
# `hermes gateway setup` discovers platforms via the registry and calls each
# entry's zero-arg ``setup_fn``. Photon registers this function so it appears
# in the unified setup wizard alongside every other channel β same onboarding
# surface, no Photon-specific detour. It runs the identical device-login +
# project + user + sidecar flow as ``hermes photon setup`` with interactive
# defaults (phone is prompted when stdin is a TTY).
def gateway_setup() -> None:
"""Run Photon first-time setup from the `hermes gateway setup` wizard."""
args = argparse.Namespace(
photon_command="setup",
project_name=None,
phone=None,
first_name=None,
last_name=None,
email=None,
no_browser=False,
skip_sidecar_install=False,
)
_cmd_setup(args)
# ---------------------------------------------------------------------------
# Small interactive helpers
def _prompt(prompt: str, *, secret: bool = False) -> str:
if not sys.stdin.isatty():
return ""
try:
if secret:
return getpass.getpass(prompt).strip()
return input(prompt).strip()
except (KeyboardInterrupt, EOFError):
print()
return ""
|