"""CLI subcommand: ``hermes pets ``. 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 ") 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 ") 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 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 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 _rename_active_if(old_slug: str, new_slug: str) -> bool: """Repoint the active pet from ``old_slug`` to ``new_slug`` iff it's active. Used when a rename realigns a pet's slug/dir: if the renamed pet was the active one, the config must follow or surfaces point at a now-missing dir. Preserves the ``enabled`` flag. Returns whether anything changed. """ if not new_slug or old_slug == new_slug: return False 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 "") != old_slug: return False pet["slug"] = new_slug 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 )