| |
| |
| """ |
| Unity Agent CLI — Interactive colored game development agent. |
| |
| A zero-dependency interactive REPL for driving the Unity game agent. |
| Uses raw ANSI escape codes for color, box-drawing characters for panels, |
| and an animated spinner while the LLM is "thinking". |
| |
| Features |
| -------- |
| * Colored output (cyan/magenta/green/red/yellow/blue/white/dim) |
| * Box-drawing panels and tables |
| * Animated "thinking" spinner (background thread) |
| * Progress bars during generation |
| * C# syntax-highlighted code boxes |
| * Full slash-command suite: |
| /help /tools /presets /generate /open /scene /clear /config |
| /history /export /test /stats /new /add /script /run /exit |
| * OpenAI-compatible Z.ai LLM client (urllib, no external deps) |
| * Tool-call parser & executor with graceful fallback tools |
| |
| Usage |
| ----- |
| python -m unity_agent.cli |
| python run.py |
| |
| Environment |
| ----------- |
| ZAI_API_KEY — Z.ai API key (https://z.ai) |
| ZAI_MODEL — model name (default: glm-4.5-flash) |
| ZAI_BASE_URL — OpenAI-compatible endpoint |
| |
| Requirements |
| ------------ |
| Standard library only. No rich / click / prompt_toolkit / httpx needed. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import os |
| import sys |
| import json |
| import re |
| import time |
| import shutil |
| import zipfile |
| import threading |
| import subprocess |
| import urllib.request |
| import urllib.error |
| from dataclasses import dataclass, field |
| from datetime import datetime |
| from typing import Any, Callable, Dict, List, Optional |
|
|
| |
| |
| |
| |
| |
| _PKG_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) |
| if _PKG_ROOT not in sys.path: |
| sys.path.insert(0, _PKG_ROOT) |
|
|
| _TOOL_REGISTRY = None |
| _ToolResult = None |
| _SystemSettings = None |
| _SYSTEM_PROMPT = "" |
|
|
| try: |
| from unity_agent.config import Settings as _Settings |
| _SystemSettings = _Settings |
| except Exception: |
| _SystemSettings = None |
|
|
| try: |
| from unity_agent.orchestrator.prompts import SYSTEM_PROMPT as _SP |
| _SYSTEM_PROMPT = _SP or "" |
| except Exception: |
| _SYSTEM_PROMPT = "" |
|
|
| try: |
| from unity_agent.tools.base import _GLOBAL_REGISTRY, ToolResult |
| _TOOL_REGISTRY = _GLOBAL_REGISTRY |
| _ToolResult = ToolResult |
| except Exception: |
| _TOOL_REGISTRY = None |
| _ToolResult = None |
|
|
|
|
| |
| |
| |
| class C: |
| """Raw ANSI escape sequences for terminal styling. |
| |
| Every constant is a plain string so it can be concatenated freely. |
| Use :py:meth:`strip` to remove escape codes when measuring string width |
| or writing to a non-TTY. |
| """ |
|
|
| RESET = "\033[0m" |
| BOLD = "\033[1m" |
| DIM = "\033[2m" |
| ITALIC = "\033[3m" |
| UNDERLINE = "\033[4m" |
| BLINK = "\033[5m" |
| REVERSE = "\033[7m" |
| HIDDEN = "\033[8m" |
|
|
| |
| BLACK = "\033[30m" |
| RED = "\033[31m" |
| GREEN = "\033[32m" |
| YELLOW = "\033[33m" |
| BLUE = "\033[34m" |
| MAGENTA = "\033[35m" |
| CYAN = "\033[36m" |
| WHITE = "\033[37m" |
|
|
| |
| B_BLACK = "\033[90m" |
| B_RED = "\033[91m" |
| B_GREEN = "\033[92m" |
| B_YELLOW = "\033[93m" |
| B_BLUE = "\033[94m" |
| B_MAGENTA = "\033[95m" |
| B_CYAN = "\033[96m" |
| B_WHITE = "\033[97m" |
|
|
| |
| BG_BLACK = "\033[40m" |
| BG_RED = "\033[41m" |
| BG_GREEN = "\033[42m" |
| BG_YELLOW = "\033[43m" |
| BG_BLUE = "\033[44m" |
| BG_MAGENTA = "\033[45m" |
| BG_CYAN = "\033[46m" |
| BG_WHITE = "\033[47m" |
|
|
| |
| CLEAR = "\033[2J" |
| CLEAR_LINE = "\033[2K" |
| CURSOR_HOME = "\033[H" |
| CURSOR_UP = "\033[1A" |
| CURSOR_DOWN = "\033[1B" |
| CURSOR_RIGHT = "\033[1C" |
| CURSOR_LEFT = "\033[1D" |
| SAVE_CURSOR = "\033[s" |
| RESTORE_CURSOR = "\033[u" |
| HIDE_CURSOR = "\033[?25l" |
| SHOW_CURSOR = "\033[?25h" |
|
|
| _ANSI_RE = re.compile(r"\033\[[0-9;]*[A-Za-z]") |
|
|
| @classmethod |
| def strip(cls, text: str) -> str: |
| """Return ``text`` with all ANSI escape sequences removed.""" |
| return cls._ANSI_RE.sub("", text) |
|
|
| @classmethod |
| def width(cls, text: str) -> int: |
| """Visible width of ``text`` (ignoring escape codes).""" |
| return len(cls.strip(text)) |
|
|
| @classmethod |
| def wrap(cls, text: str, color: str, bold: bool = False) -> str: |
| """Wrap ``text`` in ``color`` (optionally bold) and reset.""" |
| prefix = (cls.BOLD if bold else "") + color |
| return f"{prefix}{text}{cls.RESET}" |
|
|
| @classmethod |
| def available(cls) -> bool: |
| """Return True if the current stream looks color-capable.""" |
| if os.environ.get("NO_COLOR"): |
| return False |
| if os.environ.get("FORCE_COLOR"): |
| return True |
| try: |
| return sys.stdout.isatty() |
| except Exception: |
| return False |
|
|
|
|
| |
| DIM = C.B_BLACK |
|
|
|
|
| |
| |
| |
| class Box: |
| """Box-drawing glyphs (single-line and double-line sets).""" |
|
|
| |
| TL, TR, BL, BR = "┌", "┐", "└", "┘" |
| H, V = "─", "│" |
| LT, RT, TT, BT, CROSS = "├", "┤", "┬", "┴", "┼" |
|
|
| |
| DTL, DTR, DBL, DBR = "╔", "╗", "╚", "╝" |
| DH, DV = "═", "║" |
| DLT, DRT, DTT, DBT, DCROSS = "╠", "╣", "╦", "╩", "╬" |
|
|
| |
| RTL, RTR, RBL, RBR = "╭", "╮", "╰", "╯" |
|
|
| |
| BULLET = "•" |
| ARROW = "→" |
| CHECK = "✓" |
| CROSS_X = "✗" |
| STAR = "✦" |
| DIAMOND = "◆" |
| DOT = "·" |
|
|
|
|
| |
| |
| |
| def term_width(default: int = 80) -> int: |
| """Best-effort terminal column width.""" |
| try: |
| return shutil.get_terminal_size((default, 24)).columns |
| except Exception: |
| return default |
|
|
|
|
| def term_height(default: int = 24) -> int: |
| """Best-effort terminal row height.""" |
| try: |
| return shutil.get_terminal_size((80, default)).lines |
| except Exception: |
| return default |
|
|
|
|
| def visible_len(s: str) -> int: |
| """Length of ``s`` ignoring ANSI escape codes.""" |
| return C.width(s) |
|
|
|
|
| def pad_to(s: str, width: int, fill: str = " ", align: str = "left") -> str: |
| """Pad ``s`` (which may contain ANSI codes) to a visible ``width``.""" |
| pad = max(0, width - visible_len(s)) |
| if align == "right": |
| return fill * pad + s |
| if align == "center": |
| left = pad // 2 |
| right = pad - left |
| return fill * left + s + fill * right |
| return s + fill * pad |
|
|
|
|
| |
| |
| |
| def hline(width: Optional[int] = None, char: str = Box.H, color: str = DIM) -> str: |
| """Return a single horizontal line.""" |
| width = width or term_width() |
| return color + (char * width) + C.RESET |
|
|
|
|
| def panel( |
| lines: List[str], |
| title: Optional[str] = None, |
| border_color: str = C.CYAN, |
| title_color: str = C.B_CYAN, |
| pad: int = 1, |
| width: Optional[int] = None, |
| rounded: bool = False, |
| ) -> str: |
| """Render ``lines`` inside a box-drawing panel. |
| |
| ``lines`` may already contain ANSI styling; widths are computed from the |
| visible characters only. |
| """ |
| width = width or min(term_width(), 90) |
| inner = width - 2 - pad * 2 |
| tl = Box.RTL if rounded else Box.TL |
| tr = Box.RTR if rounded else Box.TR |
| bl = Box.RBL if rounded else Box.BL |
| br = Box.RBR if rounded else Box.RBR |
|
|
| out: List[str] = [] |
| if title: |
| title_str = f" {title} " |
| title_visible = len(title_str) |
| left_dash = (inner + pad * 2 - title_visible) // 2 |
| right_dash = inner + pad * 2 - title_visible - left_dash |
| out.append( |
| border_color + tl + Box.H * left_dash |
| + title_color + title_str |
| + border_color + Box.H * right_dash + tr + C.RESET |
| ) |
| else: |
| out.append(border_color + tl + Box.H * (width - 2) + tr + C.RESET) |
|
|
| def inner_row(content: str) -> str: |
| vis = visible_len(content) |
| overflow = max(0, vis - inner) |
| if overflow: |
| |
| stripped = C.strip(content) |
| content = stripped[: inner - 1] + "…" |
| return ( |
| border_color + Box.V + C.RESET |
| + " " * pad + content + " " * pad |
| + border_color + Box.V + C.RESET |
| ) |
|
|
| if not lines: |
| out.append(inner_row("")) |
| for ln in lines: |
| if ln == "": |
| out.append(inner_row("")) |
| else: |
| for sub in _wrap_visible(ln, inner): |
| out.append(inner_row(sub)) |
|
|
| out.append(border_color + bl + Box.H * (width - 2) + br + C.RESET) |
| return "\n".join(out) |
|
|
|
|
| def _wrap_visible(text: str, width: int) -> List[str]: |
| """Word-wrap ``text`` to ``width`` visible columns.""" |
| stripped = C.strip(text) |
| if not stripped: |
| return [""] |
| words = stripped.split(" ") |
| rows: List[str] = [] |
| cur = "" |
| for w in words: |
| if not cur: |
| cur = w |
| elif len(cur) + 1 + len(w) <= width: |
| cur += " " + w |
| else: |
| rows.append(cur) |
| cur = w |
| if cur: |
| rows.append(cur) |
| return rows or [""] |
|
|
|
|
| def divider(title: Optional[str] = None, color: str = C.B_CYAN) -> str: |
| """A thin divider line, optionally with a centered title.""" |
| w = term_width() |
| if not title: |
| return color + Box.H * w + C.RESET |
| title_str = f" {title} " |
| pad = max(0, (w - len(title_str)) // 2) |
| right = max(0, w - len(title_str) - pad) |
| return color + Box.H * pad + title_str + Box.H * right + C.RESET |
|
|
|
|
| |
| |
| |
| def table( |
| headers: List[str], |
| rows: List[List[str]], |
| colors: Optional[List[str]] = None, |
| header_color: str = C.B_CYAN, |
| border_color: str = DIM, |
| min_widths: Optional[List[int]] = None, |
| ) -> str: |
| """Render a colored box-drawing table. |
| |
| ``colors`` optionally provides a per-column color applied to row cells. |
| """ |
| ncol = len(headers) |
| colors = colors or ([C.WHITE] * ncol) |
| min_widths = min_widths or ([0] * ncol) |
|
|
| |
| widths = list(min_widths) |
| for i, h in enumerate(headers): |
| widths[i] = max(widths[i], len(h)) |
| for row in rows: |
| for i, cell in enumerate(row): |
| if i < ncol: |
| widths[i] = max(widths[i], len(C.strip(cell))) |
|
|
| def sep(left: str, mid: str, right: str, fill: str = Box.H) -> str: |
| parts = [left] |
| for i, w in enumerate(widths): |
| parts.append(fill * (w + 2)) |
| parts.append(mid if i < ncol - 1 else right) |
| return border_color + "".join(parts) + C.RESET |
|
|
| out: List[str] = [] |
| out.append(sep(Box.TL, Box.TT, Box.TR)) |
|
|
| |
| cells = [] |
| for i, h in enumerate(headers): |
| cells.append(" " + header_color + pad_to(h, widths[i]) + C.RESET + " ") |
| out.append(border_color + Box.V + C.RESET + (border_color + Box.V + C.RESET).join(cells)) |
|
|
| out.append(sep(Box.LT, Box.CROSS, Box.RT)) |
|
|
| |
| for r_i, row in enumerate(rows): |
| cells = [] |
| for i in range(ncol): |
| cell = row[i] if i < len(row) else "" |
| col = colors[i] if i < len(colors) else C.WHITE |
| cells.append(" " + col + pad_to(C.strip(cell), widths[i]) + C.RESET + " ") |
| out.append(border_color + Box.V + C.RESET + (border_color + Box.V + C.RESET).join(cells)) |
| if r_i < len(rows) - 1: |
| out.append(sep(Box.LT, Box.CROSS, Box.RT)) |
|
|
| out.append(sep(Box.BL, Box.BT, Box.BR)) |
| return "\n".join(out) |
|
|
|
|
| |
| |
| |
| CSHARP_KEYWORDS = { |
| "abstract", "as", "base", "bool", "break", "byte", "case", "catch", |
| "char", "checked", "class", "const", "continue", "decimal", "default", |
| "delegate", "do", "double", "else", "enum", "event", "explicit", |
| "extern", "false", "finally", "fixed", "float", "for", "foreach", |
| "goto", "if", "implicit", "in", "int", "interface", "internal", "is", |
| "lock", "long", "namespace", "new", "null", "object", "operator", |
| "out", "override", "params", "private", "protected", "public", |
| "readonly", "ref", "return", "sbyte", "sealed", "short", "sizeof", |
| "stackalloc", "static", "string", "struct", "switch", "this", "throw", |
| "true", "try", "typeof", "uint", "ulong", "unchecked", "unsafe", |
| "ushort", "using", "var", "virtual", "void", "volatile", "while", |
| "yield", "async", "await", "get", "set", "value", "where", "select", |
| "from", "group", "into", "orderby", "join", "let", "on", "equals", |
| "by", "ascending", "descending", "nameof", "when", "global", "partial", |
| } |
|
|
| CSHARP_TYPES = { |
| "Vector2", "Vector3", "Vector4", "Quaternion", "Transform", "GameObject", |
| "MonoBehaviour", "Rigidbody", "Rigidbody2D", "Collider", "Collider2D", |
| "AudioSource", "AudioClip", "Material", "Mesh", "MeshRenderer", |
| "MeshFilter", "Camera", "Light", "Canvas", "Button", "Text", "Image", |
| "RectTransform", "SceneManager", "Scene", "Input", "Time", "Physics", |
| "Physics2D", "Debug", "Color", "Ray", "RaycastHit", "List", "Dictionary", |
| "HashSet", "Queue", "Stack", "Action", "Func", "Predicate", "Tuple", |
| "IEnumerator", "WaitForSeconds", "WaitForEndOfFrame", "Coroutine", |
| "ScriptableObject", "Object", "Component", "Behaviour", "Animator", |
| "Animation", "Sprite", "Texture2D", "Shader", "Renderer", "TrailRenderer", |
| "LineRenderer", "ParticleSystem", "BoxCollider", "SphereCollider", |
| "CapsuleCollider", "CharacterController", "NavMeshAgent", "RaycastHit2D", |
| "KeyCode", "Touch", "TouchPhase", "PlayerPrefs", "Application", |
| "Screen", "Cursor", "Random", "Mathf", "Vector3Int", "Vector2Int", |
| "Gradient", "AnimationCurve", "GradientColorKey", "GradientAlphaKey", |
| "SyncVar", "Command", "ClientRpc", "SerializeField", "HideInInspector", |
| "Range", "Header", "Tooltip", "TextArea", "ContextMenu", "RequireComponent", |
| } |
|
|
|
|
| def highlight_csharp(code: str) -> str: |
| """Apply simple regex-based C# syntax highlighting. |
| |
| Order matters: comments and strings are matched first so that keywords |
| inside them are not re-colored. |
| """ |
| token_spec = [ |
| ("COMMENT", r"//[^\n]*|/\*[\s\S]*?\*/"), |
| ("STRING", r"@?\"(?:\"\"|\\.|[^\"\\])*\"|'(?:\\.|[^'\\])*'"), |
| ("NUMBER", r"\b\d+\.?\d*[fdmFDM]?\b|\b0x[0-9a-fA-F]+\b"), |
| ("IDENT", r"[A-Za-z_][A-Za-z0-9_]*"), |
| ("OP", r"[{}\[\]().,;:?=+\-*/%&|<>!^~]+"), |
| ("WS", r"\s+"), |
| ("ANY", r"."), |
| ] |
| |
| |
| |
| master = "|".join("(?P<%s>%s)" % (name, pat) for name, pat in token_spec) |
| out: List[str] = [] |
| for m in re.finditer(master, code): |
| kind = m.lastgroup |
| val = m.group() |
| if kind == "COMMENT": |
| out.append(C.DIM + C.ITALIC + val + C.RESET) |
| elif kind == "STRING": |
| out.append(C.GREEN + val + C.RESET) |
| elif kind == "NUMBER": |
| out.append(C.YELLOW + val + C.RESET) |
| elif kind == "IDENT": |
| if val in CSHARP_KEYWORDS: |
| out.append(C.MAGENTA + val + C.RESET) |
| elif val in CSHARP_TYPES: |
| out.append(C.CYAN + val + C.RESET) |
| elif val[0].isupper(): |
| out.append(C.B_CYAN + val + C.RESET) |
| else: |
| out.append(C.WHITE + val + C.RESET) |
| elif kind == "OP": |
| out.append(C.B_BLACK + val + C.RESET) |
| else: |
| out.append(val) |
| return "".join(out) |
|
|
|
|
| def code_block(code: str, language: str = "csharp", title: Optional[str] = None) -> str: |
| """Render source code inside a bordered, syntax-highlighted panel.""" |
| highlighted = highlight_csharp(code) if language.lower() in ("csharp", "c#", "cs") else C.WHITE + code + C.RESET |
| raw_lines = code.splitlines() or [""] |
| width = min(max(60, max((len(l) for l in raw_lines), default=0) + 8), term_width()) |
| header = title or language.upper() |
| lines = [] |
| for ln in highlighted.splitlines() or [""]: |
| lines.append(ln if ln else " ") |
| return panel(lines, title=header, border_color=C.B_BLUE, title_color=C.BLUE, pad=1, width=width) |
|
|
|
|
| |
| |
| |
| class Spinner: |
| """Animated "thinking" spinner running on a background thread. |
| |
| Usage:: |
| |
| s = Spinner("Thinking") |
| s.start() |
| ... long operation ... |
| s.stop() |
| """ |
|
|
| FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] |
| ALT_FRAMES = ["|", "/", "-", "\\"] |
|
|
| def __init__(self, message: str = "Thinking", color: str = C.MAGENTA, interval: float = 0.08): |
| self.message = message |
| self.color = color |
| self.interval = interval |
| self._stop = threading.Event() |
| self._thread: Optional[threading.Thread] = None |
| self._frame_idx = 0 |
| |
| try: |
| if sys.stdout.encoding and "utf" in sys.stdout.encoding.lower(): |
| self.frames = self.FRAMES |
| else: |
| self.frames = self.ALT_FRAMES |
| except Exception: |
| self.frames = self.ALT_FRAMES |
|
|
| def _spin(self) -> None: |
| sys.stdout.write(C.HIDE_CURSOR) |
| sys.stdout.flush() |
| while not self._stop.is_set(): |
| frame = self.frames[self._frame_idx % len(self.frames)] |
| self._frame_idx += 1 |
| line = f" {self.color}{frame}{C.RESET} {self.color}{self.message}{C.RESET}{C.B_BLACK}...{C.RESET}" |
| sys.stdout.write("\r" + C.CLEAR_LINE + "\r") |
| sys.stdout.write(line) |
| sys.stdout.flush() |
| time.sleep(self.interval) |
| sys.stdout.write("\r" + C.CLEAR_LINE + "\r") |
| sys.stdout.write(C.SHOW_CURSOR) |
| sys.stdout.flush() |
|
|
| def start(self) -> "Spinner": |
| self._stop.clear() |
| if C.available(): |
| self._thread = threading.Thread(target=self._spin, daemon=True) |
| self._thread.start() |
| else: |
| sys.stdout.write(f" {self.message}...\n") |
| sys.stdout.flush() |
| return self |
|
|
| def stop(self, final_message: Optional[str] = None) -> None: |
| self._stop.set() |
| if self._thread and self._thread.is_alive(): |
| self._thread.join(timeout=1.0) |
| self._thread = None |
| if final_message: |
| sys.stdout.write(final_message + "\n") |
| sys.stdout.flush() |
|
|
| def update(self, message: str) -> None: |
| self.message = message |
|
|
|
|
| |
| |
| |
| class ProgressBar: |
| """A single-line animated progress bar.""" |
|
|
| def __init__(self, total: int, label: str = "Working", width: int = 30, |
| color: str = C.CYAN, fill_color: str = C.B_CYAN): |
| self.total = max(1, total) |
| self.label = label |
| self.width = width |
| self.color = color |
| self.fill_color = fill_color |
| self.value = 0 |
|
|
| def _render(self) -> str: |
| pct = self.value / self.total |
| filled = int(self.width * pct) |
| bar = self.fill_color + "█" * filled + C.B_BLACK + "░" * (self.width - filled) + C.RESET |
| return f" {self.color}{self.label}{C.RESET} [{bar}] {C.BOLD}{int(pct * 100):3d}%{C.RESET}" |
|
|
| def update(self, value: int, label: Optional[str] = None) -> None: |
| self.value = min(self.total, max(0, value)) |
| if label: |
| self.label = label |
| sys.stdout.write("\r" + C.CLEAR_LINE + "\r") |
| sys.stdout.write(self._render()) |
| sys.stdout.flush() |
|
|
| def finish(self, message: Optional[str] = None) -> None: |
| self.update(self.total) |
| sys.stdout.write("\n") |
| if message: |
| sys.stdout.write(message + "\n") |
| sys.stdout.flush() |
|
|
| def __enter__(self) -> "ProgressBar": |
| sys.stdout.write(self._render() + "\r") |
| sys.stdout.flush() |
| return self |
|
|
| def __exit__(self, exc_type, exc, tb) -> None: |
| if exc_type is None: |
| self.finish() |
| else: |
| sys.stdout.write("\n") |
| sys.stdout.flush() |
|
|
|
|
| |
| |
| |
| class LLMError(Exception): |
| """Raised when the LLM endpoint returns an error.""" |
|
|
|
|
| class LLMClient: |
| """Z.ai OpenAI-compatible LLM client (standard library only). |
| |
| Talks to ``{base_url}/chat/completions`` using :mod:`urllib.request`. |
| Supports plain completion as well as a simple token stream callback. |
| """ |
|
|
| def __init__( |
| self, |
| api_key: str, |
| model: str = "glm-4.5-flash", |
| base_url: str = "https://open.bigmodel.cn/api/paas/v4", |
| timeout: float = 120.0, |
| ): |
| self.api_key = api_key |
| self.model = model |
| self.base_url = base_url.rstrip("/") |
| self.timeout = timeout |
|
|
| |
| def _post(self, path: str, payload: Dict[str, Any], stream: bool = False): |
| url = f"{self.base_url}{path}" |
| headers = { |
| "Authorization": f"Bearer {self.api_key}", |
| "Content-Type": "application/json", |
| "Accept": "text/event-stream" if stream else "application/json", |
| } |
| data = json.dumps(payload).encode("utf-8") |
| req = urllib.request.Request(url, data=data, headers=headers, method="POST") |
| try: |
| return urllib.request.urlopen(req, timeout=self.timeout) |
| except urllib.error.HTTPError as e: |
| body = "" |
| try: |
| body = e.read().decode("utf-8", errors="replace") |
| except Exception: |
| pass |
| raise LLMError(f"HTTP {e.code} {e.reason}: {body[:400]}") from None |
| except urllib.error.URLError as e: |
| raise LLMError(f"Network error: {e.reason}") from None |
|
|
| |
| def chat( |
| self, |
| messages: List[Dict[str, str]], |
| temperature: float = 0.2, |
| max_tokens: int = 4096, |
| on_token: Optional[Callable[[str], None]] = None, |
| ) -> str: |
| """Send a chat completion request and return the assistant message. |
| |
| If ``on_token`` is provided, the response is streamed and the |
| callback receives each delta token; the concatenated content is |
| still returned. |
| """ |
| payload: Dict[str, Any] = { |
| "model": self.model, |
| "messages": messages, |
| "temperature": temperature, |
| "max_tokens": max_tokens, |
| } |
| if on_token is not None: |
| payload["stream"] = True |
|
|
| if on_token is None: |
| resp = self._post("/chat/completions", payload, stream=False) |
| try: |
| raw = resp.read().decode("utf-8") |
| data = json.loads(raw) |
| finally: |
| resp.close() |
| if "choices" not in data: |
| raise LLMError(f"Unexpected response: {raw[:400]}") |
| return data["choices"][0]["message"]["content"] |
|
|
| |
| resp = self._post("/chat/completions", payload, stream=True) |
| full: List[str] = [] |
| try: |
| buf = "" |
| for chunk in resp: |
| buf += chunk.decode("utf-8", errors="replace") |
| while "\n" in buf: |
| line, buf = buf.split("\n", 1) |
| line = line.strip() |
| if not line or not line.startswith("data:"): |
| continue |
| payload_str = line[len("data:"):].strip() |
| if payload_str == "[DONE]": |
| break |
| try: |
| obj = json.loads(payload_str) |
| except json.JSONDecodeError: |
| continue |
| try: |
| delta = obj["choices"][0]["delta"].get("content", "") |
| except (KeyError, IndexError): |
| delta = "" |
| if delta: |
| full.append(delta) |
| on_token(delta) |
| finally: |
| resp.close() |
| return "".join(full) |
|
|
| def chat_with_tools( |
| self, |
| messages: List[Dict[str, str]], |
| temperature: float = 0.2, |
| max_tokens: int = 4096, |
| ) -> str: |
| """Convenience wrapper identical to :meth:`chat` for readability.""" |
| return self.chat(messages, temperature=temperature, max_tokens=max_tokens) |
|
|
|
|
| |
| |
| |
| _TOOL_CALL_RE = re.compile( |
| r"```(?:tool_call|tool|json)\s*\n(?P<body>.*?)```", |
| re.DOTALL | re.IGNORECASE, |
| ) |
| _TAG_RE = re.compile(r"<tool_call>(.*?)</tool_call>", re.DOTALL | re.IGNORECASE) |
|
|
|
|
| def parse_tool_call(text: str) -> Optional[Dict[str, Any]]: |
| """Extract a tool call from an LLM response. |
| |
| Recognized formats:: |
| |
| ```tool_call |
| {"name": "write_file", "arguments": {...}} |
| ``` |
| |
| ```json |
| {"name": "write_file", "arguments": {...}} |
| ``` |
| |
| <tool_call>{"name": "...", "arguments": {...}}</tool_call> |
| |
| {"name": "...", "arguments": {...}} (inline JSON) |
| |
| Returns a dict with at least ``name`` and ``arguments`` keys, or ``None`` |
| if no tool call was found. |
| """ |
| if not text: |
| return None |
|
|
| |
| for m in _TOOL_CALL_RE.finditer(text): |
| body = m.group("body").strip() |
| parsed = _try_json(body) |
| if parsed and isinstance(parsed, dict) and "name" in parsed: |
| return _normalize_tool_call(parsed) |
|
|
| |
| for m in _TAG_RE.finditer(text): |
| body = m.group(1).strip() |
| parsed = _try_json(body) |
| if parsed and isinstance(parsed, dict) and "name" in parsed: |
| return _normalize_tool_call(parsed) |
|
|
| |
| for m in re.finditer(r"\{[\s\S]*\}", text): |
| candidate = m.group(0) |
| parsed = _try_json(candidate) |
| if parsed and isinstance(parsed, dict) and "name" in parsed and "arguments" in parsed: |
| return _normalize_tool_call(parsed) |
|
|
| return None |
|
|
|
|
| def parse_all_tool_calls(text: str) -> List[Dict[str, Any]]: |
| """Return every tool call found in ``text`` (in order).""" |
| out: List[Dict[str, Any]] = [] |
| if not text: |
| return out |
| for m in _TOOL_CALL_RE.finditer(text): |
| parsed = _try_json(m.group("body").strip()) |
| if parsed and isinstance(parsed, dict) and "name" in parsed: |
| out.append(_normalize_tool_call(parsed)) |
| for m in _TAG_RE.finditer(text): |
| parsed = _try_json(m.group(1).strip()) |
| if parsed and isinstance(parsed, dict) and "name" in parsed: |
| out.append(_normalize_tool_call(parsed)) |
| if not out: |
| single = parse_tool_call(text) |
| if single: |
| out.append(single) |
| return out |
|
|
|
|
| def _try_json(text: str) -> Optional[Any]: |
| try: |
| return json.loads(text) |
| except Exception: |
| |
| try: |
| cleaned = re.sub(r",\s*([}\]])", r"\1", text) |
| return json.loads(cleaned) |
| except Exception: |
| return None |
|
|
|
|
| def _normalize_tool_call(obj: Dict[str, Any]) -> Dict[str, Any]: |
| name = obj.get("name") or obj.get("tool") or obj.get("function", {}).get("name") |
| arguments = obj.get("arguments") |
| if arguments is None: |
| arguments = obj.get("parameters") or obj.get("args") or obj.get("input") or {} |
| if isinstance(arguments, str): |
| parsed = _try_json(arguments) |
| arguments = parsed if parsed is not None else {"_raw": arguments} |
| if not isinstance(arguments, dict): |
| arguments = {"value": arguments} |
| return {"name": str(name), "arguments": arguments} |
|
|
|
|
| |
| |
| |
| @dataclass |
| class ToolResult: |
| """Standard tool execution result (matches the package ToolResult).""" |
| success: bool = True |
| message: str = "" |
| data: Any = None |
| files: List[str] = field(default_factory=list) |
| error: Optional[str] = None |
|
|
| def as_dict(self) -> Dict[str, Any]: |
| return { |
| "success": self.success, |
| "message": self.message, |
| "data": self.data, |
| "files": self.files, |
| "error": self.error, |
| } |
|
|
|
|
| class _FallbackTool: |
| """A simple builtin tool used when the full unity_agent package is absent.""" |
|
|
| def __init__(self, name: str, description: str, schema: Dict[str, Any], |
| fn: Callable[..., ToolResult]): |
| self.name = name |
| self.__doc__ = description |
| self.description = description |
| self.input_schema = schema |
| self._fn = fn |
|
|
| def run(self, **kwargs: Any) -> ToolResult: |
| try: |
| return self._fn(**kwargs) |
| except Exception as e: |
| return ToolResult(success=False, message=str(e), error=str(e)) |
|
|
|
|
| class _FallbackRegistry: |
| """Minimal registry mimicking ``_GLOBAL_REGISTRY``.""" |
|
|
| def __init__(self) -> None: |
| self._tools: Dict[str, _FallbackTool] = {} |
|
|
| def register(self, name: str, description: str, schema: Dict[str, Any], |
| fn: Callable[..., ToolResult]) -> None: |
| self._tools[name] = _FallbackTool(name, description, schema, fn) |
|
|
| def get(self, name: str) -> Optional[_FallbackTool]: |
| return self._tools.get(name) |
|
|
| def all_names(self) -> List[str]: |
| return sorted(self._tools.keys()) |
|
|
| def all(self) -> List[_FallbackTool]: |
| return [self._tools[n] for n in self.all_names()] |
|
|
|
|
| def _build_fallback_registry() -> _FallbackRegistry: |
| """Populate a small but useful set of builtin tools.""" |
| reg = _FallbackRegistry() |
|
|
| def _safe_path(base: str, *parts: str) -> str: |
| root = os.path.abspath(base) |
| full = os.path.abspath(os.path.join(root, *parts)) |
| if not full.startswith(root): |
| raise ValueError("Path escapes output directory") |
| return full |
|
|
| def t_write_file(path: str = "", content: str = "", output_dir: str = "./unity_projects", |
| settings: Any = None, transport: Any = None, **_: Any) -> ToolResult: |
| base = getattr(settings, "output_dir", output_dir) if settings else output_dir |
| full = _safe_path(base, path) |
| os.makedirs(os.path.dirname(full), exist_ok=True) |
| with open(full, "w", encoding="utf-8") as fh: |
| fh.write(content) |
| return ToolResult(success=True, message=f"Wrote {len(content)} bytes to {path}", |
| files=[full]) |
|
|
| def t_read_file(path: str = "", output_dir: str = "./unity_projects", |
| settings: Any = None, **_: Any) -> ToolResult: |
| base = getattr(settings, "output_dir", output_dir) if settings else output_dir |
| full = _safe_path(base, path) |
| if not os.path.exists(full): |
| return ToolResult(success=False, message=f"File not found: {path}", error="not found") |
| with open(full, "r", encoding="utf-8", errors="replace") as fh: |
| content = fh.read() |
| return ToolResult(success=True, message=f"Read {len(content)} bytes from {path}", |
| data=content, files=[full]) |
|
|
| def t_list_files(path: str = "", output_dir: str = "./unity_projects", |
| settings: Any = None, **_: Any) -> ToolResult: |
| base = getattr(settings, "output_dir", output_dir) if settings else output_dir |
| full = _safe_path(base, path) if path else base |
| if not os.path.isdir(full): |
| return ToolResult(success=False, message=f"Directory not found: {path}") |
| files: List[str] = [] |
| for r, _d, fs in os.walk(full): |
| for f in fs: |
| files.append(os.path.relpath(os.path.join(r, f), base)) |
| files.sort() |
| return ToolResult(success=True, message=f"{len(files)} files", data=files, files=files) |
|
|
| def t_setup_unity_project(project_name: str = "UnityGame", |
| game_type: str = "minimal", output_dir: str = "./unity_projects", |
| settings: Any = None, **_: Any) -> ToolResult: |
| base = getattr(settings, "output_dir", output_dir) if settings else output_dir |
| root = _safe_path(base, project_name) |
| layout = [ |
| ("Assets/Scripts", ""), |
| ("Assets/Scenes", ""), |
| ("Assets/Prefabs", ""), |
| ("Assets/Materials", ""), |
| ("Assets/Resources", ""), |
| ("ProjectSettings", ""), |
| ("Packages", ""), |
| ] |
| for sub, _ in layout: |
| os.makedirs(os.path.join(root, sub), exist_ok=True) |
| |
| _write(os.path.join(root, "Assets", ".gitkeep"), "") |
| _write(os.path.join(root, "ProjectSettings", "ProjectVersion.txt"), |
| "m_EditorVersion: 2022.3.0f1\n") |
| _write(os.path.join(root, "Packages", "manifest.json"), |
| json.dumps({"dependencies": {"com.unity.3d.core": "1.0.0"}}, indent=2)) |
| return ToolResult(success=True, |
| message=f"Created Unity project skeleton at {project_name}", |
| files=[root], data={"root": root}) |
|
|
| def t_generate_complete_game(game_name: str = "Game", game_type: str = "minimal", |
| output_dir: str = "./unity_projects", |
| settings: Any = None, **_: Any) -> ToolResult: |
| base = getattr(settings, "output_dir", output_dir) if settings else output_dir |
| proj = _safe_path(base, game_name) |
| |
| setup = t_setup_unity_project(game_name, game_type, base, settings) |
| files = list(setup.files) |
| sample_cs = _csharp_player_controller(game_name, game_type) |
| cs_path = os.path.join(proj, "Assets", "Scripts", "PlayerController.cs") |
| _write(cs_path, sample_cs) |
| files.append(cs_path) |
| readme = os.path.join(proj, "README.md") |
| _write(readme, f"# {game_name}\n\nGenerated by Unity Agent.\n\nType: {game_type}\n") |
| files.append(readme) |
| return ToolResult(success=True, |
| message=f"Generated {game_type} game '{game_name}' with {len(files)} files.", |
| files=files, data={"root": proj, "type": game_type}) |
|
|
| def t_create_script(script_name: str = "NewScript", code: str = "", |
| output_dir: str = "./unity_projects", |
| settings: Any = None, **_: Any) -> ToolResult: |
| base = getattr(settings, "output_dir", output_dir) if settings else output_dir |
| if not script_name.endswith(".cs"): |
| script_name = script_name + ".cs" |
| full = _safe_path(base, "Assets", "Scripts", script_name) |
| os.makedirs(os.path.dirname(full), exist_ok=True) |
| if not code.strip(): |
| code = _csharp_template(script_name[:-3]) |
| _write(full, code) |
| return ToolResult(success=True, message=f"Created script {script_name}", files=[full]) |
|
|
| def t_create_scene(scene_name: str = "Main", |
| output_dir: str = "./unity_projects", |
| settings: Any = None, **_: Any) -> ToolResult: |
| base = getattr(settings, "output_dir", output_dir) if settings else output_dir |
| if not scene_name.endswith(".unity"): |
| scene_name = scene_name + ".unity" |
| full = _safe_path(base, "Assets", "Scenes", scene_name) |
| os.makedirs(os.path.dirname(full), exist_ok=True) |
| _write(full, "%YAML 1.1\n%TAG !u! tag:unity3d.com,2011:\n---\n# Empty scene stub\n") |
| return ToolResult(success=True, message=f"Created scene {scene_name}", files=[full]) |
|
|
| def t_create_prefab(prefab_name: str = "NewPrefab", |
| output_dir: str = "./unity_projects", |
| settings: Any = None, **_: Any) -> ToolResult: |
| base = getattr(settings, "output_dir", output_dir) if settings else output_dir |
| if not prefab_name.endswith(".prefab"): |
| prefab_name = prefab_name + ".prefab" |
| full = _safe_path(base, "Assets", "Prefabs", prefab_name) |
| os.makedirs(os.path.dirname(full), exist_ok=True) |
| _write(full, "%YAML 1.1\n%TAG !u! tag:unity3d.com,2011:\n---\n# Empty prefab stub\n") |
| return ToolResult(success=True, message=f"Created prefab {prefab_name}", files=[full]) |
|
|
| def t_run_qa_checks(output_dir: str = "./unity_projects", |
| settings: Any = None, **_: Any) -> ToolResult: |
| base = getattr(settings, "output_dir", output_dir) if settings else output_dir |
| if not os.path.isdir(base): |
| return ToolResult(success=False, message="No project directory to check") |
| issues: List[str] = [] |
| cs_count = 0 |
| scene_count = 0 |
| for r, _d, fs in os.walk(base): |
| for f in fs: |
| if f.endswith(".cs"): |
| cs_count += 1 |
| p = os.path.join(r, f) |
| try: |
| with open(p, encoding="utf-8", errors="replace") as fh: |
| src = fh.read() |
| if "MonoBehaviour" in src and "void Start" not in src and "void Awake" not in src: |
| issues.append(f"{f}: MonoBehaviour without Start/Awake") |
| |
| if src.count("{") != src.count("}"): |
| issues.append(f"{f}: unbalanced braces") |
| except Exception as e: |
| issues.append(f"{f}: read error {e}") |
| elif f.endswith(".unity"): |
| scene_count += 1 |
| if cs_count == 0: |
| issues.append("No C# scripts found") |
| if scene_count == 0: |
| issues.append("No scenes found") |
| ok = not issues |
| msg = f"QA {'PASSED' if ok else 'FAILED'}: {cs_count} scripts, {scene_count} scenes" |
| return ToolResult(success=ok, message=msg, data={"issues": issues}, |
| files=[base], error=None if ok else "; ".join(issues)) |
|
|
| reg.register("write_file", "Write a file to the project output directory.", |
| {"path": "str", "content": "str"}, t_write_file) |
| reg.register("read_file", "Read a file from the project output directory.", |
| {"path": "str"}, t_read_file) |
| reg.register("list_files", "List files in the project (optionally under a subpath).", |
| {"path": "str"}, t_list_files) |
| reg.register("setup_unity_project", "Create a Unity project folder skeleton.", |
| {"project_name": "str", "game_type": "str"}, t_setup_unity_project) |
| reg.register("generate_complete_game", "Generate a complete playable game from a preset.", |
| {"game_name": "str", "game_type": "str"}, t_generate_complete_game) |
| reg.register("create_script", "Create a C# script under Assets/Scripts.", |
| {"script_name": "str", "code": "str"}, t_create_script) |
| reg.register("create_scene", "Create a Unity scene stub.", |
| {"scene_name": "str"}, t_create_scene) |
| reg.register("create_prefab", "Create a Unity prefab stub.", |
| {"prefab_name": "str"}, t_create_prefab) |
| reg.register("run_qa_checks", "Run QA / lint checks on the current project.", |
| {}, t_run_qa_checks) |
| return reg |
|
|
|
|
| def _write(path: str, content: str) -> None: |
| os.makedirs(os.path.dirname(path), exist_ok=True) |
| with open(path, "w", encoding="utf-8") as fh: |
| fh.write(content) |
|
|
|
|
| def _csharp_template(name: str) -> str: |
| return ( |
| "using UnityEngine;\n\n" |
| f"public class {name} : MonoBehaviour\n" |
| "{{\n" |
| " void Start()\n" |
| " {{\n" |
| " Debug.Log(\"{name} ready\");\n" |
| " }}\n\n" |
| " void Update()\n" |
| " {{\n" |
| " }}\n" |
| "}}\n" |
| ).format(name=name) |
|
|
|
|
| def _csharp_player_controller(game_name: str, game_type: str) -> str: |
| return ( |
| "using UnityEngine;\n\n" |
| f"/// <summary>\n" |
| f"/// Auto-generated PlayerController for {game_name} ({game_type}).\n" |
| "/// </summary>\n" |
| "public class PlayerController : MonoBehaviour\n" |
| "{\n" |
| " public float moveSpeed = 5f;\n" |
| " public float rotateSpeed = 720f;\n" |
| " private Rigidbody rb;\n\n" |
| " void Awake()\n" |
| " {\n" |
| " rb = GetComponent<Rigidbody>();\n" |
| " }\n\n" |
| " void Start()\n" |
| " {\n" |
| " Debug.Log(\"PlayerController ready\");\n" |
| " }\n\n" |
| " void Update()\n" |
| " {\n" |
| " float h = Input.GetAxisRaw(\"Horizontal\");\n" |
| " float v = Input.GetAxisRaw(\"Vertical\");\n" |
| " Vector3 dir = new Vector3(h, 0f, v).normalized;\n" |
| " if (dir.magnitude > 0.01f)\n" |
| " {\n" |
| " Quaternion toRot = Quaternion.LookRotation(dir, Vector3.up);\n" |
| " transform.rotation = Quaternion.RotateTowards(\n" |
| " transform.rotation, toRot, rotateSpeed * Time.deltaTime);\n" |
| " }\n" |
| " if (rb != null)\n" |
| " {\n" |
| " rb.MovePosition(transform.position + dir * moveSpeed * Time.deltaTime);\n" |
| " }\n" |
| " }\n" |
| "}\n" |
| ) |
|
|
|
|
| def get_registry(): |
| """Return the real tool registry if available, else the fallback.""" |
| if _TOOL_REGISTRY is not None: |
| return _TOOL_REGISTRY |
| global _FALLBACK_REGISTRY |
| try: |
| return _FALLBACK_REGISTRY |
| except NameError: |
| _FALLBACK_REGISTRY = _build_fallback_registry() |
| return _FALLBACK_REGISTRY |
|
|
|
|
| def get_tool_result_class(): |
| """Return the real ToolResult class if available, else our fallback.""" |
| return _ToolResult or ToolResult |
|
|
|
|
| def build_tool_catalog() -> str: |
| """Return a plain-text catalog of all tools (for the system prompt).""" |
| reg = get_registry() |
| lines: List[str] = [] |
| for name in reg.all_names(): |
| try: |
| tool = reg.get(name) |
| schema = getattr(tool, "input_schema", {}) or {} |
| params = ", ".join(schema.keys()) if schema else "none" |
| doc = (getattr(tool, "__doc__", "") or "").strip().split("\n")[0][:80] |
| except Exception: |
| params, doc = "?", "" |
| lines.append(f" - {name}({params}): {doc}") |
| return "\n".join(lines) |
|
|
|
|
| def run_tool(tool_name: str, arguments: Dict[str, Any], settings: Any, transport: Any) -> ToolResult: |
| """Execute a registered tool by name with ``arguments``. |
| |
| ``settings`` and ``transport`` are passed through to the tool constructor |
| / function signature when supported. Unknown tools return a failed |
| :class:`ToolResult`. |
| """ |
| reg = get_registry() |
| tool = reg.get(tool_name) |
| if tool is None: |
| return ToolResult(success=False, message=f"Unknown tool: {tool_name}", |
| error="unknown tool") |
| try: |
| |
| if _TOOL_REGISTRY is not None and isinstance(tool, type): |
| try: |
| instance = tool(settings, transport) |
| return instance.run(**arguments) |
| except TypeError: |
| |
| return tool(settings=settings, transport=transport, **arguments) |
| |
| if hasattr(tool, "run"): |
| return tool.run(settings=settings, transport=transport, **arguments) |
| |
| return tool(settings=settings, transport=transport, **arguments) |
| except TypeError as e: |
| |
| try: |
| if hasattr(tool, "run"): |
| return tool.run(**arguments) |
| return tool(**arguments) |
| except Exception as e2: |
| return ToolResult(success=False, message=f"{e2}", error=str(e2)) |
| except Exception as e: |
| return ToolResult(success=False, message=str(e), error=str(e)) |
|
|
|
|
| |
| |
| |
| class Settings: |
| """Lightweight settings container; mirrors the package Settings if present.""" |
|
|
| def __init__(self, output_dir: Optional[str] = None): |
| self.output_dir = os.path.abspath(output_dir or "./unity_projects") |
| os.makedirs(self.output_dir, exist_ok=True) |
| self.model = os.environ.get("ZAI_MODEL", "glm-4.5-flash") |
| self.base_url = os.environ.get("ZAI_BASE_URL", "https://open.bigmodel.cn/api/paas/v4") |
| self.temperature = float(os.environ.get("ZAI_TEMPERATURE", "0.2")) |
| self.max_tokens = int(os.environ.get("ZAI_MAX_TOKENS", "4096")) |
|
|
| def to_dict(self) -> Dict[str, Any]: |
| return { |
| "output_dir": self.output_dir, |
| "model": self.model, |
| "base_url": self.base_url, |
| "temperature": self.temperature, |
| "max_tokens": self.max_tokens, |
| } |
|
|
|
|
| |
| |
| |
| PRESETS: Dict[str, Dict[str, Any]] = { |
| "minimal": { |
| "title": "Minimal Cube Demo", |
| "description": "A single cube you can move with WASD. Perfect starter.", |
| "complexity": 1, |
| "estimated_files": 4, |
| }, |
| "fps_arena": { |
| "title": "FPS Arena", |
| "description": "First-person shooter in a small arena with targets.", |
| "complexity": 4, |
| "estimated_files": 12, |
| }, |
| "open_world_city": { |
| "title": "Open World City", |
| "description": "Procedural city blocks with a drivable car and traffic.", |
| "complexity": 5, |
| "estimated_files": 30, |
| }, |
| "racing": { |
| "title": "Arcade Racing", |
| "description": "Lap-based racing with checkpoints and a speedometer.", |
| "complexity": 3, |
| "estimated_files": 10, |
| }, |
| "platformer": { |
| "title": "2.5D Platformer", |
| "description": "Side-scrolling platformer with collectibles and enemies.", |
| "complexity": 3, |
| "estimated_files": 11, |
| }, |
| "puzzle": { |
| "title": "Match-3 Puzzle", |
| "description": "Grid-based match-3 puzzle with score and timers.", |
| "complexity": 2, |
| "estimated_files": 7, |
| }, |
| "tower_defense": { |
| "title": "Tower Defense", |
| "description": "Place towers along a path to stop waves of enemies.", |
| "complexity": 4, |
| "estimated_files": 16, |
| }, |
| "endless_runner": { |
| "title": "Endless Runner", |
| "description": "Auto-runner with obstacles and increasing difficulty.", |
| "complexity": 2, |
| "estimated_files": 8, |
| }, |
| } |
|
|
|
|
| |
| |
| |
| LOGO = r""" |
| __ _ _ _ __ _ _ _ |
| \ \|| || || |___ / /_ | || | __ _ | |__ _____ __ |
| \ | _ || / -_) _|| __ | / _` | | '_ \ / _ \ \/ / |
| \|_||_||_|_\___|\__||_||_| \__,_| |_.__/ \___/> < |
| /_/\_\ |
| """ |
|
|
|
|
| def print_banner() -> None: |
| """Print the animated startup banner.""" |
| width = term_width() |
| |
| print(C.B_MAGENTA + Box.DH * width + C.RESET) |
| for line in LOGO.splitlines(): |
| print(C.B_CYAN + line.center(width) + C.RESET) |
| sub = f"{C.BOLD}{C.B_WHITE}Unity Game Agent{C.RESET} {DIM}·{C.RESET} {C.B_YELLOW}Interactive CLI{C.RESET}" |
| print(sub.center(width + 6)) |
| info = (f"{C.GREEN}{Box.STAR}{C.RESET} Zero-dependency " |
| f"{C.GREEN}{Box.STAR}{C.RESET} Standard-library only " |
| f"{C.GREEN}{Box.STAR}{C.RESET} Z.ai powered") |
| print(info.center(width + 6)) |
| print(C.B_MAGENTA + Box.DH * width + C.RESET) |
|
|
|
|
| |
| |
| |
| def ok(msg: str) -> None: |
| print(f" {C.BOLD}{C.GREEN}{Box.CHECK}{C.RESET} {C.GREEN}{msg}{C.RESET}") |
|
|
|
|
| def err(msg: str) -> None: |
| print(f" {C.BOLD}{C.RED}{Box.CROSS_X}{C.RESET} {C.RED}{msg}{C.RESET}") |
|
|
|
|
| def warn(msg: str) -> None: |
| print(f" {C.BOLD}{C.YELLOW}!{C.RESET} {C.YELLOW}{msg}{C.RESET}") |
|
|
|
|
| def info(msg: str) -> None: |
| print(f" {C.BLUE}{Box.BULLET}{C.RESET} {C.BLUE}{msg}{C.RESET}") |
|
|
|
|
| def dim(msg: str) -> None: |
| print(f" {DIM}{msg}{C.RESET}") |
|
|
|
|
| def user_echo(msg: str) -> None: |
| print(f" {C.CYAN}{Box.ARROW}{C.RESET} {C.CYAN}{msg}{C.RESET}") |
|
|
|
|
| def agent_echo(msg: str) -> None: |
| print(f" {C.MAGENTA}{Box.DIAMOND}{C.RESET} {C.MAGENTA}{msg}{C.RESET}") |
|
|
|
|
| def section(title: str, color: str = C.B_CYAN) -> None: |
| print() |
| print(divider(title, color=color)) |
|
|
|
|
| def prompt_label() -> str: |
| """Return the colored ``game>`` prompt string.""" |
| return f"{C.BOLD}{C.CYAN}game{C.RESET}{C.B_MAGENTA}>{C.RESET} " |
|
|
|
|
| |
| |
| |
| def render_file_tree(root: str, max_depth: int = 5) -> str: |
| """Render an ASCII file tree under ``root``.""" |
| if not os.path.isdir(root): |
| return f"{C.RED}No such directory: {root}{C.RESET}" |
|
|
| lines: List[str] = [] |
| base_name = os.path.basename(os.path.normpath(root)) or root |
| lines.append(f"{C.B_BLUE}{Box.DIAMOND} {base_name}/{C.RESET}") |
|
|
| def walk(path: str, prefix: str, depth: int) -> None: |
| if depth > max_depth: |
| return |
| try: |
| entries = sorted(os.listdir(path)) |
| except OSError: |
| return |
| dirs = [e for e in entries if os.path.isdir(os.path.join(path, e))] |
| files = [e for e in entries if not os.path.isdir(os.path.join(path, e))] |
| |
| skip = {".git", "__pycache__", ".idea", "Library", "Temp", "Obj", "Build"} |
| dirs = [d for d in dirs if d not in skip] |
| all_items = [(d, True) for d in dirs] + [(f, False) for f in files] |
| for i, (name, is_dir) in enumerate(all_items): |
| last = i == len(all_items) - 1 |
| connector = Box.BT if last else Box.TT |
| child = os.path.join(path, name) |
| if is_dir: |
| lines.append(f"{prefix}{DIM}{connector}{C.RESET} {C.B_BLUE}{name}/{C.RESET}") |
| extension = " " if last else f"{DIM}{Box.V}{C.RESET} " |
| walk(child, prefix + extension, depth + 1) |
| else: |
| color = _file_color(name) |
| lines.append(f"{prefix}{DIM}{connector}{C.RESET} {color}{name}{C.RESET}") |
|
|
| walk(root, "", 1) |
| return "\n".join(lines) |
|
|
|
|
| def _file_color(name: str) -> str: |
| lower = name.lower() |
| if lower.endswith(".cs"): |
| return C.B_CYAN |
| if lower.endswith((".unity",)): |
| return C.B_GREEN |
| if lower.endswith((".prefab",)): |
| return C.B_YELLOW |
| if lower.endswith((".mat", ".asset", ".shader")): |
| return C.MAGENTA |
| if lower.endswith((".json",)): |
| return C.YELLOW |
| if lower.endswith((".md", ".txt")): |
| return C.WHITE |
| if lower.endswith((".png", ".jpg", ".jpeg", ".tga", ".psd")): |
| return C.B_MAGENTA |
| if lower.endswith((".wav", ".mp3", ".ogg")): |
| return C.BLUE |
| return C.WHITE |
|
|
|
|
| |
| |
| |
| def collect_stats(root: str) -> Dict[str, Any]: |
| """Walk ``root`` and return aggregate statistics.""" |
| stats: Dict[str, Any] = { |
| "total_files": 0, |
| "total_dirs": 0, |
| "total_bytes": 0, |
| "by_ext": {}, |
| "scripts": 0, |
| "scenes": 0, |
| "prefabs": 0, |
| "code_lines": 0, |
| } |
| if not os.path.isdir(root): |
| return stats |
| for r, d, fs in os.walk(root): |
| |
| d[:] = [x for x in d if x not in {".git", "__pycache__", "Library", "Temp", "Obj"}] |
| stats["total_dirs"] += len(d) |
| for f in fs: |
| fp = os.path.join(r, f) |
| try: |
| size = os.path.getsize(fp) |
| except OSError: |
| size = 0 |
| stats["total_files"] += 1 |
| stats["total_bytes"] += size |
| ext = os.path.splitext(f)[1].lower() or "(none)" |
| stats["by_ext"][ext] = stats["by_ext"].get(ext, 0) + 1 |
| if ext == ".cs": |
| stats["scripts"] += 1 |
| try: |
| with open(fp, encoding="utf-8", errors="replace") as fh: |
| stats["code_lines"] += sum(1 for _ in fh) |
| except OSError: |
| pass |
| elif ext == ".unity": |
| stats["scenes"] += 1 |
| elif ext == ".prefab": |
| stats["prefabs"] += 1 |
| return stats |
|
|
|
|
| def human_size(n: int) -> str: |
| for unit in ("B", "KB", "MB", "GB", "TB"): |
| if n < 1024 or unit == "TB": |
| return f"{n:.1f} {unit}" if unit != "B" else f"{n} B" |
| n /= 1024 |
| return f"{n} TB" |
|
|
|
|
| |
| |
| |
| class UnityCLI: |
| """The interactive REPL driving the Unity game agent.""" |
|
|
| HISTORY_FILE = ".unity_agent_history.json" |
| CONFIG_FILE = ".unity_agent_config.json" |
|
|
| def __init__(self, settings: Optional[Settings] = None): |
| self.settings = settings or Settings() |
| self.transport = None |
| try: |
| from unity_agent.transport.unity_transport import UnityTransport |
| self.transport = UnityTransport(self.settings) |
| except Exception: |
| self.transport = None |
|
|
| self.api_key = self._resolve_api_key() |
| self.llm: Optional[LLMClient] = None |
| if self.api_key: |
| self.llm = LLMClient(self.api_key, self.settings.model, self.settings.base_url) |
|
|
| self.messages: List[Dict[str, str]] = [] |
| self.history: List[Dict[str, str]] = [] |
| self.current_project: Optional[str] = None |
| self.running = True |
| self.tool_call_count = 0 |
| self.start_time = time.time() |
| self._init_system_prompt() |
|
|
| |
| def _resolve_api_key(self) -> str: |
| key = os.environ.get("ZAI_API_KEY", "").strip() |
| if key: |
| return key |
| env_path = os.path.join(os.getcwd(), ".env") |
| if os.path.exists(env_path): |
| try: |
| with open(env_path, encoding="utf-8") as fh: |
| for line in fh: |
| if line.strip().startswith("ZAI_API_KEY="): |
| return line.split("=", 1)[1].strip().strip('"').strip("'") |
| except OSError: |
| pass |
| return "" |
|
|
| def _init_system_prompt(self) -> None: |
| catalog = build_tool_catalog() |
| preset_lines = "\n".join(f" - {k}: {v['title']}" for k, v in PRESETS.items()) |
| base = _SYSTEM_PROMPT or _DEFAULT_SYSTEM_PROMPT |
| full = ( |
| base.strip() |
| + "\n\n# AVAILABLE TOOLS\n" + catalog |
| + "\n\n# AVAILABLE PRESETS\n" + preset_lines |
| + "\n\n# OUTPUT FORMAT\n" |
| + "When you need to use a tool, emit a fenced block:\n" |
| + "```tool_call\n" |
| + '{"name": "<tool_name>", "arguments": { ... }}\n' |
| + "```\n" |
| + "Then briefly explain what you did in plain prose.\n" |
| + "When the task is fully complete, include the literal token TASK COMPLETE.\n" |
| ) |
| self.messages = [{"role": "system", "content": full}] |
| self.system_prompt = full |
|
|
| |
| def run(self) -> None: |
| print_banner() |
| self._print_status() |
| self._maybe_prompt_api_key() |
| self._repl() |
|
|
| def _print_status(self) -> None: |
| lines = [ |
| f"{C.B_CYAN}Model{C.RESET} : {C.WHITE}{self.settings.model}{C.RESET}", |
| f"{C.B_CYAN}Endpoint{C.RESET} : {C.WHITE}{self.settings.base_url}{C.RESET}", |
| f"{C.B_CYAN}Output{C.RESET} : {C.GREEN}{self.settings.output_dir}{C.RESET}", |
| f"{C.B_CYAN}Tools{C.RESET} : {C.WHITE}{len(get_registry().all_names())} registered{C.RESET}", |
| f"{C.B_CYAN}API Key{C.RESET} : " |
| + (f"{C.GREEN}set ({self.api_key[:6]}...{self.api_key[-4:]}){C.RESET}" |
| if self.api_key else f"{C.RED}missing{C.RESET}"), |
| ] |
| print(panel(lines, title="STATUS", border_color=C.CYAN, title_color=C.B_CYAN, rounded=True)) |
|
|
| def _maybe_prompt_api_key(self) -> None: |
| if self.api_key: |
| return |
| print() |
| warn("No ZAI_API_KEY found in env or .env file.") |
| info("Get a free key at https://z.ai") |
| try: |
| key = input(f" {C.YELLOW}Paste your Z.ai API key (or blank to skip): {C.RESET}").strip() |
| except (EOFError, KeyboardInterrupt): |
| print() |
| return |
| if key: |
| self.api_key = key |
| self.llm = LLMClient(key, self.settings.model, self.settings.base_url) |
| ok("API key accepted for this session.") |
| save = input(f" {DIM}Save to .env for future sessions? [y/N]: {C.RESET}").strip().lower() |
| if save in ("y", "yes"): |
| try: |
| with open(os.path.join(os.getcwd(), ".env"), "a", encoding="utf-8") as fh: |
| fh.write(f"\nZAI_API_KEY={key}\n") |
| ok("Saved to .env") |
| except OSError as e: |
| err(f"Could not save .env: {e}") |
|
|
| |
| def _repl(self) -> None: |
| print() |
| info("Type a game idea, or " + C.BOLD + "/help" + C.RESET + " for commands.") |
| print() |
| while self.running: |
| try: |
| raw = input(prompt_label()) |
| except (EOFError, KeyboardInterrupt): |
| print() |
| self.cmd_exit() |
| break |
| line = raw.strip() |
| if not line: |
| continue |
| if line.startswith("/"): |
| self._dispatch_slash(line) |
| continue |
| self._handle_chat(line) |
|
|
| def _dispatch_slash(self, line: str) -> None: |
| parts = line.split(None, 1) |
| cmd = parts[0].lower() |
| args = parts[1].strip() if len(parts) > 1 else "" |
| handler = { |
| "/help": self.cmd_help, |
| "/?": self.cmd_help, |
| "/tools": self.cmd_tools, |
| "/presets": self.cmd_presets, |
| "/generate": self.cmd_generate, |
| "/open": self.cmd_open, |
| "/scene": self.cmd_scene, |
| "/clear": self.cmd_clear, |
| "/config": self.cmd_config, |
| "/history": self.cmd_history, |
| "/export": self.cmd_export, |
| "/test": self.cmd_test, |
| "/stats": self.cmd_stats, |
| "/new": self.cmd_new, |
| "/add": self.cmd_add, |
| "/script": self.cmd_script, |
| "/run": self.cmd_run, |
| "/exit": self.cmd_exit, |
| "/quit": self.cmd_exit, |
| }.get(cmd) |
| if handler is None: |
| err(f"Unknown command: {cmd}. Type /help for the command list.") |
| return |
| try: |
| handler(args) |
| except Exception as e: |
| err(f"Command failed: {e}") |
|
|
| |
| def cmd_help(self, args: str) -> None: |
| section("HELP — COMMANDS", color=C.B_CYAN) |
| groups = [ |
| ("Getting Around", C.B_CYAN, [ |
| ("/help", "Show this help with colored categories"), |
| ("/tools", "List all available tools in a colored table"), |
| ("/presets", "List built-in game presets"), |
| ("/clear", "Clear the conversation history"), |
| ("/exit", "Quit the Unity Agent CLI"), |
| ]), |
| ("Project Management", C.B_GREEN, [ |
| ("/new <type>", "Start a new game project (preset or custom)"), |
| ("/generate <preset>", "Quick-generate a game from a preset"), |
| ("/open <path>", "Set the project output directory"), |
| ("/scene", "Show the current project file tree"), |
| ("/stats", "Show project statistics"), |
| ("/export", "Export the current project as a .zip"), |
| ("/run", "Show instructions to open the project in Unity"), |
| ]), |
| ("Development", C.B_YELLOW, [ |
| ("/add <tool>", "Call a specific tool manually"), |
| ("/script <name>", "Write / edit a custom C# script"), |
| ("/test", "Run QA checks on the current project"), |
| ]), |
| ("Session", C.B_MAGENTA, [ |
| ("/config", "Show / edit CLI configuration"), |
| ("/history", "Show conversation history"), |
| ]), |
| ] |
| for title, color, cmds in groups: |
| print(f"\n {color}{C.BOLD}{title}{C.RESET}") |
| for name, desc in cmds: |
| print(f" {C.BOLD}{C.WHITE}{name:<22}{C.RESET} {DIM}{desc}{C.RESET}") |
| print() |
| info("Or just type a request in plain English, e.g.:") |
| print(f" {DIM}> create a 3d platformer with double-jump and collectibles{C.RESET}") |
| print() |
|
|
| def cmd_tools(self, args: str) -> None: |
| section("TOOLS", color=C.B_GREEN) |
| reg = get_registry() |
| names = reg.all_names() |
| if not names: |
| warn("No tools registered.") |
| return |
| headers = ["#", "Tool", "Parameters", "Description"] |
| rows: List[List[str]] = [] |
| for i, name in enumerate(names, 1): |
| tool = reg.get(name) |
| schema = getattr(tool, "input_schema", {}) or {} |
| params = ", ".join(schema.keys()) if schema else "—" |
| doc = (getattr(tool, "__doc__", "") or "").strip().split("\n")[0] |
| if not doc: |
| doc = "(no description)" |
| rows.append([ |
| C.B_BLACK + str(i) + C.RESET, |
| C.B_CYAN + name + C.RESET, |
| C.YELLOW + params + C.RESET, |
| C.WHITE + doc + C.RESET, |
| ]) |
| colors = [C.B_BLACK, C.B_CYAN, C.YELLOW, C.WHITE] |
| print(table(headers, rows, colors=colors, header_color=C.B_GREEN, border_color=DIM)) |
| print() |
| info(f"{C.BOLD}{len(names)}{C.RESET} tools available. Use " + C.BOLD + "/add <tool>" + C.RESET + " to call one.") |
|
|
| def cmd_presets(self, args: str) -> None: |
| section("GAME PRESETS", color=C.B_YELLOW) |
| headers = ["Preset", "Title", "Complexity", "Files", "Description"] |
| rows: List[List[str]] = [] |
| for key, meta in PRESETS.items(): |
| stars = C.B_YELLOW + "★" * meta["complexity"] + C.RESET + DIM + "★" * (5 - meta["complexity"]) + C.RESET |
| rows.append([ |
| C.B_CYAN + key + C.RESET, |
| C.B_WHITE + meta["title"] + C.RESET, |
| stars, |
| C.YELLOW + str(meta["estimated_files"]) + C.RESET, |
| C.WHITE + meta["description"] + C.RESET, |
| ]) |
| colors = [C.B_CYAN, C.B_WHITE, C.B_YELLOW, C.YELLOW, C.WHITE] |
| print(table(headers, rows, colors=colors, header_color=C.B_YELLOW, border_color=DIM)) |
| print() |
| info("Use " + C.BOLD + "/generate <preset>" + C.RESET + " to scaffold one.") |
|
|
| def cmd_generate(self, args: str) -> None: |
| preset = args.strip() |
| if not preset: |
| err("Usage: /generate <preset> (e.g. /generate open_world_city)") |
| info("Available presets: " + ", ".join(C.B_CYAN + k + C.RESET for k in PRESETS)) |
| return |
| if preset not in PRESETS: |
| err(f"Unknown preset: {preset}") |
| info("Available: " + ", ".join(PRESETS.keys())) |
| return |
| meta = PRESETS[preset] |
| section(f"GENERATE — {meta['title']}", color=C.B_GREEN) |
| info(f"Preset: {C.BOLD}{preset}{C.RESET}") |
| info(f"Detail: {meta['description']}") |
| info(f"Expect: ~{meta['estimated_files']} files, complexity {meta['complexity']}/5") |
|
|
| game_name = f"Game_{preset}" |
| with ProgressBar(meta["estimated_files"] + 2, label="Generating", color=C.B_GREEN, fill_color=C.B_GREEN) as bar: |
| bar.update(1, "Setting up project") |
| r = run_tool("setup_unity_project", |
| {"project_name": game_name, "game_type": preset}, |
| self.settings, self.transport) |
| bar.update(2, "Generating scripts") |
| if r.success: |
| ok("Project skeleton created.") |
| self.current_project = os.path.join(self.settings.output_dir, game_name) |
| else: |
| err(f"Setup failed: {r.message}") |
| bar.finish() |
| return |
| bar.update(3, "Generating game content") |
| r2 = run_tool("generate_complete_game", |
| {"game_name": game_name, "game_type": preset}, |
| self.settings, self.transport) |
| for i in range(4, meta["estimated_files"] + 2): |
| bar.update(i, f"Writing asset {i - 3}") |
| time.sleep(0.05) |
| bar.update(meta["estimated_files"] + 2, "Finalizing") |
| if r2.success: |
| ok(f"Game generated: {self.current_project}") |
| self._record_history("system", f"Generated {preset} game at {self.current_project}") |
| print() |
| print(panel( |
| [ |
| f"{C.GREEN}{Box.CHECK}{C.RESET} {C.BOLD}{meta['title']}{C.RESET}", |
| f"{DIM}Location:{C.RESET} {self.current_project}", |
| f"{DIM}Files:{C.RESET} {len(r2.files)}", |
| "", |
| f"{C.B_YELLOW}Next:{C.RESET} type " + C.BOLD + "/run" + C.RESET + " for Unity instructions,", |
| " or " + C.BOLD + "/scene" + C.RESET + " to browse the file tree.", |
| ], |
| title="GENERATION COMPLETE", |
| border_color=C.B_GREEN, |
| title_color=C.B_GREEN, |
| rounded=True, |
| )) |
| else: |
| err(f"Generation failed: {r2.message}") |
|
|
| def cmd_open(self, args: str) -> None: |
| if not args.strip(): |
| err("Usage: /open <path>") |
| info(f"Current output dir: {self.settings.output_dir}") |
| return |
| path = os.path.abspath(os.path.expanduser(args.strip())) |
| try: |
| os.makedirs(path, exist_ok=True) |
| except OSError as e: |
| err(f"Cannot create/use directory {path}: {e}") |
| return |
| self.settings.output_dir = path |
| if self.transport is not None: |
| try: |
| from unity_agent.transport.unity_transport import UnityTransport |
| self.transport = UnityTransport(self.settings) |
| except Exception: |
| pass |
| ok(f"Output directory set to {path}") |
| self.current_project = None |
|
|
| def cmd_scene(self, args: str) -> None: |
| section("PROJECT FILE TREE", color=C.B_BLUE) |
| target = self.current_project or self.settings.output_dir |
| if not os.path.isdir(target): |
| err(f"No project directory yet: {target}") |
| info("Try " + C.BOLD + "/generate <preset>" + C.RESET + " or " + C.BOLD + "/new <type>" + C.RESET + " first.") |
| return |
| print(render_file_tree(target)) |
|
|
| def cmd_clear(self, args: str) -> None: |
| self.messages = [{"role": "system", "content": self.system_prompt}] |
| ok("Conversation cleared. History retained — use " + C.BOLD + "/history" + C.RESET + " to view it.") |
|
|
| def cmd_config(self, args: str) -> None: |
| section("CONFIGURATION", color=C.B_MAGENTA) |
| d = self.settings.to_dict() |
| rows = [ |
| [C.B_CYAN + "output_dir" + C.RESET, C.GREEN + d["output_dir"] + C.RESET], |
| [C.B_CYAN + "model" + C.RESET, C.WHITE + d["model"] + C.RESET], |
| [C.B_CYAN + "base_url" + C.RESET, C.WHITE + d["base_url"] + C.RESET], |
| [C.B_CYAN + "temperature" + C.RESET, C.YELLOW + str(d["temperature"]) + C.RESET], |
| [C.B_CYAN + "max_tokens" + C.RESET, C.YELLOW + str(d["max_tokens"]) + C.RESET], |
| [C.B_CYAN + "api_key" + C.RESET, |
| (C.GREEN + f"{self.api_key[:6]}...{self.api_key[-4:]}" + C.RESET) |
| if self.api_key else C.RED + "(not set)" + C.RESET], |
| ] |
| print(table(["Key", "Value"], rows, colors=[C.B_CYAN, C.WHITE], |
| header_color=C.B_MAGENTA, border_color=DIM)) |
| print() |
| info("Edit by setting environment variables (ZAI_MODEL, ZAI_BASE_URL, ...)") |
| info("or run: " + C.BOLD + "/config set <key> <value>" + C.RESET) |
| if args.strip().lower().startswith("set"): |
| self._config_set(args.strip()[3:].strip()) |
|
|
| def _config_set(self, args: str) -> None: |
| parts = args.split(None, 1) |
| if len(parts) != 2: |
| err("Usage: /config set <key> <value>") |
| return |
| key, value = parts |
| key = key.lower() |
| if key in ("output_dir", "output"): |
| self.settings.output_dir = os.path.abspath(os.path.expanduser(value)) |
| os.makedirs(self.settings.output_dir, exist_ok=True) |
| ok(f"output_dir = {self.settings.output_dir}") |
| elif key in ("model",): |
| self.settings.model = value |
| if self.llm: |
| self.llm.model = value |
| ok(f"model = {value}") |
| elif key in ("base_url", "baseurl", "endpoint"): |
| self.settings.base_url = value.rstrip("/") |
| if self.llm: |
| self.llm.base_url = self.settings.base_url |
| ok(f"base_url = {self.settings.base_url}") |
| elif key in ("temperature", "temp"): |
| try: |
| self.settings.temperature = float(value) |
| ok(f"temperature = {self.settings.temperature}") |
| except ValueError: |
| err("temperature must be a number") |
| elif key in ("max_tokens", "maxtokens"): |
| try: |
| self.settings.max_tokens = int(value) |
| ok(f"max_tokens = {self.settings.max_tokens}") |
| except ValueError: |
| err("max_tokens must be an integer") |
| else: |
| err(f"Unknown config key: {key}") |
|
|
| def cmd_history(self, args: str) -> None: |
| section("CONVERSATION HISTORY", color=C.B_MAGENTA) |
| if not self.history: |
| warn("No conversation history yet.") |
| return |
| headers = ["#", "Role", "Time", "Snippet"] |
| rows: List[List[str]] = [] |
| for i, entry in enumerate(self.history, 1): |
| role = entry.get("role", "?") |
| ts = entry.get("ts", "") |
| content = entry.get("content", "") |
| snippet = content.replace("\n", " ")[:60] |
| if len(content) > 60: |
| snippet += "…" |
| role_color = { |
| "user": C.CYAN, |
| "assistant": C.MAGENTA, |
| "system": C.B_BLUE, |
| "tool": C.GREEN, |
| }.get(role, C.WHITE) |
| rows.append([ |
| C.B_BLACK + str(i) + C.RESET, |
| role_color + role + C.RESET, |
| DIM + ts + C.RESET, |
| C.WHITE + snippet + C.RESET, |
| ]) |
| print(table(headers, rows, colors=[C.B_BLACK, C.WHITE, DIM, C.WHITE], |
| header_color=C.B_MAGENTA, border_color=DIM)) |
| print() |
| info(f"{C.BOLD}{len(self.history)}{C.RESET} entries. Use " + C.BOLD + "/clear" + C.RESET + " to reset the live conversation.") |
|
|
| def cmd_export(self, args: str) -> None: |
| section("EXPORT PROJECT", color=C.B_YELLOW) |
| target = self.current_project or self.settings.output_dir |
| if not os.path.isdir(target): |
| err(f"No project to export: {target}") |
| return |
| base_name = os.path.basename(os.path.normpath(target)) or "unity_project" |
| zip_name = args.strip() or base_name + ".zip" |
| if not zip_name.endswith(".zip"): |
| zip_name += ".zip" |
| zip_path = os.path.abspath(zip_name) |
| info(f"Zipping {target} → {zip_path}") |
| skip_dirs = {".git", "__pycache__", "Library", "Temp", "Obj", "Build"} |
| file_count = 0 |
| total_bytes = 0 |
| with ProgressBar(100, label="Exporting", color=C.B_YELLOW, fill_color=C.B_YELLOW) as bar: |
| with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: |
| for r, d, fs in os.walk(target): |
| d[:] = [x for x in d if x not in skip_dirs] |
| for f in fs: |
| full = os.path.join(r, f) |
| arc = os.path.relpath(full, target) |
| zf.write(full, arc) |
| file_count += 1 |
| total_bytes += os.path.getsize(full) |
| bar.update(100) |
| ok(f"Exported {file_count} files ({human_size(total_bytes)}) → {zip_path}") |
|
|
| def cmd_test(self, args: str) -> None: |
| section("QA CHECKS", color=C.B_RED) |
| target = self.current_project or self.settings.output_dir |
| if not os.path.isdir(target): |
| err(f"No project to test: {target}") |
| return |
| info(f"Running QA checks on {target}...") |
| spinner = Spinner("Analyzing", color=C.RED, interval=0.07) |
| spinner.start() |
| time.sleep(0.4) |
| result = run_tool("run_qa_checks", {}, self.settings, self.transport) |
| spinner.stop() |
| if result.success: |
| ok(result.message) |
| else: |
| err(result.message) |
| issues = [] |
| if isinstance(result.data, dict): |
| issues = result.data.get("issues", []) or [] |
| if issues: |
| warn(f"{len(issues)} issue(s) found:") |
| for i, issue in enumerate(issues, 1): |
| print(f" {C.YELLOW}{i}.{C.RESET} {issue}") |
| else: |
| ok("No issues detected.") |
| |
| self._extra_qa(target) |
|
|
| def _extra_qa(self, target: str) -> None: |
| extra: List[str] = [] |
| has_readme = os.path.exists(os.path.join(target, "README.md")) |
| has_manifest = os.path.exists(os.path.join(target, "Packages", "manifest.json")) |
| has_projectversion = os.path.exists(os.path.join(target, "ProjectSettings", "ProjectVersion.txt")) |
| if not has_readme: |
| extra.append("missing README.md") |
| if not has_manifest: |
| extra.append("missing Packages/manifest.json") |
| if not has_projectversion: |
| extra.append("missing ProjectSettings/ProjectVersion.txt") |
| if extra: |
| warn("Project hygiene:") |
| for e in extra: |
| print(f" {C.YELLOW}-{C.RESET} {e}") |
| else: |
| ok("Project hygiene: all marker files present.") |
|
|
| def cmd_stats(self, args: str) -> None: |
| section("PROJECT STATISTICS", color=C.B_BLUE) |
| target = self.current_project or self.settings.output_dir |
| if not os.path.isdir(target): |
| err(f"No project: {target}") |
| return |
| s = collect_stats(target) |
| rows = [ |
| [C.B_CYAN + "Path" + C.RESET, C.GREEN + target + C.RESET], |
| [C.B_CYAN + "Files" + C.RESET, C.WHITE + str(s["total_files"]) + C.RESET], |
| [C.B_CYAN + "Directories" + C.RESET, C.WHITE + str(s["total_dirs"]) + C.RESET], |
| [C.B_CYAN + "Size" + C.RESET, C.YELLOW + human_size(s["total_bytes"]) + C.RESET], |
| [C.B_CYAN + "C# Scripts" + C.RESET, C.B_CYAN + str(s["scripts"]) + C.RESET], |
| [C.B_CYAN + "Scenes" + C.RESET, C.B_GREEN + str(s["scenes"]) + C.RESET], |
| [C.B_CYAN + "Prefabs" + C.RESET, C.B_YELLOW + str(s["prefabs"]) + C.RESET], |
| [C.B_CYAN + "Code Lines" + C.RESET, C.MAGENTA + str(s["code_lines"]) + C.RESET], |
| ] |
| print(table(["Metric", "Value"], rows, colors=[C.B_CYAN, C.WHITE], |
| header_color=C.B_BLUE, border_color=DIM)) |
| |
| if s["by_ext"]: |
| print() |
| info("Files by extension:") |
| ext_rows = sorted(s["by_ext"].items(), key=lambda kv: -kv[1]) |
| for ext, count in ext_rows[:12]: |
| bar_len = min(40, count) |
| bar = C.B_BLUE + "█" * bar_len + C.RESET + DIM + "·" * max(0, 40 - bar_len) + C.RESET |
| print(f" {C.YELLOW}{ext:<10}{C.RESET} {C.WHITE}{count:>4}{C.RESET} {bar}") |
| print() |
| info(f"Session uptime: {self._uptime()}") |
|
|
| def cmd_new(self, args: str) -> None: |
| game_type = args.strip() |
| if not game_type: |
| err("Usage: /new <type> (e.g. /new minimal, /new fps_arena)") |
| info("Presets: " + ", ".join(PRESETS.keys())) |
| return |
| section(f"NEW PROJECT — {game_type}", color=C.B_GREEN) |
| name = f"Game_{game_type}" |
| info(f"Creating new {game_type} project as '{name}'...") |
| r = run_tool("generate_complete_game", |
| {"game_name": name, "game_type": game_type}, |
| self.settings, self.transport) |
| if r.success: |
| self.current_project = os.path.join(self.settings.output_dir, name) |
| ok(f"Project created at {self.current_project}") |
| self._record_history("system", f"New {game_type} project at {self.current_project}") |
| else: |
| err(f"Failed: {r.message}") |
|
|
| def cmd_add(self, args: str) -> None: |
| spec = args.strip() |
| if not spec: |
| err("Usage: /add <tool> [key=value key=value ...]") |
| info("Example: /add create_script script_name=Enemy") |
| return |
| parts = spec.split() |
| tool_name = parts[0] |
| rest = parts[1:] |
| arguments: Dict[str, Any] = {} |
| for tok in rest: |
| if "=" in tok: |
| k, v = tok.split("=", 1) |
| arguments[k.strip()] = _coerce(v.strip()) |
| else: |
| arguments[tok] = True |
| reg = get_registry() |
| if reg.get(tool_name) is None: |
| err(f"Unknown tool: {tool_name}") |
| info("Use " + C.BOLD + "/tools" + C.RESET + " to list available tools.") |
| return |
| self._invoke_tool(tool_name, arguments) |
|
|
| def cmd_script(self, args: str) -> None: |
| name = args.strip() |
| if not name: |
| err("Usage: /script <Name>") |
| return |
| clean = os.path.basename(name) |
| if clean.endswith(".cs"): |
| clean = clean[:-3] |
| if not clean or not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", clean): |
| err("Invalid script name (must be a valid C# identifier).") |
| return |
| section(f"SCRIPT EDITOR — {clean}.cs", color=C.B_CYAN) |
| |
| existing = "" |
| if self.current_project: |
| cand = os.path.join(self.current_project, "Assets", "Scripts", clean + ".cs") |
| if os.path.exists(cand): |
| try: |
| with open(cand, encoding="utf-8") as fh: |
| existing = fh.read() |
| except OSError: |
| pass |
| if existing: |
| info(f"Existing script found. Re-displaying; type your new content.") |
| print(code_block(existing, "csharp", title=clean + ".cs")) |
| print() |
| info("Paste your C# code. End with a line containing only " + C.BOLD + "EOF" + C.RESET + ".") |
| info("Or type " + C.BOLD + "template" + C.RESET + " to insert a starter, " + C.BOLD + "cancel" + C.RESET + " to abort.") |
| lines: List[str] = [] |
| try: |
| while True: |
| ln = input(f" {C.B_BLUE}{Box.V}{C.RESET} ") |
| if ln.strip() == "EOF": |
| break |
| if ln.strip().lower() == "cancel": |
| warn("Script creation cancelled.") |
| return |
| if ln.strip().lower() == "template" and not lines: |
| lines.extend(_csharp_template(clean).splitlines()) |
| print(code_block("\n".join(lines), "csharp", title=clean + ".cs (template)")) |
| continue |
| lines.append(ln) |
| except (EOFError, KeyboardInterrupt): |
| print() |
| warn("Aborted.") |
| return |
| code = "\n".join(lines) |
| if not code.strip(): |
| warn("Empty script; nothing written.") |
| return |
| print() |
| print(code_block(code, "csharp", title=clean + ".cs")) |
| result = run_tool("create_script", {"script_name": clean, "code": code}, |
| self.settings, self.transport) |
| if result.success: |
| ok(f"Script written: {clean}.cs") |
| if result.files: |
| info(f"Path: {result.files[0]}") |
| else: |
| err(f"Failed to write script: {result.message}") |
|
|
| def cmd_run(self, args: str) -> None: |
| section("RUN IN UNITY", color=C.B_GREEN) |
| target = self.current_project or self.settings.output_dir |
| if not os.path.isdir(target): |
| err(f"No project to run: {target}") |
| return |
| steps = [ |
| f"Open {C.BOLD}Unity Hub{C.RESET}.", |
| f"Click {C.BOLD}Add{C.RESET} → browse to:", |
| f" {C.GREEN}{target}{C.RESET}", |
| f"Select the project, then click {C.BOLD}Open{C.RESET}.", |
| f"Unity will import assets; press {C.BOLD}Play{C.RESET} ▶ to run.", |
| ] |
| if sys.platform.startswith("win"): |
| steps.append(f"Tip: also run {C.CYAN}start \"\" \"{target}\"{C.RESET} to reveal in Explorer.") |
| elif sys.platform == "darwin": |
| steps.append(f"Tip: also run {C.CYAN}open \"{target}\"{C.RESET} to reveal in Finder.") |
| else: |
| steps.append(f"Tip: also run {C.CYAN}xdg-open \"{target}\"{C.RESET} to reveal in your file manager.") |
| print(panel([f"{C.GREEN}{i}.{C.RESET} {s}" for i, s in enumerate(steps, 1)], |
| title="OPEN IN UNITY", border_color=C.B_GREEN, title_color=C.B_GREEN, |
| rounded=True)) |
| |
| if args.strip().lower() in ("open", "reveal", "folder"): |
| try: |
| if sys.platform.startswith("win"): |
| subprocess.Popen(["explorer", target]) |
| elif sys.platform == "darwin": |
| subprocess.Popen(["open", target]) |
| else: |
| subprocess.Popen(["xdg-open", target]) |
| ok("Opened file manager.") |
| except Exception as e: |
| warn(f"Could not open file manager: {e}") |
|
|
| def cmd_exit(self, args: str = "") -> None: |
| self.running = False |
| print() |
| uptime = self._uptime() |
| summary = [ |
| f"{C.BOLD}Session summary{C.RESET}", |
| f"{DIM}Uptime:{C.RESET} {uptime}", |
| f"{DIM}Tool calls:{C.RESET} {self.tool_call_count}", |
| f"{DIM}History entries:{C.RESET} {len(self.history)}", |
| f"{DIM}Project:{C.RESET} {self.current_project or '(none)'}", |
| ] |
| print(panel(summary, title="GOODBYE", border_color=C.B_MAGENTA, |
| title_color=C.B_MAGENTA, rounded=True)) |
| print() |
| print(f" {C.B_MAGENTA}{Box.STAR} Thanks for using Unity Agent. {Box.STAR}{C.RESET}") |
| print() |
|
|
| |
| def _handle_chat(self, user_input: str) -> None: |
| if not self.llm: |
| err("No LLM client configured. Set ZAI_API_KEY or restart and paste a key.") |
| return |
| user_echo(user_input) |
| self.messages.append({"role": "user", "content": user_input}) |
| self._record_history("user", user_input) |
|
|
| spinner = Spinner("Agent is thinking", color=C.MAGENTA, interval=0.08) |
| spinner.start() |
| try: |
| response = self.llm.chat(self.messages, |
| temperature=self.settings.temperature, |
| max_tokens=self.settings.max_tokens) |
| except LLMError as e: |
| spinner.stop() |
| err(f"LLM error: {e}") |
| return |
| except Exception as e: |
| spinner.stop() |
| err(f"Unexpected error: {e}") |
| return |
| spinner.stop() |
|
|
| self.messages.append({"role": "assistant", "content": response}) |
| self._record_history("assistant", response) |
|
|
| |
| tool_calls = parse_all_tool_calls(response) |
| prose = response |
| if tool_calls: |
| prose = _TOOL_CALL_RE.sub("", prose) |
| prose = _TAG_RE.sub("", prose) |
| prose = prose.strip() |
| if prose: |
| for block in _split_paragraphs(prose): |
| self._print_agent_block(block) |
|
|
| |
| for call in tool_calls: |
| self._invoke_tool(call["name"], call["arguments"], from_llm=True) |
|
|
| if "TASK COMPLETE" in response.upper(): |
| print() |
| print(divider("TASK COMPLETE", color=C.B_GREEN)) |
| if self.current_project: |
| info(f"Project ready: {C.GREEN}{self.current_project}{C.RESET}") |
| info("Use " + C.BOLD + "/run" + C.RESET + " for Unity instructions.") |
|
|
| def _print_agent_block(self, block: str) -> None: |
| |
| fence_re = re.compile(r"```(?:csharp|c#|cs)?\s*\n(.*?)```", re.DOTALL | re.IGNORECASE) |
| last = 0 |
| found_code = False |
| for m in fence_re.finditer(block): |
| before = block[last:m.start()].strip() |
| if before: |
| print(f" {C.MAGENTA}{Box.DIAMOND}{C.RESET} {before}") |
| code = m.group(1).rstrip() |
| print() |
| print(code_block(code, "csharp")) |
| print() |
| last = m.end() |
| found_code = True |
| tail = block[last:].strip() |
| if tail: |
| for ln in tail.splitlines(): |
| print(f" {C.MAGENTA}{Box.DIAMOND}{C.RESET} {C.MAGENTA}{ln}{C.RESET}") |
| if not found_code and not tail: |
| for ln in block.splitlines(): |
| print(f" {C.MAGENTA}{Box.DIAMOND}{C.RESET} {C.MAGENTA}{ln}{C.RESET}") |
|
|
| def _invoke_tool(self, name: str, arguments: Dict[str, Any], from_llm: bool = False) -> None: |
| self.tool_call_count += 1 |
| arg_preview = json.dumps(arguments, default=str) |
| if len(arg_preview) > 140: |
| arg_preview = arg_preview[:137] + "..." |
| print() |
| print(f" {C.BOLD}{C.BLUE}{Box.ARROW}{C.RESET} {C.BOLD}{C.B_CYAN}tool_call{C.RESET} " |
| f"{C.WHITE}{name}{C.RESET}{DIM}({arg_preview}){C.RESET}") |
| spinner = Spinner(f"Running {name}", color=C.BLUE, interval=0.07) |
| spinner.start() |
| time.sleep(0.25) |
| result = run_tool(name, arguments, self.settings, self.transport) |
| spinner.stop() |
|
|
| if result.success: |
| ok(result.message[:300]) |
| if result.files: |
| for f in result.files[:8]: |
| print(f" {DIM}{Box.BULLET}{C.RESET} {C.GREEN}{f}{C.RESET}") |
| if len(result.files) > 8: |
| print(f" {DIM}... and {len(result.files) - 8} more{C.RESET}") |
| else: |
| err(result.message[:300]) |
| if result.error: |
| print(f" {C.RED}{result.error}{C.RESET}") |
|
|
| |
| feedback = (f"Tool '{name}' result: success={result.success}, " |
| f"message={result.message}") |
| if result.files: |
| feedback += f", files={result.files}" |
| if result.error: |
| feedback += f", error={result.error}" |
| self.messages.append({"role": "user", "content": feedback}) |
| self._record_history("tool", feedback) |
|
|
| |
| if name in ("generate_complete_game", "setup_unity_project") and result.success: |
| if result.files: |
| |
| for f in result.files: |
| if os.path.isdir(f): |
| self.current_project = f |
| break |
| else: |
| self.current_project = self.settings.output_dir |
|
|
| |
| def _record_history(self, role: str, content: str) -> None: |
| self.history.append({ |
| "role": role, |
| "content": content, |
| "ts": datetime.now().strftime("%H:%M:%S"), |
| }) |
| |
| try: |
| path = os.path.join(os.getcwd(), self.HISTORY_FILE) |
| keep = self.history[-200:] |
| with open(path, "w", encoding="utf-8") as fh: |
| json.dump(keep, fh, indent=2, ensure_ascii=False) |
| except OSError: |
| pass |
|
|
| def _uptime(self) -> str: |
| secs = int(time.time() - self.start_time) |
| h, rem = divmod(secs, 3600) |
| m, s = divmod(rem, 60) |
| if h: |
| return f"{h}h {m}m {s}s" |
| if m: |
| return f"{m}m {s}s" |
| return f"{s}s" |
|
|
|
|
| |
| |
| |
| def _split_paragraphs(text: str) -> List[str]: |
| """Split prose into paragraphs (double-newline separated).""" |
| parts = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()] |
| return parts or [text.strip()] |
|
|
|
|
| def _coerce(value: str) -> Any: |
| """Best-effort coerce a string token to int/float/bool/JSON/str.""" |
| if value.lower() in ("true", "false"): |
| return value.lower() == "true" |
| if value.lower() in ("null", "none"): |
| return None |
| try: |
| return int(value) |
| except ValueError: |
| pass |
| try: |
| return float(value) |
| except ValueError: |
| pass |
| if (value.startswith("{") and value.endswith("}")) or (value.startswith("[") and value.endswith("]")): |
| try: |
| return json.loads(value) |
| except json.JSONDecodeError: |
| pass |
| return value |
|
|
|
|
| _DEFAULT_SYSTEM_PROMPT = """\ |
| You are Unity Agent, an expert Unity C# game developer assistant. |
| You help the user design, scaffold, and write code for Unity games. |
| |
| Always: |
| - Be concise but complete. |
| - When a tool is needed, emit a ```tool_call``` block with JSON {"name": ..., "arguments": {...}}. |
| - After calling a tool, briefly summarize what happened. |
| - Prefer the existing tools over guessing file paths. |
| - When the task is fully done, include the literal text TASK COMPLETE. |
| """ |
|
|
|
|
| def interactive_repl() -> None: |
| """Entry point — create a Settings + CLI and run the REPL.""" |
| settings = Settings() |
| cli = UnityCLI(settings) |
| cli.run() |
|
|
|
|
| def main(argv: Optional[List[str]] = None) -> int: |
| """Command-line entry point with a few non-interactive shortcuts.""" |
| argv = list(sys.argv[1:] if argv is None else argv) |
| if not argv: |
| interactive_repl() |
| return 0 |
| cmd = argv[0].lower() |
| settings = Settings() |
| cli = UnityCLI(settings) |
| if cmd in ("--help", "-h", "help"): |
| print_banner() |
| cli.cmd_help("") |
| return 0 |
| if cmd in ("tools", "list-tools"): |
| cli.cmd_tools("") |
| return 0 |
| if cmd in ("presets", "list-presets"): |
| cli.cmd_presets("") |
| return 0 |
| if cmd == "generate": |
| preset = argv[1] if len(argv) > 1 else "" |
| cli.cmd_generate(preset) |
| return 0 |
| |
| err(f"Unknown command: {cmd}") |
| info("Run with no arguments to start the interactive REPL.") |
| return 1 |
|
|
|
|
| if __name__ == "__main__": |
| sys.exit(main()) |
|
|