"""Commands that drive the pet's *body* instead of the shell. The server relays shell commands to this machine (see server_client. run_local_command). Rather than inventing a new payload type the desk API doesn't speak — this client can't change the server — a small `petctl` pseudo-command is intercepted before it ever reaches `subprocess`: if Bolt emits `petctl move top-left` or `petctl emote wave`, the pet does it and returns a normal-looking command output string, so from the server's side it's just another tool call that worked. Pure parsing logic — no Qt, no subprocess — so it's cheap to unit test. The UI half lives in ui/pet_window.py (apply_action). """ from __future__ import annotations import shlex from typing import Optional # What Bolt is allowed to type. Anything else falls through to a real shell. _PREFIXES = ("petctl", "bolt-pet", "pet") ANCHORS = ( "top-left", "top", "top-right", "left", "center", "right", "bottom-left", "bottom", "bottom-right", "cursor", "random", ) EMOTES = ("wave", "hop", "spin", "nod", "shake", "bounce", "wiggle") # Where `petctl jump` can be aimed. A bare number (1-based) works too, as does # any unique part of a monitor's name — resolution lives in monitors.resolve(). MONITOR_SPECS = ( "next", "prev", "primary", "other", "random", "left", "right", "up", "down", ) HELP = ( "petctl move | <" + "|".join(ANCHORS) + ">\n" "petctl jump \n" "petctl monitors\n" "petctl read [monitor number|here|all]\n" "petctl emote <" + "|".join(EMOTES) + ">\n" "petctl say \n" "petctl wander on|off\n" "petctl nap on|off" ) class ActionError(Exception): """Bad petctl syntax — reported back to the server as command output.""" def is_pet_command(command: str) -> bool: parts = (command or "").strip().split() return bool(parts) and parts[0].lower() in _PREFIXES def _bool_arg(value: str) -> bool: value = value.lower() if value in ("on", "true", "yes", "1", "start", "enable"): return True if value in ("off", "false", "no", "0", "stop", "disable"): return False raise ActionError(f"expected on/off, got {value!r}") def parse(command: str) -> Optional[dict]: """Parse a `petctl ...` string into an action dict, or None if this isn't a pet command at all (caller should run it as a real shell command). Raises ActionError on a pet command that doesn't make sense.""" if not is_pet_command(command): return None try: parts = shlex.split(command.strip()) except ValueError as exc: # unbalanced quotes raise ActionError(f"couldn't parse arguments: {exc}") from exc verb = (parts[1].lower() if len(parts) > 1 else "help") args = parts[2:] if verb in ("help", "-h", "--help"): return {"action": "help"} if verb in ("move", "goto", "walk"): if not args: raise ActionError("move needs a target: " + ", ".join(ANCHORS) + ", or x y") if len(args) >= 2 and _looks_numeric(args[0]) and _looks_numeric(args[1]): return {"action": "move", "x": int(float(args[0])), "y": int(float(args[1]))} anchor = args[0].lower().replace("_", "-") if anchor not in ANCHORS: raise ActionError(f"unknown position {args[0]!r}; try one of: " + ", ".join(ANCHORS)) return {"action": "move", "anchor": anchor} if verb in ("jump", "monitor", "screen"): if not args: raise ActionError( "jump needs a monitor: a number, a name, or one of " + ", ".join(MONITOR_SPECS) ) # The spec isn't validated here on purpose: which monitors exist is a # runtime fact this pure module doesn't have. monitors.resolve() does # it once the published screen list is in hand. return {"action": "jump", "target": " ".join(args).strip()} if verb in ("monitors", "screens", "displays"): return {"action": "monitors"} if verb in ("read", "look", "ocr", "see"): target = (" ".join(args).strip() or "here").lower() return {"action": "read", "target": target} if verb in ("emote", "do"): if not args: raise ActionError("emote needs a name: " + ", ".join(EMOTES)) emote = args[0].lower() if emote not in EMOTES: raise ActionError(f"unknown emote {args[0]!r}; try one of: " + ", ".join(EMOTES)) return {"action": "emote", "emote": emote} if verb == "say": text = " ".join(args).strip() if not text: raise ActionError("say needs something to say") return {"action": "say", "text": text} if verb == "wander": if not args: raise ActionError("wander needs on or off") return {"action": "wander", "enabled": _bool_arg(args[0])} if verb in ("nap", "sleep", "dnd"): if not args: raise ActionError("nap needs on or off") return {"action": "nap", "enabled": _bool_arg(args[0])} raise ActionError(f"unknown petctl verb {verb!r}\n{HELP}") def _looks_numeric(value: str) -> bool: try: float(value) return True except ValueError: return False def describe(action: dict) -> str: """The text handed back to the server as this "command"'s output. Phrased as a completed fact so the model doesn't narrate the mechanics of it.""" kind = action.get("action") if kind == "move": where = action.get("anchor") or f"({action.get('x')}, {action.get('y')})" return f"[pet] walking to {where}" if kind == "jump": return f"[pet] jumping to monitor {action['target']}" if kind == "emote": return f"[pet] {action['emote']}" if kind == "say": return "[pet] showing that in the speech bubble" if kind == "wander": return "[pet] wandering " + ("enabled" if action["enabled"] else "disabled") if kind == "nap": return "[pet] " + ("napping" if action["enabled"] else "awake") if kind == "help": return HELP return "[pet] ok"