diff --git a/.gitignore b/.gitignore index 249f8bd..fccfc26 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ __pycache__/ .pkg-cache.json .catalog-cache.json skills/unity-cli-command/dynamic-commands.md +skills/*-workspace/ docs tests/ cc-codex-discussion-history/ diff --git a/AGENTS.md b/AGENTS.md index d8c0733..01b0438 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,8 +50,17 @@ cache the resolved package lazily, so setup is a convenience rather than a gate. ### Command-first principle Prefer `cs command` whenever a built-in framework command covers the task. Use -`cs list-commands --json` to discover commands, then check the snippet library -(`cs snippets`) before falling back to ad-hoc `cs exec`. +`cs list-commands --offline --domain --tier core --json` to discover a +small committed contract set without connecting to Unity. Query the matching +advanced tier only when needed; remove `--offline` to verify the installed +package's live registry. Then check the snippet library (`cs snippets`) before +falling back to ad-hoc `cs exec`. + +Recognized built-in requests are preflighted from the committed manifest before +HTTP dispatch. Unknown arguments, missing requirements, invalid types/ranges, +ambiguous selectors, empty mutations, blocked actions, and missing explicit +session ids fail without executing Unity. Project-defined custom commands pass +through because their contracts are project-specific. ### Command map @@ -64,7 +73,8 @@ Prefer `cs command` whenever a built-in framework command covers the task. Use | `cs batch --input FILE --json` | post | Run multiple commands in one request | | `cs health` | post | Check service health | | `cs refresh [--wait TIMEOUT] [--exit-playmode]` | post | Refresh assets and compile | -| `cs list-commands --json` | post | Discover available commands | +| `cs list-commands --offline … --json` | pre/post | Discover committed contracts by domain/tier/id | +| `cs list-commands … --json` | post | Inspect the installed package's live registry | | `cs catalog sync` / `cs catalog list` | post | Maintain the custom-command catalog | | `cs snippets …` | post | Browse, run, and maintain reusable snippets | @@ -85,8 +95,10 @@ package so client and service versions stay aligned: 2. `Library/PackageCache/com.zh1zh1.csharpconsole@*/Editor/ExternalTool~/console-client/`. `ConsoleSession` wires the core client, command protocol, configuration, output, -response parser, and HTTP transport into the CLI operations. Transport failures are -retried once after one second to tolerate Unity domain reloads. +response parser, and HTTP transport into the CLI operations. Clearly refused +connections are retried once after one second to tolerate Unity domain reloads. +Timeouts, HTTP errors, and connection resets are not retried because a mutation may +already have executed. ### Version and machine-local state @@ -108,6 +120,8 @@ retried once after one second to tolerate Unity domain reloads. skills/unity-cli/SKILL.md Skill entry and routing skills/unity-cli/references/*.md Topic-specific operating guidance skills/unity-cli/scripts/cli/cs.py CLI dispatcher +skills/unity-cli/scripts/cli/command_index.py +skills/unity-cli/scripts/cli/command_manifest.json skills/unity-cli/scripts/cli/core_bridge.py skills/unity-cli/scripts/cli/paths.py Per-project cache paths skills/unity-cli/scripts/cli/VERSION Release version diff --git a/README.md b/README.md index 2267363..5d72109 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,10 @@ **Powered by [unity-csharpconsole](https://github.com/niqibiao/unity-csharpconsole)** [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE) -[![Unity](https://img.shields.io/badge/Unity-2022.3%2B-black.svg?logo=unity)](https://unity.com/) +[![Unity](https://img.shields.io/badge/Unity-2022-black.svg?logo=unity)](https://unity.com/) [![Claude Code](https://img.shields.io/badge/Claude_Code-blueviolet.svg?logo=anthropic)](https://claude.ai/code) -40+ commands for scene editing, components, assets, screenshots, profiling, and more.
+59 built-in command contracts; 57 are routable for scene editing, components, assets, screenshots, profiling, and more.
Depends on **[unity-csharpconsole](https://github.com/niqibiao/unity-csharpconsole)** — a Roslyn-powered interactive C# REPL for Unity. [Quick Start](#-quick-start) · [Usage](#-usage) · [Commands](#-commands) · [Custom Commands](#-custom-commands) · [Architecture](#️-architecture) @@ -57,7 +57,7 @@ npx skills add niqibiao/unity-cli-skill --copy In your AI agent, run **`unity-cli setup`**. -**Prerequisites:** a skills-compatible agent (e.g. [Claude Code](https://claude.ai/code) or [Codex CLI](https://github.com/openai/codex) 0.139+), Node.js (for `npx`), Unity 2022.3+, Python 3.7+ +**Prerequisites:** a skills-compatible agent (e.g. [Claude Code](https://claude.ai/code) or [Codex CLI](https://github.com/openai/codex) 0.139+), Node.js (for `npx`), Unity 2022, Python 3.7+ ### 💬 Usage @@ -91,7 +91,10 @@ operation, and the agent triggers it automatically (in any skills-compatible age ### 📦 Commands -50 built-in commands across 13 namespaces. All commands support `--json` output. +59 built-in contracts across 13 wire namespaces. 57 are routable by default; +`editor/menu.open` and `editor/window.open` are retained but blocked because their +noninteractive UI effects cannot be verified reliably. Structured command results +are available through `cs command --json`. #### gameobject @@ -143,6 +146,9 @@ operation, and the agent triggers it automatically (in any skills-compatible age | `create` | Create a prefab asset from a scene GameObject | | `instantiate` | Instantiate a prefab into the active scene | | `unpack` | Unpack a prefab instance | +| `asset_get` / `asset_hierarchy` | Inspect GameObjects and hierarchy inside a prefab asset | +| `asset_add_component` / `asset_get_component` / `asset_modify_component` / `asset_remove_component` | Read and edit components directly in a prefab asset | +| `asset_add_gameobject` / `asset_modify_gameobject` / `asset_remove_gameobject` | Add, edit, or remove GameObjects directly in a prefab asset | #### material @@ -184,8 +190,8 @@ operation, and the agent triggers it automatically (in any skills-compatible age | `playmode.status` | Get current play mode state | | `playmode.enter` | Enter play mode | | `playmode.exit` | Exit play mode | -| `menu.open` | Execute a menu item by path | -| `window.open` | Open an editor window by type name | +| `menu.open` | Retained contract; blocked for agent execution (noninteractive UI) | +| `window.open` | Retained contract; blocked for agent execution (noninteractive UI) | | `console.clear` | Clear the editor console | | `console.mark` | Write a searchable marker to the editor log | @@ -269,9 +275,9 @@ AI Agent Unity Editor │ │ │ │ └─ REPL Executor │ │ │ Python CLI │ │ └────────────────────┘ │ │ ┌────────────┐ │ │ │ -│ │ cs.py │ │ │ 40+ CommandActions │ +│ │ cs.py │ │ │ 59 command contracts │ │ │ core_bridge│ │ │ (GameObject, Component, │ -│ └────────────┘ │ │ Prefab, Material, ...) │ +│ └────────────┘ │ │ 57 routable by agents) │ └──────────────────┘ └──────────────────────────┘ ``` @@ -301,4 +307,4 @@ Auto-detects project root and service port. No manual configuration. --- -If this skill saves you time, consider giving it a star. It helps others find it. \ No newline at end of file +If this skill saves you time, consider giving it a star. It helps others find it. diff --git a/README_zh.md b/README_zh.md index f2c9a07..2045550 100644 --- a/README_zh.md +++ b/README_zh.md @@ -6,10 +6,10 @@ **基于 [unity-csharpconsole](https://github.com/niqibiao/unity-csharpconsole)** [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE) -[![Unity](https://img.shields.io/badge/Unity-2022.3%2B-black.svg?logo=unity)](https://unity.com/) +[![Unity](https://img.shields.io/badge/Unity-2022-black.svg?logo=unity)](https://unity.com/) [![Claude Code](https://img.shields.io/badge/Claude_Code-blueviolet.svg?logo=anthropic)](https://claude.ai/code) -40+ 命令覆盖场景编辑、组件、资产、截图、性能分析等。
+内置 59 份命令契约,其中 57 份可供 Agent 路由,覆盖场景编辑、组件、资产、截图、性能分析等。
依赖 **[unity-csharpconsole](https://github.com/niqibiao/unity-csharpconsole)** — 基于 Roslyn 的 Unity 交互式 C# REPL。 [快速开始](#-快速开始) · [使用方式](#-使用方式) · [命令](#-命令) · [自定义命令](#-自定义命令) · [架构](#️-架构) @@ -56,7 +56,7 @@ npx skills add niqibiao/unity-cli-skill --copy 在 AI Agent 里运行 **`unity-cli setup`**。 -**前置条件:** 一个兼容 skills 的 Agent(如 [Claude Code](https://claude.ai/code) 或 [Codex CLI](https://github.com/openai/codex) 0.139+)、Node.js(用于 `npx`)、Unity 2022.3+、Python 3.7+ +**前置条件:** 一个兼容 skills 的 Agent(如 [Claude Code](https://claude.ai/code) 或 [Codex CLI](https://github.com/openai/codex) 0.139+)、Node.js(用于 `npx`)、Unity 2022、Python 3.7+ ### 💬 使用方式 @@ -90,7 +90,9 @@ Claude 会自动选择合适的命令,或在需要时编写 C# 代码。 ### 📦 命令 -13 个命名空间、50 个内置命令。所有命令支持 `--json` 输出。 +13 个协议命名空间、59 份内置命令契约。其中 57 份默认可路由; +`editor/menu.open` 与 `editor/window.open` 因非交互式 UI 效果无法可靠验证而保留但禁用。 +结构化命令结果通过 `cs command --json` 返回。 #### gameobject @@ -142,6 +144,9 @@ Claude 会自动选择合适的命令,或在需要时编写 C# 代码。 | `create` | 从场景中的 GameObject 创建 Prefab 资产 | | `instantiate` | 将 Prefab 实例化到当前场景 | | `unpack` | 解包 Prefab 实例 | +| `asset_get` / `asset_hierarchy` | 检查 Prefab 资产内的 GameObject 与层级 | +| `asset_add_component` / `asset_get_component` / `asset_modify_component` / `asset_remove_component` | 直接读取和编辑 Prefab 资产中的组件 | +| `asset_add_gameobject` / `asset_modify_gameobject` / `asset_remove_gameobject` | 直接添加、编辑或移除 Prefab 资产中的 GameObject | #### material @@ -183,8 +188,8 @@ Claude 会自动选择合适的命令,或在需要时编写 C# 代码。 | `playmode.status` | 获取当前 Play Mode 状态 | | `playmode.enter` | 进入 Play Mode | | `playmode.exit` | 退出 Play Mode | -| `menu.open` | 按路径执行菜单项 | -| `window.open` | 按类型名打开编辑器窗口 | +| `menu.open` | 保留契约;Agent 执行已禁用(非交互式 UI) | +| `window.open` | 保留契约;Agent 执行已禁用(非交互式 UI) | | `console.clear` | 清空编辑器控制台 | | `console.mark` | 向编辑器日志写入可搜索标记 | @@ -268,9 +273,9 @@ AI Agent Unity Editor │ │ │ │ └─ REPL 执行器 │ │ │ Python CLI │ │ └────────────────────┘ │ │ ┌────────────┐ │ │ │ -│ │ cs.py │ │ │ 40+ CommandActions │ +│ │ cs.py │ │ │ 59 command contracts │ │ │ core_bridge│ │ │ (GameObject, Component, │ -│ └────────────┘ │ │ Prefab, Material, ...) │ +│ └────────────┘ │ │ Agent 可路由 57 份) │ └──────────────────┘ └──────────────────────────┘ ``` @@ -300,4 +305,4 @@ AI Agent Unity Editor --- -如果这个 skill 对你有帮助,请给个 Star,让更多人发现它。 \ No newline at end of file +如果这个 skill 对你有帮助,请给个 Star,让更多人发现它。 diff --git a/skills/unity-cli/SKILL.md b/skills/unity-cli/SKILL.md index 8e3844b..bcddd20 100644 --- a/skills/unity-cli/SKILL.md +++ b/skills/unity-cli/SKILL.md @@ -1,124 +1,140 @@ --- name: unity-cli description: > - Drive the Unity Editor/Player from the command line via the C# Console service. - Use for ANY Unity Editor automation: GameObject / component / transform / scene / - prefab / material / asset / screenshot / profiler commands; executing raw C# in the - live Editor; refreshing / recompiling; querying connection & editor status; managing - a reusable C# snippet library; and syncing the custom-command catalog. Triggers on - Unity editor tasks, "run C#" / "exec" / "eval" in Unity, create / modify / find - GameObjects, screenshots, play mode, profiling, or "set up / 安装 unity-cli". + Drive a live Unity Editor or Player with unity-cli. Use when a task must inspect + or change Unity scenes, GameObjects, components, transforms, prefabs, materials, + project assets, play mode, screenshots, profiler recording, or execute C# inside + Unity; also use for Unity console maintenance and unity-cli setup, status, + refresh, snippets, or custom commands. Do not use for source-only Unity coding + that does not require interaction with the live Editor or Player. --- # Unity CLI -One CLI (`cs`) drives everything; subcommands cover all operations. -Decision order for any task: **built-in command → snippet → raw exec**. +One CLI (`cs`) drives the Unity Editor/Player. Built-in commands are grouped into +seven agent-facing domains so only the relevant schema enters context. ## Running `cs` -`cs` below = `python "/scripts/cli/cs.py"`, where `` is THIS -skill's base directory (shown when the skill loads — an absolute path). Expand `cs` -to that full command on every call. **Do not pass -`--project`** — the CLI auto-detects the Unity project (it walks up from the working -directory, and from its own committed location). Prefix with -`PYTHONDONTWRITEBYTECODE=1` so running the CLI leaves no `__pycache__` in the project: +`cs` below means: ```bash -PYTHONDONTWRITEBYTECODE=1 python "/scripts/cli/cs.py" command --json --input "/Temp/CSharpConsole/AgentScratch/req.json" +python "/scripts/cli/cs.py" ``` -No bootstrap/copy step — the CLI runs in place from this skill. First-time use needs the -**Unity package** in the project: `cs setup` installs it (see `references/setup.md`), and -`cs status` reports `NOT FOUND` until Unity resolves it. The first command auto-caches the -resolved package path (machine-local, under your home cache), so an explicit `cs setup` is -a convenience, not a gate. **`cs setup` writes the project's `Packages/manifest.json` — a -shared project file. Never run it (or `--update`) unprompted: tell the user what it will -write and get their go-ahead first.** - -### Passing parameters — C# via `--file`, JSON via `--input` (never inline) - -Never pass C# code or params as inline shell arguments — write a file with your file -tool, then hand the CLI the path. Two channels: - -- **C# code → `--file .cs`** (raw C#, zero escaping). Always use this for - `exec` — never wrap code in a JSON `{"code": …}` payload, where every quote / - backslash / newline must be JSON-escaped (a raw `.cs` file needs none). -- **Structured params → `--input ` JSON** (or `-` for stdin) for `command` / - `batch`. - -**Scratch file location (mandatory):** the absolute path -`/Temp/CSharpConsole/AgentScratch/` — inside the Unity project's own -`Temp/` (Unity-managed, never imported, normally git-ignored; the C# Console service -already keeps its state under `Temp/CSharpConsole/`). Always write the **full -absolute path** — your file tool resolves relative paths against its own cwd, not -the project. Name files by semantic task (`inspect-camera.cs`, -`req-create-cube.json`): overwrite the same file when revising the same task -serially; add a one-shot random suffix only when another agent is known to work the -same project concurrently. The directory lives and dies with the Unity Editor -(closing it may delete `Temp/`) — scratch files are one-shot execution payloads, -never durable storage; anything worth reusing across conversations goes into the -snippet library. Clean only `AgentScratch/`; never delete `Temp/CSharpConsole/` -itself (the service's `refresh_state.json` lives there). **Never write scratch -files under `Assets/`**: typical REPL snippets are not valid standalone project -sources — the import very likely fails project compilation, blocking -refresh/domain-reload workflows, and after an editor restart the console service -may not start at all. +`` is this skill's absolute base directory, supplied when the skill +loads. Expand `cs` to that absolute command on every call and run it without +changing directory. + +Do not pass `--project` during normal use. The CLI locates the Unity project by +walking up from both the working directory and its own committed location. +`--project ` is only for deliberately targeting a different project. + +The CLI runs in place; there is no bootstrap or copy step. `cs setup` is a +convenience, not a gate. If the package is absent or `--update` is used, setup +writes the shared `Packages/manifest.json`. Before any write-producing setup, +state the exact source it will add and obtain the user's approval. See +`references/setup.md`. + +## Passing inputs + +Never pass C# code or structured command parameters inline through the shell. +Write a file, then pass its absolute path: + +- Raw C# for `exec` goes in a `.cs` file via `--file`. +- Structured parameters for `command` and `batch` go in a JSON file via + `--input`. + +The mandatory scratch directory is: + +```text +/Temp/CSharpConsole/AgentScratch/ +``` + +This is inside Unity's managed `Temp/`, is not imported, and is normally ignored +by git. Always use the full absolute path. Name files for the semantic task and +overwrite the same file when revising it serially; use a random suffix only when +another agent is known to work on the same Unity project concurrently. + +Scratch payloads are one-shot. Put reusable C# in the snippet library. Clean only +`AgentScratch/`; never delete `Temp/CSharpConsole/`, which contains service state. +Never write REPL payloads under `Assets/`: importing them can break project +compilation and prevent the service from restarting. ```bash -cs exec --file "/Temp/CSharpConsole/AgentScratch/inspect-camera.cs" -cs command --json --input "/Temp/CSharpConsole/AgentScratch/req-create-cube.json" # {"ns":"gameobject","action":"create","args":{"name":"Cube"}} -cs batch --json --input "/Temp/CSharpConsole/AgentScratch/req-batch.json" # {"commands":[ … ],"stopOnError":true} +cs exec --file "/Temp/CSharpConsole/AgentScratch/inspect-camera.cs" +cs command --json --input "/Temp/CSharpConsole/AgentScratch/req-create-cube.json" +cs batch --json --input "/Temp/CSharpConsole/AgentScratch/req-batch.json" ``` -### REPL context — share only when needed +## REPL sessions -`cs exec` starts a fresh REPL session by default. Keep that default for self-contained -one-shot code. When a later submission intentionally depends on variables, `using`s, -types, or helpers created by an earlier submission, generate one opaque session id -for that task and pass the exact same `--session ` on every dependent call: +`cs exec` starts a fresh session by default. Keep that default for self-contained +code. When later submissions intentionally depend on variables, `using`s, types, +or helpers created earlier, generate one opaque task-specific id and reuse it: ```bash cs exec --session agent-a1b2c3 --file "/Temp/CSharpConsole/AgentScratch/inspect-camera.cs" cs exec --session agent-a1b2c3 --file "/Temp/CSharpConsole/AgentScratch/use-camera.cs" ``` -Do not attach a shared session to unrelated work. Different agents/tasks must use -different ids. Named session state is ephemeral: a domain reload clears it, so after -`cs refresh` either rebuild the context with self-contained code or start a new id. -Use a new id whenever a clean context is simpler than resetting the old one. See -`references/exec-code.md` for reset and lifecycle details. +Never share a session between unrelated tasks or agents. A domain reload clears +session state, so after `cs refresh` rebuild the context or use a new id. See +`references/exec-code.md` for lifecycle and reset details. + +## Routing + +Use this canonical order for every task: **built-in command → cached custom command → +snippet → raw exec**. Do not skip a matching built-in in favor of C#. + +For a structured Unity operation: + +1. Choose one domain from the index below. +2. Read `references/commands.md`. +3. Discover only that domain's committed core schema with + `cs list-commands --offline --domain --tier core --json`. Offline + discovery needs neither a Unity project nor a running service. +4. Query `advanced` only when the requested operation is specialized, + destructive, or absent from core. Keep `--offline`; use + `--id ` when checking one exact contract. Remove + `--offline` only when verifying what the currently installed package actually + registers. +5. Write the selected `ns`, `action`, and `args` to the mandatory scratch JSON + file, then run `cs command --json --input `. + +Do not load or print the unfiltered 59-command registry during routine work. + +| Domain | Positive intent | Exclude / route elsewhere | +|---|---|---| +| `editor` | Editor state, play mode, console maintenance | Scene contents, object selection, asset files | +| `scene` | Scene listing/open/save and hierarchy | GameObject properties, prefab contents | +| `objects` | GameObjects, object selection, components, transforms | Project asset movement, prefab-file editing | +| `assets` | Asset search/import/CRUD, materials, and material assignment | Scene hierarchy, prefab contents | +| `prefabs` | Create/instantiate/unpack or directly edit prefab contents | Generic asset paths unrelated to prefab contents | +| `capture` | Scene/Game screenshots and Profiler recording | Structured state inspection | +| `control` | REPL sessions and command discovery | Normal Unity authoring | -## Routing — pick the subcommand +### Other subcommands | Task | Subcommand | Detail | -|------|------------|--------| -| Structured editor ops (GameObject/component/scene/prefab/asset/material/screenshot/profiler) | `cs command --input` | references/commands.md | -| Raw C# in the live Editor (fallback) | `cs exec` | references/exec-code.md | -| Reusable C# snippet library | `cs snippets …` | references/snippets.md | -| Audit / validate snippets | `cs snippets doctor` / `stats` | references/snippets-audit.md | -| Refresh / recompile after writing .cs | `cs refresh` | references/refresh.md | -| Sync custom-command catalog / maintainer audit | `cs catalog sync` / `cs list-commands` | references/catalog.md | -| Connection / editor status | `cs status` / `cs health` | references/status.md | -| First-time package setup / version-check | `cs setup` | references/setup.md | - -## Conventions (all subcommands) - -- `--json` only where the payload comes back as structured data: **`command`, - `list-commands`, `batch`** — envelope - `{ "ok", "exitCode", "summary", "data" }`, check `ok` / `exitCode`. Every other - subcommand (`exec`, `status`, `refresh`, `health`, `setup`, `catalog`, - `snippets`) prints an equivalent, cheaper text form — omit `--json`; success = - exit code 0, errors go to stderr. Add `--json` to `exec` only when you need the - structured result envelope instead of the REPL's text output. -- **Never pass `--project`** — the CLI auto-detects the project. Pass `--project ` - only to deliberately target a different project. -- Prefer `cs command` over `cs exec` when a built-in covers the task; check the snippet - library (`cs snippets search `) before falling back to ad-hoc `cs exec`. -- A `⚠ version mismatch` warning means the installed Unity package and the CLI are on - different `major.minor` lines — align them (see references/setup.md). It warns; it - does not block. -- `--json` and the expanded `python … cs.py …` command line are **agent-internal** — - for you to run and parse, never to paste to the user. When a step needs the user to - act (e.g. open Unity to resolve the package) and then re-verify, ask them in plain - language to **check unity-cli status**; run `cs status` yourself to read the result. +|---|---|---| +| Raw C# fallback | `cs exec --file` | `references/exec-code.md` | +| Reusable C# | `cs snippets …` | `references/snippets.md` | +| Snippet audit | `cs snippets doctor` / `stats` | `references/snippets-audit.md` | +| Refresh and compile | `cs refresh` | `references/refresh.md` | +| Custom-command catalog | `cs catalog sync` / `list` | `references/catalog.md` | +| Package / connection state | `cs status` / `cs health` | `references/status.md` | +| Package setup | `cs setup` | `references/setup.md` | + +## Output conventions + +- Use `--json` on `command`, `list-commands`, and `batch`. Their envelope is + `{ "ok", "exitCode", "summary", "data" }`; check `ok` and `exitCode`. +- Other subcommands print a cheaper text form. Add `--json` to `exec` only when + its structured envelope is specifically needed. +- A version-mismatch warning means the installed package and CLI use different + `major.minor` lines. Follow `references/setup.md`; the warning does not itself + block execution. +- Expanded CLI commands and JSON payloads are agent-internal. When Unity must be + opened or focused, tell the user what action is needed in plain language, then + run `cs status` yourself to verify the result. diff --git a/skills/unity-cli/references/catalog.md b/skills/unity-cli/references/catalog.md index b829cd4..4918fdf 100644 --- a/skills/unity-cli/references/catalog.md +++ b/skills/unity-cli/references/catalog.md @@ -1,6 +1,8 @@ # Unity CLI Catalog -Two operations over the custom-command catalog, both via `cs`. +Two operations over the custom-command catalog, both via `cs`. The catalog is +custom-command state only. Consult it at the custom-command stage defined in +`SKILL.md`; built-in discovery belongs to `references/commands.md`. ## Sync the per-project custom command catalog @@ -26,22 +28,23 @@ cs catalog list If sync fails, check that the Unity Editor is open and the C# Console package is installed. -## Maintainer audit: built-in tables vs the live Editor +## Maintainer audit: static contracts vs the live Editor -**Audience: skill maintainers.** Check whether the static built-in command tables in -`references/commands.md` have drifted from the commands registered in the running -Editor (new actions added upstream, removed actions, changed signatures). This does -**not** touch the per-project custom-command catalog. +**Audience: skill maintainers.** Check whether the canonical static contracts in +`scripts/cli/command_manifest.json` have drifted from commands registered in the +running Editor. This does **not** touch the per-project custom-command catalog. 1. Fetch the live command list: ```bash cs list-commands --json ``` 2. Parse `data.commands` (built-in + custom). -3. Compare with the static tables in `references/commands.md`. Built-in namespaces: - editor, gameobject, component, transform, material, prefab, project, scene, - screenshot, profiler, session, command. -4. Report differences and suggest edits to `references/commands.md`: - - **New** commands not in the tables → add them - - **Removed** commands in the tables but not live → remove them - - **Changed signatures** (different args) → update them +3. Compare live built-ins with the 59 manifest contracts, including the 57 + routable contracts and the retained blocked `editor/menu.open` and + `editor/window.open` entries. +4. Report differences and suggest manifest updates: + - **New** live commands missing from the manifest; + - **Removed** live commands still present in the manifest; + - **Changed signatures** whose live args differ from the static contract; + - **Unclassified** contracts missing domain, tier, intent boundary, or + verification metadata. diff --git a/skills/unity-cli/references/commands.md b/skills/unity-cli/references/commands.md index e73d914..7d8290d 100644 --- a/skills/unity-cli/references/commands.md +++ b/skills/unity-cli/references/commands.md @@ -1,244 +1,183 @@ -# Unity CLI Command +# Unity CLI Commands -Run framework commands in the Unity Editor via the C# Console command protocol. +Use the framework command protocol for bounded, structured Unity operations. The +canonical routing order is defined once in `SKILL.md`; this reference covers +domain discovery, request contracts, and result verification. -## Command-First Principle +## Static contract and live schema -Always prefer `cs command` over `cs exec` when a built-in framework command exists. Only fall back to `cs exec` for ad-hoc C# that no existing command covers. +`scripts/cli/command_manifest.json` is the canonical static contract for built-in +routing. It records each command's domain, visibility tier, availability, argument +rules, intent boundaries, and verification guidance. The live Unity registry +remains the execution authority and supplies the installed package's schema. -If no built-in or custom command matches the task, **next** check `cs snippets` (run `cs snippets search `) before falling back to ad-hoc `cs exec` via `cs exec`. The decision order is: command → snippet → ad-hoc. +The manifest retains **59** built-in contracts: -## Usage +- **57 routable contracts** are returned by default discovery. +- `editor/menu.open` and `editor/window.open` are retained for compatibility and + audit, but are blocked because they require noninteractive UI behavior that + cannot be verified reliably. Discovery hides them unless `--include-blocked` is + requested; command preflight rejects their execution. -Write the request to a JSON file in the scratch dir (absolute path -`/Temp/CSharpConsole/AgentScratch/` — see SKILL.md "Passing -parameters"), then: +Do not copy the full registry into agent context. Narrow discovery by domain and +tier, then inspect one exact contract when needed. -```bash -cs command --json --input "/Temp/CSharpConsole/AgentScratch/req.json" -``` +## Domain boundaries -`req.json` is a single object — `{"ns": "", "action": "", "args": { … }}` -(`args` omitted for no-arg commands). `--input -` reads the JSON from stdin instead. +| Domain | Use for | Do not use for | +|---|---|---| +| `editor` | Editor state, play mode, console maintenance | Scene contents, object selection, or project asset files | +| `scene` | Scene listing/open/save and hierarchy | GameObject properties or prefab-file contents | +| `objects` | GameObjects, object selection, components, transforms | Asset movement or direct prefab-file editing | +| `assets` | Asset search/import/CRUD, materials, and material assignment | Scene hierarchy or prefab contents | +| `prefabs` | Prefab creation, instantiation, unpacking, direct content editing | Generic asset operations unrelated to prefab contents | +| `capture` | Scene/Game screenshots and Profiler recording | Structured state inspection | +| `control` | REPL sessions and command discovery | Routine Unity authoring | -## Argument & Result Conventions +The agent-facing domain is metadata; it does not rename the wire protocol. +For example, scene and asset operations may still use the `project` namespace. +Always send the exact `ns` and `action` returned by discovery. -- Always pass `--json` on `command` / `list-commands` / `batch` — their result payload is only emitted as JSON. The envelope is `{ "ok": bool, "exitCode": int, "summary": str, "data": {...} }` — check `ok` / `exitCode` for success. (Other subcommands print an equivalent text form; leave `--json` off there.) -- `data` is already structured. For `cs command`, it's the command's own result object; for `cs list-commands`, it's `{ "commands": [...] }`. Do not expect or re-parse a `resultJson` string field — that only appears with `--verbose`. -- `Vector3` args are JSON objects: `{"x":0,"y":1,"z":3}`. Same for `rotation` and `scale`. -- Array args (e.g. `instanceIds: int[]`, `assetPaths: string[]`) are JSON arrays. -- `bool` args accept `true` / `false`; some legacy fields (`active`, `isStatic`) take `int` (0/1) — see the per-action signature. +## Visibility tiers -## Asset Refresh +- **`core`** — common, bounded, unambiguous operations. Search this tier first. +- **`advanced`** — specialized, destructive, schema-heavy, or lower-frequency + operations. Search it only after the chosen domain has no core match or the + user's intent explicitly requires it. +- **`control-plane`** — sessions and command discovery. Do not mix these with + normal Unity authoring candidates. -After writing `.cs` files or modifying assets on disk, trigger a refresh so Unity recompiles. `cs refresh` wraps the full procedure (play-mode check, exit if needed, refresh, wait). For direct CLI use: +Tier changes discovery visibility, not protocol availability. Existing wire ids +remain compatible. -```bash -cs refresh --wait --exit-playmode -``` +## Progressive discovery -REPL sessions are cleared on domain reload. - -## Identifier Convention - -Many commands accept both `path` (hierarchy path like `"Canvas/Button"`) and `instanceId` (int). Use whichever is available — `path` for human-readable references, `instanceId` when you have it from a prior command result. You never need both. - -## Built-in Command Catalog - -### editor - -| action | summary | args | -|--------|---------|------| -| status | Get editor state and play mode info | — | -| playmode.status | Get current play mode state | — | -| playmode.enter | Enter play mode | — | -| playmode.exit | Exit play mode | — | -| menu.open | Open a menu item by path | menuPath: string | -| window.open | Open an editor window by type name | typeName: string, utility: bool | -| console.clear | Clear the editor console | — | -| console.mark | Write a searchable marker into the editor log and return the log file path | label: string | - -### gameobject - -| action | summary | args | -|--------|---------|------| -| find | Find GameObjects by name, tag, or component type | name: string, tag: string, componentType: string | -| create | Create a new GameObject (empty or primitive) | name: string, primitiveType: string, parentPath: string | -| destroy | Destroy a GameObject | path: string, instanceId: int | -| get | Get detailed info about a GameObject | path: string, instanceId: int | -| modify | Modify a GameObject's basic properties | path: string, instanceId: int, name: string, tag: string, layer: int, active: int, isStatic: int | -| set_parent | Change a GameObject's parent | path: string, instanceId: int, parentPath: string, parentInstanceId: int, worldPositionStays: bool | -| duplicate | Duplicate a GameObject | path: string, instanceId: int, newName: string | - -### component - -| action | summary | args | -|--------|---------|------| -| add | Add a component to a GameObject | typeName: string, gameObjectPath: string, gameObjectInstanceId: int | -| remove | Remove a component from a GameObject | typeName: string, gameObjectPath: string, gameObjectInstanceId: int, index: int | -| get | Get serialized field data of a component | typeName: string, gameObjectPath: string, gameObjectInstanceId: int, index: int | -| modify | Modify serialized fields of a component | fields: FieldPair[], typeName: string, gameObjectPath: string, gameObjectInstanceId: int, index: int | +Start with one domain's core commands: -### transform - -| action | summary | args | -|--------|---------|------| -| get | Get a GameObject's transform values | path: string, instanceId: int | -| set | Set a GameObject's transform values | path: string, instanceId: int, position: Vector3, rotation: Vector3, scale: Vector3, local: bool | +```bash +cs list-commands --offline --domain objects --tier core --json +``` -### material - -| action | summary | args | -|--------|---------|------| -| create | Create a new material asset | savePath: string, shaderName: string | -| get | Get material properties | assetPath: string, gameObjectPath: string | -| assign | Assign a material to a Renderer component | materialPath: string, gameObjectPath: string, gameObjectInstanceId: int, index: int | +If the requested operation is specialized or absent from core, query only that +domain's advanced commands: -### prefab - -**Scene instance operations:** +```bash +cs list-commands --offline --domain prefabs --tier advanced --json +``` -| action | summary | args | -|--------|---------|------| -| create | Create a prefab asset from a scene GameObject | savePath: string, gameObjectPath: string, gameObjectInstanceId: int | -| instantiate | Instantiate a prefab into the active scene | assetPath: string, parentPath: string, position: Vector3 | -| unpack | Unpack a prefab instance | gameObjectPath: string, gameObjectInstanceId: int, full: bool | - -**Direct asset editing** (edit the `.prefab` file without instantiating — `assetPath` is the asset path, `gameObjectPath` is the relative path within the prefab hierarchy): +Inspect one exact canonical id before constructing its request: -| action | summary | args | -|--------|---------|------| -| asset_get | Get detailed info about a GameObject in a prefab asset | assetPath: string, gameObjectPath: string | -| asset_hierarchy | Get the hierarchy tree of a prefab asset | assetPath: string, depth: int, includeComponents: bool | -| asset_add_component | Add a component to a GameObject in a prefab asset | assetPath: string, typeName: string, gameObjectPath: string | -| asset_get_component | Get serialized properties of a component in a prefab asset | assetPath: string, typeName: string, gameObjectPath: string, index: int | -| asset_modify_component | Modify serialized fields of a component in a prefab asset | fields: AssetFieldPair[], assetPath: string, typeName: string, gameObjectPath: string, index: int | -| asset_remove_component | Remove a component from a GameObject in a prefab asset | assetPath: string, typeName: string, gameObjectPath: string, index: int | -| asset_add_gameobject | Add a child GameObject to a prefab asset | assetPath: string, parentPath: string, name: string | -| asset_modify_gameobject | Modify a GameObject's properties in a prefab asset | assetPath: string, gameObjectPath: string, name: string, tag: string, layer: int, active: int, isStatic: int | -| asset_remove_gameobject | Remove a child GameObject from a prefab asset | assetPath: string, gameObjectPath: string | +```bash +cs list-commands --offline --id prefab/asset_modify_component --json +``` -### project +Canonical ids use `/`. The returned entry includes the actual +wire namespace, action, committed argument schema, domain, tier, and availability. +Do not guess arguments from an action name. -| action | summary | args | -|--------|---------|------| -| scene.list | List all scenes in the project | — | -| scene.open | Open a scene by path | scenePath: string, mode: string | -| scene.save | Save the current scene | scenePath: string, saveAsCopy: bool | -| selection.get | Get the current editor selection | — | -| selection.set | Set the editor selection by name or path | instanceIds: int[], assetPaths: string[] | -| asset.list | List assets by type filter | filter: string, folders: string[] | -| asset.import | Import an asset by path | assetPath: string, forceSynchronousImport: bool | -| asset.reimport | Reimport an asset by path | assetPath: string, forceSynchronousImport: bool | +Useful discovery filters: -### asset +```bash +cs list-commands --offline --type builtin --domain scene --tier core --json +cs list-commands --offline --include-blocked --json +cs list-commands --type custom --json +``` -| action | summary | args | -|--------|---------|------| -| move | Move or rename an asset | sourcePath: string, destinationPath: string | -| copy | Copy an asset to a new path | sourcePath: string, destinationPath: string | -| delete | Delete one or more assets | assetPath: string, assetPaths: string[] | -| create_folder | Create a folder in the Asset Database | folderPath: string | +`--offline` reads the committed manifest and needs neither a Unity project nor a +running service. Remove it only when checking the current package's live registry +or discovering project-defined custom commands. Use unfiltered, +live, or `--include-blocked` discovery only for maintainer audits. Routine tasks +should expose one domain and tier at a time. -### scene +## Request protocol -| action | summary | args | -|--------|---------|------| -| hierarchy | Get the full scene hierarchy tree | depth: int, includeComponents: bool | - -### screenshot - -| action | summary | args | -|--------|---------|------| -| scene_view | Capture the current Scene View | savePath: string, width: int, height: int | -| game_view | Capture the Game View | savePath: string, width: int, height: int, superSize: int | +Write a single JSON object to the mandatory scratch directory documented in +`SKILL.md`: -### profiler +```json +{"ns":"gameobject","action":"create","args":{"name":"Wall","primitiveType":"Cube"}} +``` -| action | summary | args | -|--------|---------|------| -| start | Start Profiler recording | deep: bool, logFile: string | -| stop | Stop Profiler recording | — | -| status | Get current Profiler state | — | -| save | Save recorded profiler data to a .raw file | savePath: string | +Then run: -### session +```bash +cs command --json --input "/Temp/CSharpConsole/AgentScratch/req-create-wall.json" +``` -| action | summary | args | -|--------|---------|------| -| list | List active REPL sessions | — | -| inspect | Inspect a session's state | — | -| reset | Reset a session's compiler and executor | — | +Omit `args` only for a command whose discovered contract has no arguments. Never +pass structured parameters inline or infer omitted required fields. -Pass `--session ` on `session/inspect` or `session/reset` so the command -targets the same named context used by the dependent `cs exec` calls. +The response envelope is: -### command +```json +{"ok":true,"exitCode":0,"summary":"...","data":{}} +``` -| action | summary | args | -|--------|---------|------| -| list | List registered commands | — | +Check both `ok` and `exitCode`. `data` is already structured; do not reparse a +`resultJson` string unless `--verbose` explicitly returned one. -## Custom Commands +Common shapes: -Lookup order for custom (user-defined) commands: +- `Vector3`: `{"x":0,"y":1,"z":3}` with all three numeric axes. +- Arrays: JSON arrays of the discovered element type. +- Booleans: JSON `true` / `false`. +- Legacy `active` and `isStatic`: integer `0` / `1` when the discovered schema + says `int`. +- Object identifiers: provide the meaningful `path` or `instanceId` form required + by the exact contract; do not send both unless discovery explicitly permits it. -1. Check the static built-in catalog in this SKILL.md (the tables above). -2. Read the per-project catalog cache via `cs catalog list` (text index: id, arg names, summary). For full arg types/descriptions, Read the catalog file directly (default `{project}/.unity-cli/catalog.json`, committed). -3. If the catalog is empty, missing, or stale, fall back to a live query: - ```bash - cs list-commands --type custom --json - ``` -4. If a catalog refresh is needed, run `cs catalog sync`. +## Local preflight -### Catalog commands +Recognized built-ins are validated before any HTTP request. Preflight rejects: -| command | description | -|---------|-------------| -| `cs catalog sync` | Pull the current custom command list from Unity and write/update the per-project catalog JSON | -| `cs catalog list` | Display the contents of the per-project catalog JSON | +- blocked commands; +- unknown arguments; +- missing or empty required arguments; +- wrong scalar, array, vector, or field-pair types; +- invalid enum/range values; +- violations of exactly-one, at-most-one, at-least-one, or conditional argument + rules; +- session operations that omit an explicit `--session` id. -### `list-commands` `--type` flag +`batch` preflights every recognized built-in item before sending the batch. A +preflight failure means the rejected command was **not executed**. Fix the request; +do not retry it unchanged. -Pass `--type` to filter results: -- `--type builtin` — built-in framework commands only -- `--type custom` — user-registered custom commands only -- *(omit `--type`)* — all commands +Project-defined custom commands pass through because their contracts are +project-specific. Use their live or cached schema instead of assuming built-in +validation applies. -## Runtime Mode +## Verification and retry policy -Most commands are **editor-only** (require the Unity Editor, not a standalone player). The `session/*` and `command/list` commands work in both editor and runtime modes. Pass `--mode runtime --port 15500` for player builds. +A successful transport response is not by itself proof that the intended Unity +state was reached. -## Examples +1. For reads, verify that the returned scope and identifiers match the request. +2. After mutation, use the manifest's verification hint and perform the smallest + independent read-back: `get`, `hierarchy`, `list`, `status`, or a relevant + screenshot. +3. After asset or C# file changes that require compilation, follow + `references/refresh.md`; a domain reload clears REPL sessions. +4. If transport fails after a mutation may have been accepted, read back before + retrying. Never blindly repeat a create, duplicate, destroy, import, or other + mutation whose execution state is unknown. +5. Report completion only when read-back matches the requested state. Otherwise + report the observed state and the next recoverable action. -Each block below is the `req.json` content; run it with -`cs command --json --input "/Temp/CSharpConsole/AgentScratch/req.json"`: +## Custom command fallback -```json -{"ns":"editor","action":"status"} -{"ns":"gameobject","action":"create","args":{"name":"Wall","primitiveType":"Cube"}} -{"ns":"transform","action":"set","args":{"path":"Wall","position":{"x":0,"y":1,"z":3}}} -{"ns":"component","action":"get","args":{"gameObjectPath":"Main Camera","typeName":"Camera"}} -{"ns":"screenshot","action":"scene_view","args":{"savePath":"Assets/screenshot.png"}} -{"ns":"scene","action":"hierarchy","args":{"depth":3,"includeComponents":true}} -{"ns":"prefab","action":"asset_hierarchy","args":{"assetPath":"Assets/Prefabs/Player.prefab","depth":2,"includeComponents":true}} -{"ns":"prefab","action":"asset_add_component","args":{"assetPath":"Assets/Prefabs/Player.prefab","typeName":"BoxCollider","gameObjectPath":"Body"}} -``` +When no built-in contract matches: -Discovery / catalog (no payload — plain flags, no `--input`): +1. Run `cs catalog list` to inspect the committed per-project custom-command cache. +2. If it is missing or stale, run `cs list-commands --type custom --json`. +3. Run `cs catalog sync` only when the cache needs to be refreshed. +4. Return to the fallback order in `SKILL.md` if no custom command matches. -```bash -cs list-commands --json -cs list-commands --type custom --json -cs catalog sync -cs catalog list -``` +See `references/catalog.md` for catalog maintenance. Do not read or edit the +catalog as a substitute for its CLI workflow. -## Workflow +## Runtime mode -1. Match the user's intent to a namespace + action from the catalog above -2. Run the command with appropriate args -3. **After writing C# files**, follow the Asset Refresh procedure above (check play mode → exit if needed → refresh) -4. If no matching command exists in the built-in catalog, run `cs catalog list` to check the per-project custom-command cache -5. If the cache is empty or stale, run `cs list-commands --type custom` as a live fallback, then `cs catalog sync` -6. If no command covers the request at all, fall back to `cs exec` +Most built-ins are Editor-only. Session operations and command discovery also work +against a supported Player service. Use `--mode runtime --port 15500` only when +deliberately targeting a Player build. diff --git a/skills/unity-cli/references/exec-code.md b/skills/unity-cli/references/exec-code.md index fb33d60..4491e08 100644 --- a/skills/unity-cli/references/exec-code.md +++ b/skills/unity-cli/references/exec-code.md @@ -1,9 +1,12 @@ # Unity CLI Exec Code (Fallback) Execute raw C# in a running Unity Editor via the Roslyn-based CSharpConsole REPL. -Always prefer `cs command` first. +Raw exec is the final fallback in the canonical routing order in `SKILL.md`. +Follow that routing and exhaust the matching built-in, cached custom command, and +snippet stages before using this reference. -Then check the snippet library (`cs snippets search `) before writing ad-hoc code. After solving a non-trivial task that's likely to recur, consider distilling it into a snippet — see references/snippets.md. +After solving a non-trivial task that's likely to recur, consider distilling it +into a snippet — see `references/snippets.md`. ## Usage @@ -99,8 +102,8 @@ error does not require this split. ### LINQ queries over live scene ```csharp -// Prefer FindObjectsByType (Unity 2023.1+); FindObjectsOfType is deprecated. -using System.Linq; UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None).Select(r => $"{r.name}: mass={r.mass}").ToList() +// Unity 2022-compatible scene query. +using System.Linq; UnityEngine.Object.FindObjectsOfType().Select(r => $"{r.name}: mass={r.mass}").ToList() // Resources.FindObjectsOfTypeAll is still current — it returns inactive/asset objects too: using System.Linq; Resources.FindObjectsOfTypeAll().Where(g => !g.activeInHierarchy).Select(g => g.name).ToList() ``` diff --git a/skills/unity-cli/references/snippets.md b/skills/unity-cli/references/snippets.md index 3d79cad..223bff9 100644 --- a/skills/unity-cli/references/snippets.md +++ b/skills/unity-cli/references/snippets.md @@ -1,12 +1,11 @@ # Unity CLI Snippets -## Decision Order (strict) +## Entry boundary -Before writing ad-hoc `cs exec` for any non-trivial Unity automation: - -1. `cs list-commands [--type custom]` — built-in or custom command available? -2. `cs snippets search ` — matching snippet? -3. Only if neither match: ad-hoc `cs exec`. +Enter the snippet branch only after following the canonical routing order in +`SKILL.md`. Built-in discovery must use the relevant domain/tier filters from +`references/commands.md`; do not load the full command registry merely to decide +whether to search snippets. "Non-trivial" = >3 lines, or uses LINQ / reflection / AssetDatabase / multi-step. @@ -122,11 +121,11 @@ No `Quaternion` (use `vector3` Euler or `vector4` raw inside `Run`). No `expr` ( - Read `.unity-cli/snippets~/` directly with shell tools. - Hand-edit snippet `.md` files; use `add` / `update --file` so validation runs. -- Skip `cs list-commands` and `cs snippets search` before ad-hoc `cs exec`. +- Bypass the routing stages defined in `SKILL.md` before ad-hoc `cs exec`. - Distill one-shot operations or trivial one-liners. ## Boundary with `cs command` / `cs exec` -- Built-in/custom command available → `cs command` (see references/commands.md). -- One-off ad-hoc → `cs exec` (see references/exec-code.md). -- Reusable ad-hoc → `cs snippets`. +The canonical boundary is defined in `SKILL.md`. This reference owns reusable +fallback code only; see `references/commands.md` for structured discovery and +`references/exec-code.md` for one-off raw C#. diff --git a/skills/unity-cli/scripts/cli/command_index.py b/skills/unity-cli/scripts/cli/command_index.py new file mode 100644 index 0000000..598b7af --- /dev/null +++ b/skills/unity-cli/scripts/cli/command_index.py @@ -0,0 +1,400 @@ +"""Static routing contracts for the built-in Unity command protocol. + +The Unity service remains the execution authority. This module supplies the +agent-facing metadata that reflection cannot express (visibility, intent +boundaries, required arguments, and verification) and rejects requests that +the service would otherwise accept as silent no-ops. +""" + +from __future__ import annotations + +import json +from pathlib import Path + + +MANIFEST_PATH = Path(__file__).with_name("command_manifest.json") +TIERS = ("core", "advanced", "control-plane") +DOMAINS = ("editor", "scene", "objects", "assets", "prefabs", "capture", "control") + + +class CommandContractError(ValueError): + """A built-in command request violates the committed routing contract.""" + + +def load_manifest(path=None): + """Load the committed built-in command manifest.""" + manifest_path = Path(path) if path else MANIFEST_PATH + try: + data = json.loads(manifest_path.read_text("utf-8")) + except OSError as exc: + raise CommandContractError(f"cannot read command manifest: {exc}") from exc + except json.JSONDecodeError as exc: + raise CommandContractError(f"invalid command manifest JSON: {exc}") from exc + if not isinstance(data, dict) or not isinstance(data.get("commands"), list): + raise CommandContractError("command manifest needs a commands array") + return data + + +def command_contracts(path=None): + """Return built-in contracts keyed by canonical ``namespace/action`` id.""" + contracts = {} + for contract in load_manifest(path)["commands"]: + command_id = contract.get("id") + if not isinstance(command_id, str) or not command_id: + raise CommandContractError("every command manifest entry needs a non-empty id") + if command_id in contracts: + raise CommandContractError(f"duplicate command contract: {command_id}") + contracts[command_id] = contract + return contracts + + +def live_command_id(command): + """Return the canonical id for a live list-commands descriptor.""" + namespace = command.get("commandNamespace") or command.get("namespace") + action = command.get("action") + if isinstance(namespace, str) and isinstance(action, str): + return f"{namespace}/{action}" + live_id = command.get("id") + if isinstance(live_id, str): + return live_id.replace(".", "/", 1) if "/" not in live_id else live_id + return "" + + +def annotate_live_command(command, contracts=None): + """Add routing metadata to a live descriptor without replacing live schema.""" + if contracts is None: + contracts = command_contracts() + annotated = dict(command) + command_id = live_command_id(command) + if command_id: + annotated["canonicalId"] = command_id + contract = contracts.get(command_id) + if contract: + annotated["domain"] = contract["domain"] + annotated["tier"] = contract["tier"] + annotated["availability"] = contract["availability"] + elif command.get("commandType") == "custom": + annotated["domain"] = "custom" + annotated["tier"] = "advanced" + annotated["availability"] = "supported" + return annotated + + +def contract_descriptors(contracts=None): + """Return manifest entries in the same broad shape as live descriptors.""" + if contracts is None: + contracts = command_contracts() + descriptors = [] + for command_id, contract in contracts.items(): + descriptor = dict(contract) + descriptor["canonicalId"] = command_id + descriptor["commandNamespace"] = contract["ns"] + descriptor["commandType"] = "builtin" + descriptor["arguments"] = contract.get("args", []) + descriptors.append(descriptor) + return descriptors + + +def filter_live_commands( + commands, + *, + type_filter="all", + domain=None, + tier=None, + command_id=None, + include_blocked=False, + contracts=None, +): + """Annotate and filter live command descriptors. + + Unknown built-ins are retained when no domain/tier filter is requested so a + newer package remains discoverable. They are never mistaken for a + classified core command. + """ + if contracts is None: + contracts = command_contracts() + filtered = [] + for raw in commands: + item = annotate_live_command(raw, contracts) + command_type = item.get("commandType", "builtin") + if type_filter != "all" and command_type != type_filter: + continue + if not include_blocked and item.get("availability") == "blocked": + continue + if domain is not None and item.get("domain") != domain: + continue + if tier is not None and item.get("tier") != tier: + continue + if command_id is not None and item.get("canonicalId") != command_id: + continue + filtered.append(item) + return filtered + + +def _is_int(value): + return isinstance(value, int) and not isinstance(value, bool) + + +def _is_number(value): + return (isinstance(value, (int, float)) and not isinstance(value, bool)) + + +def _present(args, name, arg_specs): + """Whether an argument carries a meaningful, non-sentinel value.""" + if name not in args: + return False + value = args[name] + if value is None or value == "": + return False + spec = arg_specs.get(name, {}) + if "sentinel" in spec and value == spec["sentinel"]: + return False + if "default" in spec and value == spec["default"]: + return False + if isinstance(value, (list, dict)) and not value: + return False + return True + + +def _validate_vector3(name, value): + if not isinstance(value, dict): + raise CommandContractError(f"{name} must be a Vector3 object") + expected = {"x", "y", "z"} + unknown = set(value) - expected + missing = expected - set(value) + if unknown or missing: + details = [] + if missing: + details.append(f"missing {', '.join(sorted(missing))}") + if unknown: + details.append(f"unknown {', '.join(sorted(unknown))}") + raise CommandContractError(f"{name} must contain exactly x/y/z ({'; '.join(details)})") + if not all(_is_number(value[axis]) for axis in expected): + raise CommandContractError(f"{name}.x/y/z must be numbers") + + +def _validate_field_pairs(name, value): + if not isinstance(value, list) or not value: + raise CommandContractError(f"{name} must be a non-empty FieldPair array") + for index, item in enumerate(value): + if not isinstance(item, dict): + raise CommandContractError(f"{name}[{index}] must be an object") + unknown = set(item) - {"name", "value"} + if unknown: + raise CommandContractError( + f"{name}[{index}] has unknown field(s): {', '.join(sorted(unknown))}" + ) + if not isinstance(item.get("name"), str) or not item["name"]: + raise CommandContractError(f"{name}[{index}].name must be a non-empty string") + if not isinstance(item.get("value"), str): + raise CommandContractError( + f"{name}[{index}].value must be a string; encode Vector/Color values as JSON text" + ) + + +def _validate_type(spec, value): + name = spec["name"] + type_name = spec["type"] + if type_name == "string": + valid = isinstance(value, str) + elif type_name == "int": + valid = _is_int(value) + elif type_name == "bool": + valid = isinstance(value, bool) + elif type_name == "string[]": + valid = isinstance(value, list) and all(isinstance(item, str) for item in value) + elif type_name == "int[]": + valid = isinstance(value, list) and all(_is_int(item) for item in value) + elif type_name == "Vector3": + _validate_vector3(name, value) + valid = True + elif type_name == "FieldPair[]": + _validate_field_pairs(name, value) + valid = True + else: + raise CommandContractError(f"manifest has unsupported type {type_name!r} for {name}") + if not valid: + raise CommandContractError(f"{name} must be {type_name}") + + if spec.get("nonEmpty") and isinstance(value, str) and not value.strip(): + raise CommandContractError(f"{name} must not be empty") + if "enum" in spec: + choices = spec["enum"] + if spec.get("caseInsensitive") and isinstance(value, str): + enum_match = any( + isinstance(choice, str) and value.casefold() == choice.casefold() + for choice in choices + ) + else: + enum_match = value in choices + if not enum_match: + rendered = ", ".join(repr(choice) for choice in choices) + raise CommandContractError(f"{name} must be one of: {rendered}") + measured = len(value) if isinstance(value, (str, list, dict)) else value + if "min" in spec and measured < spec["min"]: + unit = " items" if isinstance(value, list) else "" + raise CommandContractError(f"{name} must have value/length >= {spec['min']}{unit}") + if "max" in spec and measured > spec["max"]: + unit = " items" if isinstance(value, list) else "" + raise CommandContractError(f"{name} must have value/length <= {spec['max']}{unit}") + + +def _groups(value): + if not value: + return [] + if value and all(isinstance(item, str) for item in value): + return [value] + return value + + +def _validate_cross_field_rules(command_id, args, arg_specs, rules): + for group in _groups(rules.get("exactlyOneOf")): + present = [name for name in group if _present(args, name, arg_specs)] + if len(present) != 1: + raise CommandContractError( + f"{command_id} needs exactly one of {', '.join(group)}" + ) + + for group in _groups(rules.get("atMostOneOf")): + present = [name for name in group if _present(args, name, arg_specs)] + if len(present) > 1: + raise CommandContractError( + f"{command_id} accepts at most one of {', '.join(group)}" + ) + + for group in _groups(rules.get("atLeastOne")): + if not any(_present(args, name, arg_specs) for name in group): + raise CommandContractError( + f"{command_id} needs at least one of {', '.join(group)}" + ) + + mutations = rules.get("atLeastOneMutation", []) + if mutations and not any(_present(args, name, arg_specs) for name in mutations): + raise CommandContractError( + f"{command_id} needs at least one mutation field: {', '.join(mutations)}" + ) + + conditional_rules = rules.get("requiresWhen", []) + if isinstance(conditional_rules, dict): + conditional_rules = [conditional_rules] + for rule in conditional_rules: + when = rule.get("when", rule) + name = when.get("arg") + if name in args and args[name] == when.get("equals"): + missing = [ + required + for required in rule.get("requires", []) + if not _present(args, required, arg_specs) + ] + if missing: + raise CommandContractError( + f"{command_id} requires {', '.join(missing)} when " + f"{name}={when.get('equals')!r}" + ) + + +def validate_command_request(namespace, action, args=None, *, session_id=None, contracts=None): + """Validate one request; return normalized args. + + Custom commands are intentionally passed through because their schema is + project-specific. Built-ins fail closed before an HTTP request is made. + """ + if contracts is None: + contracts = command_contracts() + command_id = f"{namespace}/{action}" + contract = contracts.get(command_id) + if contract is None: + return args + if contract.get("availability") == "blocked": + raise CommandContractError( + f"{command_id} is blocked: {contract.get('blockedReason', 'not operational')}" + ) + specs = contract.get("args", []) + virtual_specs = [spec for spec in specs if spec.get("type") == "request-session"] + if (contract.get("requiresSession") or virtual_specs) and not session_id: + raise CommandContractError(f"{command_id} requires an explicit --session id") + + if args is None: + normalized = {} + elif isinstance(args, str): + try: + normalized = json.loads(args) + except json.JSONDecodeError as exc: + raise CommandContractError(f"{command_id} args is invalid JSON: {exc}") from exc + else: + normalized = args + if not isinstance(normalized, dict): + raise CommandContractError(f"{command_id} args must be a JSON object") + + wire_specs = [spec for spec in specs if spec.get("type") != "request-session"] + arg_specs = {spec["name"]: spec for spec in wire_specs} + unknown = sorted(set(normalized) - set(arg_specs)) + if unknown: + raise CommandContractError( + f"{command_id} has unknown argument(s): {', '.join(unknown)}" + ) + + for spec in wire_specs: + name = spec["name"] + if spec.get("required") and not _present(normalized, name, arg_specs): + raise CommandContractError(f"{command_id} requires non-empty {name}") + if name in normalized: + _validate_type(spec, normalized[name]) + + _validate_cross_field_rules( + command_id, normalized, arg_specs, contract.get("rules", {}) + ) + return normalized + + +def validate_batch_items(items, *, session_id=None, contracts=None): + """Validate all recognized built-ins in a batch request.""" + if contracts is None: + contracts = command_contracts() + if not isinstance(items, list): + raise CommandContractError("batch commands must be an array") + for index, item in enumerate(items): + if not isinstance(item, dict): + raise CommandContractError(f"batch command {index} must be an object") + namespace = item.get("ns", item.get("namespace")) + action = item.get("action") + if not isinstance(namespace, str) or not isinstance(action, str): + raise CommandContractError( + f"batch command {index} needs string ns/namespace and action" + ) + item_session_id = item.get("sessionId") + if item_session_id is not None and ( + not isinstance(item_session_id, str) or not item_session_id.strip() + ): + raise CommandContractError( + f"batch command {index} sessionId must be a non-empty string" + ) + raw_args = item.get("args") + raw_args_json = item.get("argsJson") + if isinstance(raw_args, dict): + args = raw_args + else: + args = raw_args_json or raw_args + if args is None: + args = {} + elif not isinstance(args, str): + selected_field = "argsJson" if raw_args_json else "args" + expected_type = ( + "a JSON string" + if selected_field == "argsJson" + else "a JSON object or JSON string" + ) + raise CommandContractError( + f"batch command {index} {selected_field} must be {expected_type}" + ) + try: + validate_command_request( + namespace, + action, + args, + session_id=item_session_id or session_id, + contracts=contracts, + ) + except CommandContractError as exc: + raise CommandContractError(f"batch command {index}: {exc}") from exc + return items diff --git a/skills/unity-cli/scripts/cli/command_manifest.json b/skills/unity-cli/scripts/cli/command_manifest.json new file mode 100644 index 0000000..0d23c56 --- /dev/null +++ b/skills/unity-cli/scripts/cli/command_manifest.json @@ -0,0 +1,1244 @@ +{ + "schemaVersion": 1, + "domains": [ + "editor", + "scene", + "objects", + "assets", + "prefabs", + "capture", + "control" + ], + "commands": [ + { + "id": "editor/status", + "ns": "editor", + "action": "status", + "domain": "editor", + "tier": "core", + "availability": "supported", + "summary": "Read C# Console service state and Unity Editor/play-mode state.", + "sideEffect": "read", + "editorOnly": true, + "args": [], + "rules": {}, + "resultFields": ["initialized", "port", "refreshing", "generation", "editorState", "isPlaying", "isPaused", "isCompiling", "isUpdating"], + "positiveIntents": ["check whether the Unity Editor is ready", "检查 Unity 编辑器是否就绪"], + "negativeIntents": ["inspect only the play-mode transition state"], + "verification": "Require all result fields and cross-check play-mode fields with editor/playmode.status." + }, + { + "id": "editor/playmode.status", + "ns": "editor", + "action": "playmode.status", + "domain": "editor", + "tier": "advanced", + "availability": "supported", + "summary": "Read Unity play-mode and play-mode transition state only.", + "sideEffect": "read", + "editorOnly": true, + "args": [], + "rules": {}, + "resultFields": ["isPlaying", "isPaused", "isPlayingOrWillChangePlaymode", "isCompiling"], + "positiveIntents": ["check whether Unity is entering or leaving play mode", "检查 Unity 是否正在切换播放模式"], + "negativeIntents": ["check general Editor or service readiness"], + "verification": "Require a coherent play-mode state and compare shared fields with editor/status." + }, + { + "id": "editor/playmode.enter", + "ns": "editor", + "action": "playmode.enter", + "domain": "editor", + "tier": "core", + "availability": "supported", + "summary": "Request entry into play mode; completion is asynchronous.", + "sideEffect": "lifecycle", + "editorOnly": true, + "args": [], + "rules": {}, + "resultFields": [], + "positiveIntents": ["enter Unity play mode", "进入 Unity 播放模式"], + "negativeIntents": ["check whether Unity is already in play mode"], + "verification": "Poll editor/playmode.status until isPlaying is true and isCompiling is false." + }, + { + "id": "editor/playmode.exit", + "ns": "editor", + "action": "playmode.exit", + "domain": "editor", + "tier": "core", + "availability": "supported", + "summary": "Request exit from play mode; completion is asynchronous.", + "sideEffect": "lifecycle", + "editorOnly": true, + "args": [], + "rules": {}, + "resultFields": [], + "positiveIntents": ["exit Unity play mode", "退出 Unity 播放模式"], + "negativeIntents": ["check current play-mode state"], + "verification": "Poll editor/playmode.status until isPlaying and isPlayingOrWillChangePlaymode are false." + }, + { + "id": "editor/menu.open", + "ns": "editor", + "action": "menu.open", + "domain": "editor", + "tier": "advanced", + "availability": "blocked", + "blockedReason": "non-interactive menu execution can open modal dialogs and is intentionally disabled", + "summary": "Blocked in non-interactive mode because opening menu items can display modal dialogs.", + "sideEffect": "write", + "editorOnly": true, + "args": [ + {"name": "menuPath", "type": "string", "required": true} + ], + "rules": {}, + "resultFields": [], + "positiveIntents": ["attempt to invoke a Unity menu item", "尝试调用 Unity 菜单项"], + "negativeIntents": ["run a supported non-interactive editor command"], + "verification": "Require a validation error that states the command is blocked." + }, + { + "id": "editor/window.open", + "ns": "editor", + "action": "window.open", + "domain": "editor", + "tier": "advanced", + "availability": "blocked", + "blockedReason": "non-interactive Editor window creation can open modal dialogs and is intentionally disabled", + "summary": "Blocked in non-interactive mode because opening Editor windows can display modal dialogs.", + "sideEffect": "write", + "editorOnly": true, + "args": [ + {"name": "typeName", "type": "string", "required": true}, + {"name": "utility", "type": "bool", "required": false, "default": false} + ], + "rules": {}, + "resultFields": [], + "positiveIntents": ["attempt to open a Unity Editor window", "尝试打开 Unity 编辑器窗口"], + "negativeIntents": ["inspect an existing scene or GameObject"], + "verification": "Require a validation error that states the command is blocked." + }, + { + "id": "editor/console.clear", + "ns": "editor", + "action": "console.clear", + "domain": "editor", + "tier": "advanced", + "availability": "supported", + "summary": "Clear entries from the Unity Editor Console when the Unity version exposes that operation.", + "sideEffect": "destructive", + "editorOnly": true, + "args": [], + "rules": {}, + "resultFields": [], + "positiveIntents": ["clear the Unity Editor Console", "清空 Unity 编辑器控制台"], + "negativeIntents": ["read or filter Unity console messages"], + "verification": "Require success only when Console clearing is available; otherwise require a validation error." + }, + { + "id": "editor/console.mark", + "ns": "editor", + "action": "console.mark", + "domain": "editor", + "tier": "advanced", + "availability": "supported", + "summary": "Write a unique searchable marker to the Editor log and return its log location.", + "sideEffect": "diagnostic", + "editorOnly": true, + "args": [ + {"name": "label", "type": "string", "required": false, "default": ""} + ], + "rules": {}, + "resultFields": ["logPath", "id", "label", "timestampUtc", "markerText"], + "positiveIntents": ["mark the Unity log before an operation", "在操作前为 Unity 日志写入标记"], + "negativeIntents": ["clear or read existing console messages"], + "verification": "Require a nonempty id and find markerText containing that id in the returned logPath." + }, + { + "id": "gameobject/find", + "ns": "gameobject", + "action": "find", + "domain": "objects", + "tier": "core", + "availability": "supported", + "summary": "Find up to 500 GameObjects in loaded scenes and DontDestroyOnLoad by name, tag, and/or component type.", + "sideEffect": "read", + "editorOnly": true, + "args": [ + {"name": "name", "type": "string", "required": false, "default": ""}, + {"name": "tag", "type": "string", "required": false, "default": ""}, + {"name": "componentType", "type": "string", "required": false, "default": ""} + ], + "rules": {}, + "resultFields": ["gameObjects", "gameObjects[].instanceId", "gameObjects[].name", "gameObjects[].path", "gameObjects[].tag", "gameObjects[].layer", "gameObjects[].activeSelf"], + "positiveIntents": ["find GameObjects with a given name, tag, or component", "按名称、标签或组件查找游戏对象"], + "negativeIntents": ["return the complete active-scene hierarchy tree"], + "verification": "Require every returned object to satisfy all supplied filters and never return more than 500 entries." + }, + { + "id": "gameobject/create", + "ns": "gameobject", + "action": "create", + "domain": "objects", + "tier": "core", + "availability": "supported", + "summary": "Create an empty GameObject or Unity primitive, optionally under a hierarchy parent.", + "sideEffect": "write", + "editorOnly": true, + "args": [ + {"name": "name", "type": "string", "required": false, "default": ""}, + {"name": "primitiveType", "type": "string", "required": false, "default": "", "enum": ["", "Cube", "Sphere", "Capsule", "Cylinder", "Plane", "Quad"], "caseInsensitive": true}, + {"name": "parentPath", "type": "string", "required": false, "default": ""} + ], + "rules": {}, + "resultFields": ["instanceId", "name", "path"], + "positiveIntents": ["create an empty GameObject or primitive", "创建空游戏对象或基础几何体"], + "negativeIntents": ["instantiate an existing prefab asset"], + "verification": "Read back gameobject/get by result instanceId and require matching name and path." + }, + { + "id": "gameobject/destroy", + "ns": "gameobject", + "action": "destroy", + "domain": "objects", + "tier": "advanced", + "availability": "supported", + "summary": "Destroy one scene GameObject selected by hierarchy path or instance ID.", + "sideEffect": "destructive", + "editorOnly": true, + "args": [ + {"name": "path", "type": "string", "required": false, "default": ""}, + {"name": "instanceId", "type": "int", "required": false, "default": 0} + ], + "rules": { + "exactlyOneOf": [["path", "instanceId"]] + }, + "resultFields": ["instanceId", "name", "destroyed"], + "positiveIntents": ["delete a GameObject from a loaded scene", "从已加载场景删除游戏对象"], + "negativeIntents": ["delete an asset or a child inside a prefab asset"], + "verification": "Require destroyed to be true and require gameobject/get for the result instanceId to report not found." + }, + { + "id": "gameobject/get", + "ns": "gameobject", + "action": "get", + "domain": "objects", + "tier": "core", + "availability": "supported", + "summary": "Read one scene GameObject's identity, state, transform summary, and component summary.", + "sideEffect": "read", + "editorOnly": true, + "args": [ + {"name": "path", "type": "string", "required": false, "default": ""}, + {"name": "instanceId", "type": "int", "required": false, "default": 0} + ], + "rules": { + "exactlyOneOf": [["path", "instanceId"]] + }, + "resultFields": ["instanceId", "name", "path", "tag", "layer", "activeSelf", "activeInHierarchy", "transform", "components"], + "positiveIntents": ["inspect one GameObject and its components", "查看单个游戏对象及其组件摘要"], + "negativeIntents": ["read serialized fields of one specific component"], + "verification": "Require the returned selector to identify the requested object and compare transform fields with transform/get." + }, + { + "id": "gameobject/modify", + "ns": "gameobject", + "action": "modify", + "domain": "objects", + "tier": "core", + "availability": "supported", + "summary": "Change one scene GameObject's name, tag, layer, active state, and/or static flag.", + "sideEffect": "write", + "editorOnly": true, + "args": [ + {"name": "path", "type": "string", "required": false, "default": ""}, + {"name": "instanceId", "type": "int", "required": false, "default": 0}, + {"name": "name", "type": "string", "required": false, "default": ""}, + {"name": "tag", "type": "string", "required": false, "default": ""}, + {"name": "layer", "type": "int", "required": false, "default": -1, "min": -1, "max": 31}, + {"name": "active", "type": "int", "required": false, "default": -1, "enum": [-1, 0, 1]}, + {"name": "isStatic", "type": "int", "required": false, "default": -1, "enum": [-1, 0, 1]} + ], + "rules": { + "exactlyOneOf": [["path", "instanceId"]], + "atLeastOneMutation": ["name", "tag", "layer", "active", "isStatic"] + }, + "resultFields": ["instanceId", "name", "path"], + "positiveIntents": ["rename or change basic properties of a GameObject", "修改游戏对象名称、标签、层或激活状态"], + "negativeIntents": ["change transform values or serialized component fields"], + "verification": "Read back gameobject/get and require each requested property to match." + }, + { + "id": "gameobject/set_parent", + "ns": "gameobject", + "action": "set_parent", + "domain": "objects", + "tier": "core", + "availability": "supported", + "summary": "Reparent one scene GameObject or move it to a scene root.", + "sideEffect": "write", + "editorOnly": true, + "args": [ + {"name": "path", "type": "string", "required": false, "default": ""}, + {"name": "instanceId", "type": "int", "required": false, "default": 0}, + {"name": "parentPath", "type": "string", "required": false, "default": ""}, + {"name": "parentInstanceId", "type": "int", "required": false, "default": 0}, + {"name": "worldPositionStays", "type": "bool", "required": false, "default": true} + ], + "rules": { + "exactlyOneOf": [["path", "instanceId"]], + "atMostOneOf": [["parentPath", "parentInstanceId"]] + }, + "resultFields": ["instanceId", "name", "newPath"], + "positiveIntents": ["reparent a GameObject or move it to the scene root", "重新设置游戏对象父级或移到场景根节点"], + "negativeIntents": ["set a parent inside a prefab asset"], + "verification": "Read back gameobject/get and require its path and preserved/local transform policy to match." + }, + { + "id": "gameobject/duplicate", + "ns": "gameobject", + "action": "duplicate", + "domain": "objects", + "tier": "core", + "availability": "supported", + "summary": "Duplicate one scene GameObject under the same parent.", + "sideEffect": "write", + "editorOnly": true, + "args": [ + {"name": "path", "type": "string", "required": false, "default": ""}, + {"name": "instanceId", "type": "int", "required": false, "default": 0}, + {"name": "newName", "type": "string", "required": false, "default": ""} + ], + "rules": { + "exactlyOneOf": [["path", "instanceId"]] + }, + "resultFields": ["instanceId", "name", "path"], + "positiveIntents": ["duplicate a GameObject in the current scene", "复制当前场景中的游戏对象"], + "negativeIntents": ["copy an asset file or instantiate a prefab"], + "verification": "Require a new instanceId and read back an object with the expected name, parent, transform, and components." + }, + { + "id": "component/add", + "ns": "component", + "action": "add", + "domain": "objects", + "tier": "core", + "availability": "supported", + "summary": "Add one resolved Component type to a scene GameObject.", + "sideEffect": "write", + "editorOnly": true, + "args": [ + {"name": "typeName", "type": "string", "required": true}, + {"name": "gameObjectPath", "type": "string", "required": false, "default": ""}, + {"name": "gameObjectInstanceId", "type": "int", "required": false, "default": 0} + ], + "rules": { + "exactlyOneOf": [["gameObjectPath", "gameObjectInstanceId"]] + }, + "resultFields": ["gameObjectInstanceId", "typeName", "componentInstanceId"], + "positiveIntents": ["add a component to a scene GameObject", "给场景游戏对象添加组件"], + "negativeIntents": ["add a component directly inside a prefab asset"], + "verification": "Read back component/get and require the returned componentInstanceId and type." + }, + { + "id": "component/remove", + "ns": "component", + "action": "remove", + "domain": "objects", + "tier": "advanced", + "availability": "supported", + "summary": "Remove one indexed Component type from a scene GameObject.", + "sideEffect": "destructive", + "editorOnly": true, + "args": [ + {"name": "typeName", "type": "string", "required": true}, + {"name": "gameObjectPath", "type": "string", "required": false, "default": ""}, + {"name": "gameObjectInstanceId", "type": "int", "required": false, "default": 0}, + {"name": "index", "type": "int", "required": false, "default": 0, "min": 0} + ], + "rules": { + "exactlyOneOf": [["gameObjectPath", "gameObjectInstanceId"]] + }, + "resultFields": ["gameObjectInstanceId", "typeName", "removed"], + "positiveIntents": ["remove a component from a scene GameObject", "从场景游戏对象移除组件"], + "negativeIntents": ["remove a component directly from a prefab asset"], + "verification": "Require removed to be true and read back one fewer component of the requested type." + }, + { + "id": "component/get", + "ns": "component", + "action": "get", + "domain": "objects", + "tier": "core", + "availability": "supported", + "summary": "Read up to 200 visible serialized properties from one indexed Component on a scene GameObject.", + "sideEffect": "read", + "editorOnly": true, + "args": [ + {"name": "typeName", "type": "string", "required": true}, + {"name": "gameObjectPath", "type": "string", "required": false, "default": ""}, + {"name": "gameObjectInstanceId", "type": "int", "required": false, "default": 0}, + {"name": "index", "type": "int", "required": false, "default": 0, "min": 0} + ], + "rules": { + "exactlyOneOf": [["gameObjectPath", "gameObjectInstanceId"]] + }, + "resultFields": ["gameObjectInstanceId", "typeName", "componentInstanceId", "properties", "properties[].name", "properties[].type", "properties[].value"], + "positiveIntents": ["inspect serialized fields of one component", "查看单个组件的序列化字段"], + "negativeIntents": ["list only the component types attached to a GameObject"], + "verification": "Require matching type and component index and no more than 200 property entries." + }, + { + "id": "component/modify", + "ns": "component", + "action": "modify", + "domain": "objects", + "tier": "core", + "availability": "supported", + "summary": "Set supported visible serialized properties on one indexed Component of a scene GameObject.", + "sideEffect": "write", + "editorOnly": true, + "args": [ + {"name": "fields", "type": "FieldPair[]", "required": true, "min": 1, "itemSchema": {"name": "string", "value": "string"}}, + {"name": "typeName", "type": "string", "required": true}, + {"name": "gameObjectPath", "type": "string", "required": false, "default": ""}, + {"name": "gameObjectInstanceId", "type": "int", "required": false, "default": 0}, + {"name": "index", "type": "int", "required": false, "default": 0, "min": 0} + ], + "rules": { + "exactlyOneOf": [["gameObjectPath", "gameObjectInstanceId"]] + }, + "resultFields": ["gameObjectInstanceId", "typeName", "modifiedFields"], + "positiveIntents": ["change serialized fields on a component", "修改组件的序列化字段"], + "negativeIntents": ["change GameObject identity or transform properties"], + "verification": "Require modifiedFields to equal requested field names and read back each value with component/get." + }, + { + "id": "transform/get", + "ns": "transform", + "action": "get", + "domain": "objects", + "tier": "advanced", + "availability": "supported", + "summary": "Read local and world transform values for one scene GameObject.", + "sideEffect": "read", + "editorOnly": true, + "args": [ + {"name": "path", "type": "string", "required": false, "default": ""}, + {"name": "instanceId", "type": "int", "required": false, "default": 0} + ], + "rules": { + "exactlyOneOf": [["path", "instanceId"]] + }, + "resultFields": ["instanceId", "path", "localPosition", "localEulerAngles", "localScale", "position", "eulerAngles"], + "positiveIntents": ["read exact local and world transform values", "读取精确的本地与世界变换值"], + "negativeIntents": ["inspect general GameObject state and component summaries"], + "verification": "Compare equivalent transform fields with gameobject/get for the same instanceId." + }, + { + "id": "transform/set", + "ns": "transform", + "action": "set", + "domain": "objects", + "tier": "core", + "availability": "supported", + "summary": "Set position and Euler rotation in local or world space and/or set local scale.", + "sideEffect": "write", + "editorOnly": true, + "args": [ + {"name": "path", "type": "string", "required": false, "default": ""}, + {"name": "instanceId", "type": "int", "required": false, "default": 0}, + {"name": "position", "type": "Vector3", "required": false, "default": null}, + {"name": "rotation", "type": "Vector3", "required": false, "default": null}, + {"name": "scale", "type": "Vector3", "required": false, "default": null}, + {"name": "local", "type": "bool", "required": false, "default": true} + ], + "rules": { + "exactlyOneOf": [["path", "instanceId"]], + "atLeastOneMutation": ["position", "rotation", "scale"] + }, + "resultFields": ["instanceId", "path", "localPosition", "localEulerAngles", "localScale"], + "positiveIntents": ["move, rotate, or scale a scene GameObject", "移动、旋转或缩放场景游戏对象"], + "negativeIntents": ["change RectTransform or another component serialized field"], + "verification": "Read back transform/get and compare requested vectors with floating-point tolerance in the requested space." + }, + { + "id": "material/create", + "ns": "material", + "action": "create", + "domain": "assets", + "tier": "core", + "availability": "supported", + "summary": "Create a Material asset under Assets using a named shader, with Standard-to-URP-Lit fallback.", + "sideEffect": "write", + "editorOnly": true, + "args": [ + {"name": "savePath", "type": "string", "required": true}, + {"name": "shaderName", "type": "string", "required": false, "default": ""} + ], + "rules": {}, + "resultFields": ["assetPath", "shaderName"], + "positiveIntents": ["create a new material asset", "创建新的材质资源"], + "negativeIntents": ["assign an existing material to a Renderer"], + "verification": "Read back material/get by result assetPath and require the resolved shaderName." + }, + { + "id": "material/get", + "ns": "material", + "action": "get", + "domain": "assets", + "tier": "core", + "availability": "supported", + "summary": "Read one Material asset or the first shared Material on a GameObject's Renderer.", + "sideEffect": "read", + "editorOnly": true, + "args": [ + {"name": "assetPath", "type": "string", "required": false, "default": ""}, + {"name": "gameObjectPath", "type": "string", "required": false, "default": ""} + ], + "rules": { + "exactlyOneOf": [["assetPath", "gameObjectPath"]] + }, + "resultFields": ["assetPath", "shaderName", "properties", "properties[].name", "properties[].type", "properties[].value"], + "positiveIntents": ["inspect a material shader and its properties", "查看材质着色器及其属性"], + "negativeIntents": ["inspect all serialized fields of a Renderer component"], + "verification": "Require a nonempty shaderName and unique property names from the selected Material." + }, + { + "id": "material/assign", + "ns": "material", + "action": "assign", + "domain": "assets", + "tier": "core", + "availability": "supported", + "summary": "Assign a Material asset to one shared-material slot on a Renderer attached directly to a scene GameObject.", + "sideEffect": "write", + "editorOnly": true, + "args": [ + {"name": "materialPath", "type": "string", "required": true}, + {"name": "gameObjectPath", "type": "string", "required": false, "default": ""}, + {"name": "gameObjectInstanceId", "type": "int", "required": false, "default": 0}, + {"name": "index", "type": "int", "required": false, "default": 0, "min": 0} + ], + "rules": { + "exactlyOneOf": [["gameObjectPath", "gameObjectInstanceId"]] + }, + "resultFields": ["gameObjectPath", "materialPath", "index"], + "positiveIntents": ["assign a material to a Renderer slot", "把材质分配给渲染器槽位"], + "negativeIntents": ["create or edit the material asset itself"], + "verification": "Read back the target Renderer shared-material slot and require the requested materialPath." + }, + { + "id": "prefab/create", + "ns": "prefab", + "action": "create", + "domain": "prefabs", + "tier": "core", + "availability": "supported", + "summary": "Save one scene GameObject as a Prefab asset under Assets.", + "sideEffect": "write", + "editorOnly": true, + "args": [ + {"name": "savePath", "type": "string", "required": true}, + {"name": "gameObjectPath", "type": "string", "required": false, "default": ""}, + {"name": "gameObjectInstanceId", "type": "int", "required": false, "default": 0} + ], + "rules": { + "exactlyOneOf": [["gameObjectPath", "gameObjectInstanceId"]] + }, + "resultFields": ["assetPath", "instanceId", "name"], + "positiveIntents": ["create a prefab asset from a scene GameObject", "从场景游戏对象创建预制体资源"], + "negativeIntents": ["instantiate an existing prefab into a scene"], + "verification": "Read back prefab/asset_get at the result assetPath and require the expected root name." + }, + { + "id": "prefab/instantiate", + "ns": "prefab", + "action": "instantiate", + "domain": "prefabs", + "tier": "core", + "availability": "supported", + "summary": "Instantiate one Prefab asset into the active scene, optionally under a parent and at a world position.", + "sideEffect": "write", + "editorOnly": true, + "args": [ + {"name": "assetPath", "type": "string", "required": true}, + {"name": "parentPath", "type": "string", "required": false, "default": ""}, + {"name": "position", "type": "Vector3", "required": false, "default": null} + ], + "rules": {}, + "resultFields": ["instanceId", "name", "path"], + "positiveIntents": ["instantiate a prefab in the active scene", "在活动场景实例化预制体"], + "negativeIntents": ["create a new prefab asset from a scene object"], + "verification": "Read back gameobject/get by result instanceId and require the requested parent and world position." + }, + { + "id": "prefab/unpack", + "ns": "prefab", + "action": "unpack", + "domain": "prefabs", + "tier": "advanced", + "availability": "supported", + "summary": "Unpack the outermost or complete Prefab relationship for one scene Prefab instance.", + "sideEffect": "destructive", + "editorOnly": true, + "args": [ + {"name": "gameObjectPath", "type": "string", "required": false, "default": ""}, + {"name": "gameObjectInstanceId", "type": "int", "required": false, "default": 0}, + {"name": "full", "type": "bool", "required": false, "default": false} + ], + "rules": { + "exactlyOneOf": [["gameObjectPath", "gameObjectInstanceId"]] + }, + "resultFields": ["instanceId", "name", "unpacked"], + "positiveIntents": ["unpack a prefab instance in the scene", "解包场景中的预制体实例"], + "negativeIntents": ["delete or edit the prefab asset file"], + "verification": "Require unpacked to be true and verify that the scene object no longer has the requested Prefab relationship." + }, + { + "id": "prefab/asset_hierarchy", + "ns": "prefab", + "action": "asset_hierarchy", + "domain": "prefabs", + "tier": "advanced", + "availability": "supported", + "summary": "Read up to 5000 hierarchy nodes from one Prefab asset.", + "sideEffect": "read", + "editorOnly": true, + "args": [ + {"name": "assetPath", "type": "string", "required": true}, + {"name": "depth", "type": "int", "required": false, "default": -1, "min": -1}, + {"name": "includeComponents", "type": "bool", "required": false, "default": false} + ], + "rules": {}, + "resultFields": ["assetPath", "rootName", "root", "root.instanceId", "root.name", "root.activeSelf", "root.childCount", "root.components", "root.children"], + "positiveIntents": ["inspect the hierarchy stored inside a prefab asset", "查看预制体资源内部层级"], + "negativeIntents": ["inspect the active scene hierarchy"], + "verification": "Require the returned root to match asset_get for the same asset and respect the requested depth." + }, + { + "id": "prefab/asset_get", + "ns": "prefab", + "action": "asset_get", + "domain": "prefabs", + "tier": "advanced", + "availability": "supported", + "summary": "Read one GameObject stored inside a Prefab asset, using an empty relative path for its root.", + "sideEffect": "read", + "editorOnly": true, + "args": [ + {"name": "assetPath", "type": "string", "required": true}, + {"name": "gameObjectPath", "type": "string", "required": false, "default": ""} + ], + "rules": {}, + "resultFields": ["assetPath", "gameObjectPath", "instanceId", "name", "tag", "layer", "activeSelf", "transform", "components"], + "positiveIntents": ["inspect one object stored inside a prefab asset", "查看预制体资源中的单个对象"], + "negativeIntents": ["inspect a prefab instance in a loaded scene"], + "verification": "Require assetPath and canonical relative gameObjectPath to round-trip through prefab/asset_hierarchy." + }, + { + "id": "prefab/asset_get_component", + "ns": "prefab", + "action": "asset_get_component", + "domain": "prefabs", + "tier": "advanced", + "availability": "supported", + "summary": "Read up to 200 visible serialized properties from one indexed Component stored in a Prefab asset.", + "sideEffect": "read", + "editorOnly": true, + "args": [ + {"name": "assetPath", "type": "string", "required": true}, + {"name": "typeName", "type": "string", "required": true}, + {"name": "gameObjectPath", "type": "string", "required": false, "default": ""}, + {"name": "index", "type": "int", "required": false, "default": 0, "min": 0} + ], + "rules": {}, + "resultFields": ["assetPath", "gameObjectPath", "typeName", "componentInstanceId", "properties", "properties[].name", "properties[].type", "properties[].value"], + "positiveIntents": ["inspect component fields inside a prefab asset", "查看预制体资源内组件字段"], + "negativeIntents": ["inspect a component on a loaded scene object"], + "verification": "Require the selected type and index and no more than 200 property entries." + }, + { + "id": "prefab/asset_modify_component", + "ns": "prefab", + "action": "asset_modify_component", + "domain": "prefabs", + "tier": "advanced", + "availability": "supported", + "summary": "Set supported visible serialized properties on one indexed Component stored in a Prefab asset.", + "sideEffect": "write", + "editorOnly": true, + "args": [ + {"name": "fields", "type": "FieldPair[]", "required": true, "min": 1, "itemSchema": {"name": "string", "value": "string"}}, + {"name": "assetPath", "type": "string", "required": true}, + {"name": "typeName", "type": "string", "required": true}, + {"name": "gameObjectPath", "type": "string", "required": false, "default": ""}, + {"name": "index", "type": "int", "required": false, "default": 0, "min": 0} + ], + "rules": {}, + "resultFields": ["assetPath", "gameObjectPath", "typeName", "modifiedFields"], + "positiveIntents": ["modify serialized component fields directly in a prefab asset", "直接修改预制体资源中的组件字段"], + "negativeIntents": ["modify a component on a loaded scene object"], + "verification": "Require modifiedFields to equal requested field names and read back each value after reloading the Prefab asset." + }, + { + "id": "prefab/asset_add_component", + "ns": "prefab", + "action": "asset_add_component", + "domain": "prefabs", + "tier": "advanced", + "availability": "supported", + "summary": "Add one resolved Component type directly to a GameObject stored in a Prefab asset.", + "sideEffect": "write", + "editorOnly": true, + "args": [ + {"name": "assetPath", "type": "string", "required": true}, + {"name": "typeName", "type": "string", "required": true}, + {"name": "gameObjectPath", "type": "string", "required": false, "default": ""} + ], + "rules": {}, + "resultFields": ["assetPath", "gameObjectPath", "typeName", "componentInstanceId"], + "positiveIntents": ["add a component directly to a prefab asset", "直接给预制体资源添加组件"], + "negativeIntents": ["add a component to a loaded scene instance"], + "verification": "Reload the Prefab asset and require a component of the returned type at gameObjectPath." + }, + { + "id": "prefab/asset_remove_component", + "ns": "prefab", + "action": "asset_remove_component", + "domain": "prefabs", + "tier": "advanced", + "availability": "supported", + "summary": "Remove one indexed Component directly from a GameObject stored in a Prefab asset.", + "sideEffect": "destructive", + "editorOnly": true, + "args": [ + {"name": "assetPath", "type": "string", "required": true}, + {"name": "typeName", "type": "string", "required": true}, + {"name": "gameObjectPath", "type": "string", "required": false, "default": ""}, + {"name": "index", "type": "int", "required": false, "default": 0, "min": 0} + ], + "rules": {}, + "resultFields": ["assetPath", "gameObjectPath", "typeName", "removed"], + "positiveIntents": ["remove a component directly from a prefab asset", "直接从预制体资源移除组件"], + "negativeIntents": ["remove a component from a loaded scene instance"], + "verification": "Require removed to be true and read back one fewer matching component after reloading the Prefab asset." + }, + { + "id": "prefab/asset_modify_gameobject", + "ns": "prefab", + "action": "asset_modify_gameobject", + "domain": "prefabs", + "tier": "advanced", + "availability": "supported", + "summary": "Change basic properties of one GameObject stored directly in a Prefab asset.", + "sideEffect": "write", + "editorOnly": true, + "args": [ + {"name": "assetPath", "type": "string", "required": true}, + {"name": "gameObjectPath", "type": "string", "required": false, "default": ""}, + {"name": "name", "type": "string", "required": false, "default": ""}, + {"name": "tag", "type": "string", "required": false, "default": ""}, + {"name": "layer", "type": "int", "required": false, "default": -1, "min": -1, "max": 31}, + {"name": "active", "type": "int", "required": false, "default": -1, "enum": [-1, 0, 1]}, + {"name": "isStatic", "type": "int", "required": false, "default": -1, "enum": [-1, 0, 1]} + ], + "rules": { + "atLeastOneMutation": ["name", "tag", "layer", "active", "isStatic"] + }, + "resultFields": ["assetPath", "gameObjectPath", "name"], + "positiveIntents": ["rename or change basic properties inside a prefab asset", "修改预制体资源内对象的基础属性"], + "negativeIntents": ["modify a prefab instance in a loaded scene"], + "verification": "Reload prefab/asset_get and require every requested property to match." + }, + { + "id": "prefab/asset_add_gameobject", + "ns": "prefab", + "action": "asset_add_gameobject", + "domain": "prefabs", + "tier": "advanced", + "availability": "supported", + "summary": "Add an empty child GameObject directly under a parent stored in a Prefab asset.", + "sideEffect": "write", + "editorOnly": true, + "args": [ + {"name": "assetPath", "type": "string", "required": true}, + {"name": "parentPath", "type": "string", "required": false, "default": ""}, + {"name": "name", "type": "string", "required": false, "default": ""} + ], + "rules": {}, + "resultFields": ["assetPath", "gameObjectPath", "name"], + "positiveIntents": ["add a child object directly inside a prefab asset", "直接在预制体资源中添加子对象"], + "negativeIntents": ["add a child to a loaded scene object"], + "verification": "Reload prefab/asset_get at result gameObjectPath and require the returned name." + }, + { + "id": "prefab/asset_remove_gameobject", + "ns": "prefab", + "action": "asset_remove_gameobject", + "domain": "prefabs", + "tier": "advanced", + "availability": "supported", + "summary": "Remove a non-root child GameObject directly from a Prefab asset.", + "sideEffect": "destructive", + "editorOnly": true, + "args": [ + {"name": "assetPath", "type": "string", "required": true}, + {"name": "gameObjectPath", "type": "string", "required": true} + ], + "rules": {}, + "resultFields": ["assetPath", "gameObjectPath", "removed"], + "positiveIntents": ["remove a child object directly from a prefab asset", "直接从预制体资源删除子对象"], + "negativeIntents": ["destroy a loaded scene GameObject or remove the prefab root"], + "verification": "Require removed to be true and require prefab/asset_get at the removed path to report not found after reload." + }, + { + "id": "project/scene.list", + "ns": "project", + "action": "scene.list", + "domain": "scene", + "tier": "core", + "availability": "supported", + "summary": "List all Scene assets in the project and report the active Scene path.", + "sideEffect": "read", + "editorOnly": true, + "args": [], + "rules": {}, + "resultFields": ["activeScenePath", "scenes"], + "positiveIntents": ["list scene assets available in the Unity project", "列出 Unity 项目中的场景资源"], + "negativeIntents": ["list only currently loaded scenes or inspect scene contents"], + "verification": "Require sorted unique Scene asset paths and a valid or empty activeScenePath." + }, + { + "id": "project/scene.open", + "ns": "project", + "action": "scene.open", + "domain": "scene", + "tier": "core", + "availability": "supported", + "summary": "Open one Scene asset in single or additive mode; single mode refuses unsaved loaded Scenes.", + "sideEffect": "lifecycle", + "editorOnly": true, + "args": [ + {"name": "scenePath", "type": "string", "required": true}, + {"name": "mode", "type": "string", "required": false, "default": "single", "enum": ["single", "additive"], "caseInsensitive": true} + ], + "rules": {}, + "resultFields": ["scenePath", "openedPath", "additive"], + "positiveIntents": ["open a Unity scene asset", "打开 Unity 场景资源"], + "negativeIntents": ["inspect the hierarchy of the already active scene"], + "verification": "Require openedPath to match scenePath and the loaded-scene set to reflect the requested mode." + }, + { + "id": "project/scene.save", + "ns": "project", + "action": "scene.save", + "domain": "scene", + "tier": "core", + "availability": "supported", + "summary": "Save the active Scene in place or to a destination path, optionally as a copy.", + "sideEffect": "write", + "editorOnly": true, + "args": [ + {"name": "scenePath", "type": "string", "required": false, "default": ""}, + {"name": "saveAsCopy", "type": "bool", "required": false, "default": false} + ], + "rules": { + "requiresWhen": [ + {"arg": "saveAsCopy", "equals": true, "requires": ["scenePath"]} + ] + }, + "resultFields": ["saved", "scenePath", "saveAsCopy"], + "positiveIntents": ["save the active Unity scene", "保存当前活动 Unity 场景"], + "negativeIntents": ["save a different loaded scene selected by path"], + "verification": "Require saved to be true, the destination Scene asset to exist, and the active Scene to be clean unless saved as a copy." + }, + { + "id": "project/selection.get", + "ns": "project", + "action": "selection.get", + "domain": "objects", + "tier": "advanced", + "availability": "supported", + "summary": "Read the current Unity Editor selection and its active object.", + "sideEffect": "read", + "editorOnly": true, + "args": [], + "rules": {}, + "resultFields": ["activeInstanceId", "objects", "objects[].instanceId", "objects[].name", "objects[].typeName", "objects[].assetPath", "objects[].hierarchyPath"], + "positiveIntents": ["inspect the current Unity Editor selection", "查看 Unity 编辑器当前选择"], + "negativeIntents": ["find objects by name, tag, or component"], + "verification": "Require activeInstanceId to be zero or a member of the returned object IDs." + }, + { + "id": "project/selection.set", + "ns": "project", + "action": "selection.set", + "domain": "objects", + "tier": "advanced", + "availability": "supported", + "summary": "Set or clear the Unity Editor selection from object instance IDs and asset paths; invalid entries are ignored.", + "sideEffect": "write", + "editorOnly": true, + "args": [ + {"name": "instanceIds", "type": "int[]", "required": false, "default": null, "itemSchema": "int"}, + {"name": "assetPaths", "type": "string[]", "required": false, "default": null, "itemSchema": "string"} + ], + "rules": {}, + "resultFields": ["count", "activeInstanceId", "instanceIds"], + "positiveIntents": ["select objects or assets in the Unity Editor", "在 Unity 编辑器中选择对象或资源"], + "negativeIntents": ["find an object by hierarchy path or name"], + "verification": "Read back project/selection.get and require the accepted distinct instance IDs to match." + }, + { + "id": "project/asset.list", + "ns": "project", + "action": "asset.list", + "domain": "assets", + "tier": "core", + "availability": "supported", + "summary": "List sorted project asset paths matching a Unity AssetDatabase filter and optional search folders.", + "sideEffect": "read", + "editorOnly": true, + "args": [ + {"name": "filter", "type": "string", "required": false, "default": ""}, + {"name": "folders", "type": "string[]", "required": false, "default": null, "itemSchema": "string"} + ], + "rules": {}, + "resultFields": ["filter", "folders", "assetPaths"], + "positiveIntents": ["find project assets by Unity type or name filter", "按 Unity 类型或名称筛选项目资源"], + "negativeIntents": ["move, copy, import, or delete an asset"], + "verification": "Require sorted unique paths whose AssetDatabase search matches the supplied filter and folders." + }, + { + "id": "project/asset.import", + "ns": "project", + "action": "asset.import", + "domain": "assets", + "tier": "advanced", + "availability": "supported", + "summary": "Import one asset path, optionally synchronously, while guarding loaded Scenes and script-triggered domain reloads.", + "sideEffect": "lifecycle", + "editorOnly": true, + "args": [ + {"name": "assetPath", "type": "string", "required": true}, + {"name": "forceSynchronousImport", "type": "bool", "required": false, "default": false} + ], + "rules": {}, + "resultFields": ["assetPath", "imported", "exists"], + "positiveIntents": ["import a newly written asset into Unity", "把新写入的资源导入 Unity"], + "negativeIntents": ["force-update an already imported asset"], + "verification": "Require imported and exists to be true and read back the path with project/asset.list." + }, + { + "id": "project/asset.reimport", + "ns": "project", + "action": "asset.reimport", + "domain": "assets", + "tier": "advanced", + "availability": "supported", + "summary": "Force-update one imported asset path, optionally synchronously, with Scene and domain-reload guards.", + "sideEffect": "lifecycle", + "editorOnly": true, + "args": [ + {"name": "assetPath", "type": "string", "required": true}, + {"name": "forceSynchronousImport", "type": "bool", "required": false, "default": false} + ], + "rules": {}, + "resultFields": ["assetPath", "imported", "exists"], + "positiveIntents": ["force Unity to reimport an existing asset", "强制 Unity 重新导入已有资源"], + "negativeIntents": ["import a new asset for the first time"], + "verification": "Require imported and exists to be true and require the target path to remain resolvable." + }, + { + "id": "asset/move", + "ns": "asset", + "action": "move", + "domain": "assets", + "tier": "core", + "availability": "supported", + "summary": "Move or rename one Unity asset or folder, creating missing destination parent folders.", + "sideEffect": "write", + "editorOnly": true, + "args": [ + {"name": "sourcePath", "type": "string", "required": true}, + {"name": "destinationPath", "type": "string", "required": true} + ], + "rules": {}, + "resultFields": ["oldPath", "newPath"], + "positiveIntents": ["move or rename a Unity asset", "移动或重命名 Unity 资源"], + "negativeIntents": ["copy an asset while retaining the source"], + "verification": "Require the old path to be absent and the new path to resolve after the operation." + }, + { + "id": "asset/copy", + "ns": "asset", + "action": "copy", + "domain": "assets", + "tier": "core", + "availability": "supported", + "summary": "Copy one Unity asset or folder to a new path, creating missing destination parent folders.", + "sideEffect": "write", + "editorOnly": true, + "args": [ + {"name": "sourcePath", "type": "string", "required": true}, + {"name": "destinationPath", "type": "string", "required": true} + ], + "rules": {}, + "resultFields": ["sourcePath", "destinationPath"], + "positiveIntents": ["copy a Unity asset to another path", "复制 Unity 资源到新路径"], + "negativeIntents": ["move or rename an asset"], + "verification": "Require both paths to resolve and their asset GUIDs to differ." + }, + { + "id": "asset/delete", + "ns": "asset", + "action": "delete", + "domain": "assets", + "tier": "advanced", + "availability": "supported", + "summary": "Delete one or more Unity assets or folders and report partial failures.", + "sideEffect": "destructive", + "editorOnly": true, + "args": [ + {"name": "assetPath", "type": "string", "required": false, "default": ""}, + {"name": "assetPaths", "type": "string[]", "required": false, "default": null, "itemSchema": "string"} + ], + "rules": { + "atLeastOne": [["assetPath", "assetPaths"]] + }, + "resultFields": ["deletedPaths", "failedPaths"], + "positiveIntents": ["delete Unity assets or folders", "删除 Unity 资源或文件夹"], + "negativeIntents": ["destroy a GameObject in a loaded scene"], + "verification": "Count completion only when failedPaths is empty and every deletedPath no longer resolves." + }, + { + "id": "asset/create_folder", + "ns": "asset", + "action": "create_folder", + "domain": "assets", + "tier": "core", + "availability": "supported", + "summary": "Create a Unity AssetDatabase folder recursively, or return the existing folder's GUID.", + "sideEffect": "write", + "editorOnly": true, + "args": [ + {"name": "folderPath", "type": "string", "required": true} + ], + "rules": {}, + "resultFields": ["folderPath", "guid"], + "positiveIntents": ["create a folder in the Unity Assets tree", "在 Unity Assets 中创建文件夹"], + "negativeIntents": ["create an operating-system folder outside the Unity project"], + "verification": "Require a nonempty guid and require the returned folderPath to be a valid AssetDatabase folder." + }, + { + "id": "scene/hierarchy", + "ns": "scene", + "action": "hierarchy", + "domain": "scene", + "tier": "core", + "availability": "supported", + "summary": "Read up to 5000 hierarchy nodes from the active Scene plus DontDestroyOnLoad roots.", + "sideEffect": "read", + "editorOnly": true, + "args": [ + {"name": "depth", "type": "int", "required": false, "default": -1, "min": -1}, + {"name": "includeComponents", "type": "bool", "required": false, "default": false} + ], + "rules": {}, + "resultFields": ["sceneName", "scenePath", "roots", "dontDestroyOnLoadRoots"], + "positiveIntents": ["inspect the active scene hierarchy", "查看活动场景层级"], + "negativeIntents": ["list Scene assets in the project or inspect a prefab asset"], + "verification": "Require scenePath to match the active Scene and every returned branch to respect the requested depth." + }, + { + "id": "screenshot/scene_view", + "ns": "screenshot", + "action": "scene_view", + "domain": "capture", + "tier": "core", + "availability": "supported", + "summary": "Render the last active Scene View camera to a PNG file.", + "sideEffect": "write", + "editorOnly": true, + "args": [ + {"name": "savePath", "type": "string", "required": true}, + {"name": "width", "type": "int", "required": false, "default": 0, "min": 0}, + {"name": "height", "type": "int", "required": false, "default": 0, "min": 0} + ], + "rules": {}, + "resultFields": ["savePath", "width", "height"], + "positiveIntents": ["capture the current Unity Scene View", "截取当前 Unity 场景视图"], + "negativeIntents": ["capture the Game View or a runtime frame"], + "verification": "Require the PNG file to exist before completion and require its dimensions to match the result." + }, + { + "id": "screenshot/game_view", + "ns": "screenshot", + "action": "game_view", + "domain": "capture", + "tier": "core", + "availability": "supported", + "summary": "Render a scene camera in edit mode or schedule a Game View screenshot for end-of-frame in play mode.", + "sideEffect": "write", + "editorOnly": true, + "args": [ + {"name": "savePath", "type": "string", "required": true}, + {"name": "width", "type": "int", "required": false, "default": 0, "min": 0}, + {"name": "height", "type": "int", "required": false, "default": 0, "min": 0}, + {"name": "superSize", "type": "int", "required": false, "default": 1, "min": 1} + ], + "rules": {}, + "resultFields": ["savePath", "width", "height"], + "positiveIntents": ["capture the Unity Game View or gameplay frame", "截取 Unity 游戏视图或运行画面"], + "negativeIntents": ["capture the Scene View editor camera"], + "verification": "Wait for the PNG file to exist, then require its dimensions to match the result." + }, + { + "id": "profiler/start", + "ns": "profiler", + "action": "start", + "domain": "capture", + "tier": "advanced", + "availability": "supported", + "summary": "Start Unity Profiler recording, optionally enabling deep profiling and binary logging.", + "sideEffect": "lifecycle", + "editorOnly": true, + "args": [ + {"name": "deep", "type": "bool", "required": false, "default": false}, + {"name": "logFile", "type": "string", "required": false, "default": ""} + ], + "rules": {}, + "resultFields": ["started", "deepProfiling", "logFile"], + "positiveIntents": ["start recording Unity Profiler data", "开始记录 Unity Profiler 数据"], + "negativeIntents": ["inspect current profiler state without changing it"], + "verification": "Read profiler/status and require enabled, deepProfiling, and logFile to match the request." + }, + { + "id": "profiler/stop", + "ns": "profiler", + "action": "stop", + "domain": "capture", + "tier": "advanced", + "availability": "supported", + "summary": "Stop Unity Profiler recording and disable binary logging.", + "sideEffect": "lifecycle", + "editorOnly": true, + "args": [], + "rules": {}, + "resultFields": ["stopped"], + "positiveIntents": ["stop recording Unity Profiler data", "停止记录 Unity Profiler 数据"], + "negativeIntents": ["save already recorded profiler data to a file"], + "verification": "Require stopped to be true and profiler/status enabled to be false." + }, + { + "id": "profiler/status", + "ns": "profiler", + "action": "status", + "domain": "capture", + "tier": "advanced", + "availability": "supported", + "summary": "Read Unity Profiler enabled, deep-profiling, log-file, and frame-count state.", + "sideEffect": "read", + "editorOnly": true, + "args": [], + "rules": {}, + "resultFields": ["enabled", "deepProfiling", "logFile", "frameCount"], + "positiveIntents": ["check whether the Unity Profiler is recording", "检查 Unity Profiler 是否正在记录"], + "negativeIntents": ["check general Unity Editor readiness"], + "verification": "Require frameCount to be nonnegative and state to round-trip with profiler/start and profiler/stop." + }, + { + "id": "profiler/save", + "ns": "profiler", + "action": "save", + "domain": "capture", + "tier": "advanced", + "availability": "supported", + "summary": "Save recorded Unity Profiler data to a raw profile file when the Unity version supports it.", + "sideEffect": "write", + "editorOnly": true, + "args": [ + {"name": "savePath", "type": "string", "required": true} + ], + "rules": {}, + "resultFields": ["savePath", "saved"], + "positiveIntents": ["save captured Unity Profiler data", "保存已捕获的 Unity Profiler 数据"], + "negativeIntents": ["start or stop profiler recording"], + "verification": "Require saved to be true and the returned file to exist and be nonempty." + }, + { + "id": "session/list", + "ns": "session", + "action": "list", + "domain": "control", + "tier": "control-plane", + "availability": "supported", + "summary": "List active named REPL sessions and whether each owns a compiler or executor.", + "sideEffect": "read", + "editorOnly": false, + "args": [], + "rules": {}, + "resultFields": ["sessions", "sessions[].sessionId", "sessions[].hasCompiler", "sessions[].hasExecutor"], + "positiveIntents": ["list active unity-cli REPL sessions", "列出活动的 unity-cli REPL 会话"], + "negativeIntents": ["list Unity scenes or GameObjects"], + "verification": "Require unique nonempty session IDs and cross-check each entry with session/inspect." + }, + { + "id": "session/inspect", + "ns": "session", + "action": "inspect", + "domain": "control", + "tier": "control-plane", + "availability": "supported", + "summary": "Inspect one named REPL session supplied through the request session ID or CLI --session.", + "sideEffect": "read", + "editorOnly": false, + "args": [ + {"name": "sessionId", "type": "request-session", "required": true} + ], + "rules": {}, + "resultFields": ["exists", "hasCompiler", "hasExecutor"], + "positiveIntents": ["inspect a named unity-cli REPL session", "查看命名的 unity-cli REPL 会话"], + "negativeIntents": ["inspect a Unity scene object or C# component"], + "verification": "Require missing session IDs to fail; otherwise require exists to equal hasCompiler or hasExecutor." + }, + { + "id": "session/reset", + "ns": "session", + "action": "reset", + "domain": "control", + "tier": "control-plane", + "availability": "supported", + "summary": "Reset compiler and executor state for one named REPL session supplied through the request session ID or CLI --session.", + "sideEffect": "destructive", + "editorOnly": false, + "args": [ + {"name": "sessionId", "type": "request-session", "required": true} + ], + "rules": {}, + "resultFields": ["existed", "hasCompiler", "hasExecutor", "hasCompilerAfter", "hasExecutorAfter"], + "positiveIntents": ["reset a named unity-cli REPL session", "重置命名的 unity-cli REPL 会话"], + "negativeIntents": ["reset the Unity scene or Editor state"], + "verification": "Require hasCompilerAfter and hasExecutorAfter to be false and confirm with session/inspect." + }, + { + "id": "command/list", + "ns": "command", + "action": "list", + "domain": "control", + "tier": "control-plane", + "availability": "supported", + "summary": "List registered built-in and custom command descriptors; descriptors omit defaults, constraints, and result schemas.", + "sideEffect": "read", + "editorOnly": false, + "args": [], + "rules": {}, + "resultFields": ["commands", "commands[].id", "commands[].commandNamespace", "commands[].action", "commands[].summary", "commands[].editorOnly", "commands[].runOnMainThread", "commands[].declaringType", "commands[].methodName", "commands[].commandType", "commands[].arguments"], + "positiveIntents": ["discover registered Unity command descriptors", "发现已注册的 Unity 命令描述符"], + "negativeIntents": ["list scene assets, GameObjects, or reusable snippets"], + "verification": "Require unique command IDs, valid descriptor fields, and inclusion of command/list itself." + } + ] +} diff --git a/skills/unity-cli/scripts/cli/core_bridge.py b/skills/unity-cli/scripts/cli/core_bridge.py index d3e6d3b..8633f9b 100644 --- a/skills/unity-cli/scripts/cli/core_bridge.py +++ b/skills/unity-cli/scripts/cli/core_bridge.py @@ -1,5 +1,6 @@ """Dynamic bridge to csharpconsole_core from an installed Unity package.""" +import errno import json import os import sys @@ -12,6 +13,39 @@ _RETRY_DELAY_S = 1 +def _is_connection_refused(error): + """Return True only when a request clearly failed before connecting. + + A timeout or a reset can happen after Unity has already executed a + mutating command. Retrying those failures would risk applying the mutation + twice, so the automatic domain-reload retry is intentionally limited to a + refused connection. + """ + pending = [error] + seen = set() + refused_codes = {errno.ECONNREFUSED, 10061} # POSIX and Winsock + + while pending: + current = pending.pop() + if id(current) in seen: + continue + seen.add(id(current)) + + if isinstance(current, ConnectionRefusedError): + return True + if getattr(current, "errno", None) in refused_codes: + return True + if getattr(current, "winerror", None) in refused_codes: + return True + + for attribute in ("reason", "__cause__", "__context__"): + nested = getattr(current, attribute, None) + if isinstance(nested, BaseException): + pending.append(nested) + + return False + + def _find_pkg_dir(project_root): """Locate the package root directory. Returns (pkg_dir, core_path) or (None, None).""" root = Path(project_root) @@ -80,7 +114,7 @@ def _ensure_path(core_path): def _make_post_with_retry(transport_http, state, default_timeout): - """Create a POST function that retries once when the server is unreachable.""" + """Create a POST function that retries one refused connection.""" # The urllib-based core raises TransportError for every transport failure # (connection refused, timeout, non-2xx). Older requests-based cores raised # OSError subclasses instead. Catch both so the domain-reload retry survives @@ -94,7 +128,9 @@ def _post(endpoint, payload, timeout=None): url_base = state.current_server_base_url() try: return transport_http.post_json(url_base, endpoint, payload, t) - except transient: + except transient as error: + if not _is_connection_refused(error): + raise time.sleep(_RETRY_DELAY_S) return transport_http.post_json(url_base, endpoint, payload, t) @@ -144,6 +180,7 @@ def __init__(self, project_root, ip="127.0.0.1", port=DEFAULT_EDITOR_PORT, mode= self._state = state self._session_id = client_base.generate_session_id(session_id) + self._timeout = timeout self._post = _make_post_with_retry(transport_http, state, timeout) self._mode_name = lambda: state.current_mode_name() # Placeholders required by csharpconsole_core API for persistent @@ -174,6 +211,7 @@ def command(self, namespace, action, args=None): return self._cmd.request_command( self._post, self._parser.parse_command_http_response, self._mode_name, namespace, action, self._session_id, args, + timeout_seconds=self._timeout, ) def health(self): diff --git a/skills/unity-cli/scripts/cli/cs.py b/skills/unity-cli/scripts/cli/cs.py index a7342ba..8389918 100644 --- a/skills/unity-cli/scripts/cli/cs.py +++ b/skills/unity-cli/scripts/cli/cs.py @@ -15,6 +15,7 @@ sys.path.insert(0, os.path.dirname(_CLI_DIR)) from cli import PACKAGE_NAME, DEFAULT_SOURCE, DEFAULT_EDITOR_PORT, DEFAULT_RUNTIME_PORT +from cli.command_index import DOMAINS, TIERS from cli.version_check import get_plugin_version, is_aligned, parse_semver @@ -472,8 +473,15 @@ def cmd_status(root, args, agent_root=None): return rc -def _filter_commands_by_type(result, type_filter): - """Filter list-commands result by commandType field. +def _filter_commands( + result, + type_filter="all", + domain=None, + tier=None, + command_id=None, + include_blocked=False, +): + """Annotate and filter a list-commands result using the static manifest. Handles three response shapes: - data.resultJson as a JSON string (canonical post-2024 wire format) @@ -482,8 +490,8 @@ def _filter_commands_by_type(result, type_filter): In all three cases we update *both* resultJson and data.commands so downstream callers (notably _slim_result) see consistent filtered data. """ - if type_filter == "all": - return result + from cli.command_index import filter_live_commands + data = result.get("data", {}) raw_rj = data.get("resultJson") if isinstance(raw_rj, str): @@ -496,7 +504,14 @@ def _filter_commands_by_type(result, type_filter): else: rj = data commands = rj.get("commands", []) - filtered = [c for c in commands if c.get("commandType", "builtin") == type_filter] + filtered = filter_live_commands( + commands, + type_filter=type_filter, + domain=domain, + tier=tier, + command_id=command_id, + include_blocked=include_blocked, + ) rj = dict(rj) rj["commands"] = filtered data = dict(data) @@ -507,9 +522,48 @@ def _filter_commands_by_type(result, type_filter): data["resultJson"] = rj result = dict(result) result["data"] = data + if result.get("ok"): + result["summary"] = f"Listed {len(filtered)} command(s)" return result +def cmd_list_commands_offline(args): + """List committed routing contracts without a Unity project or service.""" + from cli.command_index import command_contracts, contract_descriptors, filter_live_commands + + contracts = command_contracts() + commands = filter_live_commands( + contract_descriptors(contracts), + type_filter=args.cmd_type, + domain=args.domain, + tier=args.tier, + command_id=args.command_id, + include_blocked=args.include_blocked, + contracts=contracts, + ) + result = { + "ok": True, + "exitCode": 0, + "summary": f"Listed {len(commands)} committed command contract(s)", + "data": {"commands": commands}, + } + if args.as_json: + json.dump( + result if args.verbose else _slim_result(result), + sys.stdout, + ensure_ascii=False, + indent=2, + ) + print() + else: + for command in commands: + print( + f"{command['canonicalId']}\t{command['tier']}\t" + f"{command.get('summary', '')}" + ) + return 0 + + # ── Catalog commands ─────────────────────────────────────────────────── def _resolve_catalog_path(root, args): @@ -1655,10 +1709,21 @@ def _resolve_input(parser, args): args.namespace = ns args.action = action cmd_args = payload.get("args") - if cmd_args is None or isinstance(cmd_args, str): - args.args = cmd_args - else: - args.args = json.dumps(cmd_args, ensure_ascii=False) + from cli.command_index import CommandContractError, validate_command_request + try: + cmd_args = validate_command_request( + ns, + action, + cmd_args, + session_id=getattr(args, "session", None), + ) + except CommandContractError as e: + parser.error(f"--input: {e}") + args.args = ( + cmd_args + if cmd_args is None or isinstance(cmd_args, str) + else json.dumps(cmd_args, ensure_ascii=False) + ) _apply_conn_opts(parser, args, payload) elif cmd == "batch": if isinstance(payload, list): @@ -1672,6 +1737,11 @@ def _resolve_input(parser, args): parser.error("--input for batch must be a JSON array or object") if not isinstance(items, list): parser.error('--input for batch needs a "commands" array (or a bare JSON array)') + from cli.command_index import CommandContractError, validate_batch_items + try: + validate_batch_items(items, session_id=getattr(args, "session", None)) + except CommandContractError as e: + parser.error(f"--input: {e}") args.commands = json.dumps(items, ensure_ascii=False) def main(): # Shared flags available on every subcommand. @@ -1731,6 +1801,35 @@ def main(): sp_lc = sub.add_parser("list-commands", parents=[shared], help="List available commands") sp_lc.add_argument("--type", choices=["builtin", "custom", "all"], default="all", dest="cmd_type", help="Filter by command type (default: all)") + sp_lc.add_argument( + "--domain", + choices=DOMAINS + ("custom",), + default=None, + help="Show one agent-facing domain instead of the full registry", + ) + sp_lc.add_argument( + "--tier", + choices=TIERS, + default=None, + help="Filter by visibility tier", + ) + sp_lc.add_argument( + "--id", + dest="command_id", + default=None, + metavar="NAMESPACE/ACTION", + help="Show one exact canonical command id", + ) + sp_lc.add_argument( + "--include-blocked", + action="store_true", + help="Include retained commands that are known not to execute successfully", + ) + sp_lc.add_argument( + "--offline", + action="store_true", + help="Read committed routing contracts without connecting to Unity", + ) sp_batch = sub.add_parser("batch", parents=[shared], help="Execute multiple commands in one request") sp_batch.add_argument("--stop-on-error", action="store_true", @@ -1849,6 +1948,9 @@ def main(): elif getattr(args, "code", None) is None: p.error('exec requires --input (JSON {"code":..}) or --file ') + if args.cmd == "list-commands" and args.offline: + sys.exit(cmd_list_commands_offline(args)) + root = find_project_root(args.project) # agent_root keys the per-project package-path cache; derive it from the # resolved project root (never raw cwd) so it stays stable across agents/cwd. @@ -1966,7 +2068,14 @@ def _refresh(): def _list_commands_filtered(session, a): r = session.list_commands() - return _filter_commands_by_type(r, a.cmd_type) + return _filter_commands( + r, + type_filter=a.cmd_type, + domain=a.domain, + tier=a.tier, + command_id=a.command_id, + include_blocked=a.include_blocked, + ) dispatch = { "exec": lambda: s.exec(args.code),