mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-24 11:38:29 +00:00
Render the reactive pet pane in the classic CLI (steady redraw, right-aligned) and wire the /pet command to list and switch pets, plus an enable/disable toggle. Backed by hermes_cli/pets.py and the CLI commands mixin, registered in the central command registry. Covered by the CLI pet pane and toggle tests.
483 lines
18 KiB
Python
483 lines
18 KiB
Python
"""CLI subcommand: ``hermes pets <subcommand>``.
|
||
|
||
Thin shell around :mod:`agent.pet`. Browses the public petdex gallery,
|
||
installs pets into the profile's ``pets/`` directory, selects the active
|
||
mascot (writes ``display.pet.*`` to config.yaml), and runs a doctor check.
|
||
|
||
No side effects at import time — ``main.py`` wires the argparse subparsers on
|
||
demand via :func:`register_cli`.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import sys
|
||
|
||
|
||
def _print(msg: str = "") -> None:
|
||
print(msg)
|
||
|
||
|
||
def _err(msg: str) -> None:
|
||
print(msg, file=sys.stderr)
|
||
|
||
|
||
def _cmd_list(args) -> int:
|
||
"""List gallery pets (or only installed ones with ``--installed``)."""
|
||
from agent.pet import store
|
||
|
||
if getattr(args, "installed", False):
|
||
pets = store.installed_pets()
|
||
if not pets:
|
||
_print("No pets installed. Try: hermes pets install boba")
|
||
return 0
|
||
_print(f"Installed pets ({len(pets)}):")
|
||
for pet in pets:
|
||
_print(f" {pet.slug:<24} {pet.display_name}")
|
||
return 0
|
||
|
||
from agent.pet.manifest import ManifestError, fetch_manifest
|
||
|
||
try:
|
||
entries = fetch_manifest()
|
||
except ManifestError as exc:
|
||
_err(f"✗ {exc}")
|
||
return 1
|
||
|
||
query = (getattr(args, "query", "") or "").strip().lower()
|
||
if query:
|
||
entries = [
|
||
e
|
||
for e in entries
|
||
if query in e.slug.lower() or query in e.display_name.lower()
|
||
]
|
||
|
||
limit = getattr(args, "limit", 0) or 0
|
||
shown = entries[:limit] if limit > 0 else entries
|
||
installed = {p.slug for p in store.installed_pets()}
|
||
|
||
_print(f"petdex gallery — {len(entries)} pet(s){' matching ' + repr(query) if query else ''}:")
|
||
for entry in shown:
|
||
mark = "✓" if entry.slug in installed else " "
|
||
_print(f" {mark} {entry.slug:<28} {entry.display_name} ({entry.kind})")
|
||
if limit and len(entries) > limit:
|
||
_print(f" … {len(entries) - limit} more (use --limit 0 or --query to filter)")
|
||
_print("\nInstall one with: hermes pets install <slug>")
|
||
return 0
|
||
|
||
|
||
def _cmd_install(args) -> int:
|
||
from agent.pet import store
|
||
from agent.pet.manifest import ManifestError
|
||
|
||
slug = args.slug.strip()
|
||
try:
|
||
pet = store.install_pet(slug, force=getattr(args, "force", False))
|
||
except (store.PetStoreError, ManifestError) as exc:
|
||
_err(f"✗ install failed: {exc}")
|
||
return 1
|
||
|
||
_print(f"✓ installed {pet.display_name} → {pet.directory}")
|
||
|
||
if getattr(args, "select", False) or not _has_active_pet():
|
||
_set_active(slug)
|
||
_print(f"✓ {pet.display_name} is now the active pet (display.pet.slug={slug}, enabled)")
|
||
else:
|
||
_print(f" Make it active with: hermes pets select {slug}")
|
||
return 0
|
||
|
||
|
||
def _cmd_remove(args) -> int:
|
||
from agent.pet import store
|
||
|
||
slug = args.slug.strip()
|
||
if store.remove_pet(slug):
|
||
_print(f"✓ removed {slug}")
|
||
return 0
|
||
_err(f"✗ '{slug}' is not installed")
|
||
return 1
|
||
|
||
|
||
def _cmd_select(args) -> int:
|
||
from agent.pet import store
|
||
|
||
slug = (getattr(args, "slug", "") or "").strip()
|
||
if not slug:
|
||
pets = store.installed_pets()
|
||
if not pets:
|
||
_err("✗ no pets installed — run: hermes pets install boba")
|
||
return 1
|
||
slug = _interactive_pick(pets)
|
||
if not slug:
|
||
return 1
|
||
|
||
pet = store.load_pet(slug)
|
||
if pet is None or not pet.exists:
|
||
_err(f"✗ '{slug}' is not installed — run: hermes pets install {slug}")
|
||
return 1
|
||
|
||
_set_active(slug)
|
||
_print(f"✓ active pet set to {pet.display_name} (display.pet.slug={slug}, enabled)")
|
||
return 0
|
||
|
||
|
||
def _cmd_off(args) -> int:
|
||
_set_enabled(False)
|
||
_print("✓ pet disabled (display.pet.enabled=false)")
|
||
return 0
|
||
|
||
|
||
def _cmd_scale(args) -> int:
|
||
"""Persist ``display.pet.scale`` — one knob resizes every surface."""
|
||
scale, err = set_pet_scale(args.factor)
|
||
if err:
|
||
_err(f"✗ {err}")
|
||
return 1
|
||
_print(f"✓ pet scale set to {scale:g} (display.pet.scale)")
|
||
return 0
|
||
|
||
|
||
def _cmd_show(args) -> int:
|
||
"""Animate the active (or named) pet in the terminal.
|
||
|
||
Uses the shared :class:`~agent.pet.render.PetRenderer` — full graphics
|
||
protocol (kitty/iTerm2/sixel) when the terminal supports it, else a
|
||
truecolor Unicode half-block fallback. Ctrl+C to stop.
|
||
"""
|
||
import time
|
||
|
||
from agent.pet import store
|
||
from agent.pet.constants import DEFAULT_SCALE, LOOP_MS, STATE_ROWS, PetState, resolve_cols
|
||
from agent.pet.render import build_renderer
|
||
|
||
cfg = _pet_config()
|
||
slug = (getattr(args, "slug", "") or "").strip() or str(cfg.get("slug", "") or "")
|
||
pet = store.resolve_active_pet(slug)
|
||
if pet is None:
|
||
_err("✗ no pet to show — run: hermes pets install boba")
|
||
return 1
|
||
|
||
mode_cfg = getattr(args, "mode", None) or str(cfg.get("render_mode", "auto") or "auto")
|
||
scale = float(getattr(args, "scale", 0) or cfg.get("scale", DEFAULT_SCALE) or DEFAULT_SCALE)
|
||
cols = resolve_cols(scale, cfg.get("unicode_cols", 0))
|
||
|
||
renderer = build_renderer(
|
||
pet.spritesheet,
|
||
configured_mode=mode_cfg,
|
||
scale=scale,
|
||
unicode_cols=cols,
|
||
)
|
||
if not renderer.available:
|
||
_err(
|
||
"✗ cannot render here (no TTY / graphics disabled). "
|
||
f"Effective mode: {renderer.mode}."
|
||
)
|
||
return 1
|
||
|
||
# Which states to play: one named state, or cycle the driveable rows.
|
||
requested = (getattr(args, "state", "") or "").strip().lower()
|
||
if requested:
|
||
states = [requested]
|
||
elif getattr(args, "cycle", False):
|
||
states = [s for s in STATE_ROWS if s in {e.value for e in PetState}]
|
||
else:
|
||
states = [PetState.IDLE.value]
|
||
|
||
is_unicode = renderer.mode == "unicode"
|
||
frame_delay = max(0.05, (LOOP_MS / 1000.0) / max(1, renderer.frame_count(states[0]) or 1))
|
||
|
||
# Right-align the sprite against the terminal's right edge — half-blocks by
|
||
# indenting each row, graphics protocols by padding the cursor to the right
|
||
# column before the image draws (kitty/iTerm/sixel all render at the cursor).
|
||
import shutil
|
||
|
||
term_cols = shutil.get_terminal_size((80, 24)).columns
|
||
indent = ""
|
||
g_indent = ""
|
||
if is_unicode:
|
||
indent = " " * max(0, term_cols - cols - 1)
|
||
else:
|
||
cell_cols = max(1, int(renderer.frame_w * renderer.scale) // 8)
|
||
g_indent = " " * max(0, term_cols - cell_cols - 1)
|
||
|
||
out = sys.stdout
|
||
out.write("\x1b[?25l") # hide cursor
|
||
out.flush()
|
||
prev_lines = 0
|
||
try:
|
||
_print(f"{pet.display_name} — mode={renderer.mode} (Ctrl+C to stop)")
|
||
loops = 0
|
||
while True:
|
||
for state in states:
|
||
count = renderer.frame_count(state) or 1
|
||
for i in range(count):
|
||
encoded = renderer.frame(state, i)
|
||
if is_unicode:
|
||
if indent:
|
||
encoded = "\n".join(indent + ln for ln in encoded.split("\n"))
|
||
if prev_lines:
|
||
out.write(f"\x1b[{prev_lines}F") # cursor up to redraw in place
|
||
out.write(encoded)
|
||
out.write("\x1b[0m\n")
|
||
# Lines drawn = sprite rows + the trailing newline; move
|
||
# back up exactly that many so the next frame overwrites.
|
||
prev_lines = encoded.count("\n") + 1
|
||
else:
|
||
out.write("\x1b[2J\x1b[3J\x1b[H") # clear for image protocols
|
||
out.write(f"{pet.display_name} [{state}]\n")
|
||
if g_indent:
|
||
out.write(g_indent)
|
||
out.write(encoded)
|
||
out.write("\n")
|
||
out.flush()
|
||
time.sleep(frame_delay)
|
||
loops += 1
|
||
if getattr(args, "once", False) and loops >= len(states):
|
||
break
|
||
except KeyboardInterrupt:
|
||
pass
|
||
finally:
|
||
out.write("\x1b[?25h") # show cursor
|
||
out.write("\x1b[0m\n")
|
||
out.flush()
|
||
return 0
|
||
|
||
|
||
def _cmd_doctor(args) -> int:
|
||
"""Report install state, active pet, config, and terminal capability."""
|
||
from agent.pet import store
|
||
from agent.pet.render import detect_terminal_graphics, resolve_mode
|
||
|
||
cfg = _pet_config()
|
||
enabled = bool(cfg.get("enabled"))
|
||
configured_slug = str(cfg.get("slug", "") or "")
|
||
mode_cfg = str(cfg.get("render_mode", "auto") or "auto")
|
||
|
||
pets = store.installed_pets()
|
||
active = store.resolve_active_pet(configured_slug)
|
||
|
||
_print("petdex doctor")
|
||
_print(f" pets dir: {store.pets_dir()}")
|
||
_print(f" installed: {len(pets)} ({', '.join(p.slug for p in pets) or 'none'})")
|
||
_print(f" display.pet.enabled: {enabled}")
|
||
_print(f" display.pet.slug: {configured_slug or '(unset)'}")
|
||
_print(f" active (resolved): {active.slug if active else '(none)'}")
|
||
_print(f" display.pet.render_mode: {mode_cfg}")
|
||
_print(f" detected graphics: {detect_terminal_graphics()}")
|
||
_print(f" effective mode (TTY): {resolve_mode(mode_cfg)}")
|
||
|
||
ok = True
|
||
if not pets:
|
||
_print(" → no pets installed. Run: hermes pets install boba")
|
||
ok = False
|
||
elif active is None:
|
||
_print(" → active pet unresolved. Run: hermes pets select <slug>")
|
||
ok = False
|
||
elif not enabled:
|
||
_print(" → pet display is disabled. Run: hermes pets select " + active.slug)
|
||
|
||
try:
|
||
import PIL # noqa: F401
|
||
except ImportError:
|
||
_print(" ✗ Pillow not importable — sprite decoding will be unavailable")
|
||
ok = False
|
||
|
||
_print(" ✓ ready" if ok and enabled else " (run the suggestions above to finish setup)")
|
||
return 0
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# config helpers
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
|
||
def _pet_config() -> dict:
|
||
from hermes_cli.config import load_config
|
||
|
||
cfg = load_config()
|
||
display = cfg.get("display", {}) if isinstance(cfg.get("display"), dict) else {}
|
||
pet = display.get("pet", {})
|
||
return pet if isinstance(pet, dict) else {}
|
||
|
||
|
||
def _has_active_pet() -> bool:
|
||
return bool(_pet_config().get("enabled")) and bool(_pet_config().get("slug"))
|
||
|
||
|
||
def _set_active(slug: str) -> None:
|
||
from hermes_cli.config import load_config, save_config
|
||
|
||
cfg = load_config()
|
||
display = cfg.setdefault("display", {})
|
||
pet = display.setdefault("pet", {})
|
||
pet["slug"] = slug
|
||
pet["enabled"] = True
|
||
save_config(cfg)
|
||
|
||
|
||
def _set_enabled(enabled: bool) -> None:
|
||
from hermes_cli.config import load_config, save_config
|
||
|
||
cfg = load_config()
|
||
display = cfg.setdefault("display", {})
|
||
pet = display.setdefault("pet", {})
|
||
pet["enabled"] = enabled
|
||
save_config(cfg)
|
||
|
||
|
||
def _set_scale(scale: float) -> None:
|
||
from hermes_cli.config import load_config, save_config
|
||
|
||
cfg = load_config()
|
||
display = cfg.setdefault("display", {})
|
||
pet = display.setdefault("pet", {})
|
||
pet["scale"] = scale
|
||
save_config(cfg)
|
||
|
||
|
||
def set_pet_scale(value: float | str) -> tuple[float, str | None]:
|
||
"""Set ``display.pet.scale`` (clamped to bounds). Returns ``(applied, error)``.
|
||
|
||
The single write path behind ``/pet scale`` and the desktop slider, so every
|
||
surface that resolves scale from config picks it up identically. *error* is
|
||
set (and nothing written) only when *value* isn't a number.
|
||
"""
|
||
from agent.pet.constants import clamp_scale
|
||
|
||
try:
|
||
scale = clamp_scale(float(value))
|
||
except (TypeError, ValueError):
|
||
return 0.0, f"not a number: {value!r} — try a value like 0.5"
|
||
|
||
_set_scale(scale)
|
||
return scale, None
|
||
|
||
|
||
def toggle_pet_display() -> tuple[bool, str | None, str | None]:
|
||
"""Toggle ``display.pet.enabled``.
|
||
|
||
Returns ``(enabled, display_name, error_message)``. *error_message* is set
|
||
when turning on but nothing is installed to show.
|
||
"""
|
||
from agent.pet import store
|
||
|
||
cfg = _pet_config()
|
||
slug = str(cfg.get("slug", "") or "")
|
||
pet = store.resolve_active_pet(slug)
|
||
|
||
if bool(cfg.get("enabled")):
|
||
_set_enabled(False)
|
||
return False, pet.display_name if pet else None, None
|
||
|
||
if pet is None:
|
||
installed = store.installed_pets()
|
||
if not installed:
|
||
return False, None, "no pets installed — /pet list to browse, or /pet <slug> to adopt"
|
||
pet = installed[0]
|
||
_set_active(pet.slug)
|
||
else:
|
||
_set_enabled(True)
|
||
return True, pet.display_name, None
|
||
|
||
|
||
def print_pet_gallery(*, limit: int = 20) -> None:
|
||
"""Print a slice of the public petdex gallery (CLI/TUI text fallback)."""
|
||
from agent.pet import store
|
||
from agent.pet.manifest import ManifestError, fetch_manifest
|
||
|
||
try:
|
||
entries = fetch_manifest()
|
||
except ManifestError as exc:
|
||
print(f"(._.) Couldn't reach the petdex gallery: {exc}")
|
||
return
|
||
|
||
installed = {p.slug for p in store.installed_pets()}
|
||
shown = entries[:limit] if limit > 0 else entries
|
||
print(f"(^o^)/ petdex gallery — first {len(shown)} of {len(entries)}:")
|
||
for entry in shown:
|
||
mark = "●" if entry.slug in installed else "○"
|
||
print(f" {mark} {entry.slug:<24} {entry.display_name}")
|
||
print(" /pet <slug> to adopt · /pet to toggle")
|
||
|
||
|
||
def _clear_active_if(slug: str) -> bool:
|
||
"""Disable + unset the active pet iff it's ``slug`` (e.g. after removal).
|
||
|
||
Returns whether anything changed, so callers don't write config needlessly.
|
||
"""
|
||
from hermes_cli.config import load_config, save_config
|
||
|
||
cfg = load_config()
|
||
pet = cfg.setdefault("display", {}).setdefault("pet", {})
|
||
if not isinstance(pet, dict) or str(pet.get("slug", "") or "") != slug:
|
||
return False
|
||
pet["slug"] = ""
|
||
pet["enabled"] = False
|
||
save_config(cfg)
|
||
return True
|
||
|
||
|
||
def _interactive_pick(pets) -> str:
|
||
"""Minimal numbered picker (avoids curses dep for a tiny list)."""
|
||
_print("Installed pets:")
|
||
for i, pet in enumerate(pets, 1):
|
||
_print(f" {i}. {pet.slug:<24} {pet.display_name}")
|
||
try:
|
||
choice = input("Select a pet [1]: ").strip() or "1"
|
||
idx = int(choice) - 1
|
||
except (EOFError, KeyboardInterrupt, ValueError):
|
||
_err("✗ cancelled")
|
||
return ""
|
||
if 0 <= idx < len(pets):
|
||
return pets[idx].slug
|
||
_err("✗ invalid selection")
|
||
return ""
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# argparse wiring
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
|
||
def register_cli(parent: argparse.ArgumentParser) -> None:
|
||
"""Attach ``pets`` subcommands to *parent* (called by main.py)."""
|
||
parent.set_defaults(func=lambda a: (parent.print_help(), 0)[1])
|
||
subs = parent.add_subparsers(dest="pets_command")
|
||
|
||
p_list = subs.add_parser("list", help="Browse the petdex gallery")
|
||
p_list.add_argument("query", nargs="?", default="", help="Filter by slug/name substring")
|
||
p_list.add_argument("--installed", action="store_true", help="Only show installed pets")
|
||
p_list.add_argument("--limit", type=int, default=40, help="Max rows (0 = all)")
|
||
p_list.set_defaults(func=_cmd_list)
|
||
|
||
p_install = subs.add_parser("install", help="Install a pet from the gallery")
|
||
p_install.add_argument("slug", help="Pet slug (e.g. boba)")
|
||
p_install.add_argument("--force", action="store_true", help="Re-download even if present")
|
||
p_install.add_argument("--select", action="store_true", help="Make it the active pet")
|
||
p_install.set_defaults(func=_cmd_install)
|
||
|
||
p_select = subs.add_parser("select", help="Set the active pet (writes display.pet.*)")
|
||
p_select.add_argument("slug", nargs="?", default="", help="Pet slug (omit for picker)")
|
||
p_select.set_defaults(func=_cmd_select)
|
||
|
||
p_show = subs.add_parser("show", help="Animate the active pet in the terminal")
|
||
p_show.add_argument("slug", nargs="?", default="", help="Pet slug (default: active)")
|
||
p_show.add_argument("--state", default="", help="Single state: idle/run/review/failed/wave/jump")
|
||
p_show.add_argument("--cycle", action="store_true", help="Cycle through all states")
|
||
p_show.add_argument("--once", action="store_true", help="Play once instead of looping")
|
||
p_show.add_argument("--mode", default=None, help="Override render mode (kitty/iterm/sixel/unicode/auto)")
|
||
p_show.add_argument("--scale", type=float, default=0, help="Override scale (0 = config)")
|
||
p_show.set_defaults(func=_cmd_show)
|
||
|
||
subs.add_parser("off", help="Disable the pet display").set_defaults(func=_cmd_off)
|
||
|
||
p_scale = subs.add_parser("scale", help="Resize the pet everywhere (display.pet.scale)")
|
||
p_scale.add_argument("factor", help="Scale factor, e.g. 0.5 (clamped 0.1–3.0)")
|
||
p_scale.set_defaults(func=_cmd_scale)
|
||
|
||
p_remove = subs.add_parser("remove", help="Delete an installed pet")
|
||
p_remove.add_argument("slug", help="Pet slug")
|
||
p_remove.set_defaults(func=_cmd_remove)
|
||
|
||
subs.add_parser("doctor", help="Check pet setup + terminal graphics support").set_defaults(
|
||
func=_cmd_doctor
|
||
)
|