Files
hermes-agent/hermes_cli/pets.py
Brooklyn Nicholson 83aa84ae3b feat(pets): CLI pet pane + /pet command
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.
2026-06-20 14:18:33 -05:00

483 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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.13.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
)