One scalar now shrinks every surface together. The desktop canvas already multiplied native pixels by scale; the CLI/TUI now derive their terminal width from it via constants.resolve_cols() instead of a separate pinned unicode_cols (which is now an optional override, default 0 = auto). Two sizing bugs fixed along the way: - kitty placement sized its c×r cell box from a native-aspect column count, so small pets got upscaled ~2× to fill it. It now derives the box from the scaled frame pixels (_cell_box, shared with the half-block frame() path), so kitty tracks scale like the GUI does. - half-blocks can't follow scale all the way down — a cell samples the sprite at 1 horizontal + 2 vertical taps, so a tiny width turns the pet to mush. cols_for_scale() clamps to a legibility floor (UNICODE_MIN_COLS) and only grows above it, while kitty/GUI keep shrinking on true pixels. Default scale lowered 0.7 -> 0.33 (glanceable corner sprite); the 0.7 literal fallbacks in pets.py and the desktop sprite now reference the shared default.
394 lines
14 KiB
Python
394 lines
14 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_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 _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_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
|
|
)
|