From 1b988aeb57a5f43a761662fc66944fd3bcc31c8e Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 27 Jul 2026 08:14:28 +0900 Subject: [PATCH 01/16] Give every documented tool a skill parameter table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only focus-window cannot have one (it takes no parameters); the six tools that were missing a table were missing it for no structural reason, which left their help text with no source to read from. execute-dynamic-code's prose bullets become the standard table, since keeping both would put two descriptions of one parameter in a single file — the drift this direction exists to remove. --code-file keeps its bullet verbatim below the table: it is a CLI-only flag with no schema property, so no table row can hold it. The hidden CompileOnly property is deliberately absent. The pause-point skill covers five tools, so it gains a Parameters section whose "### " headings carry a description line and one table each. simulate-keyboard and simulate-mouse-input also take their Input System prerequisites into the skill description, which until now existed only in the generated catalog — the one class of fact that must not be lost when the skill becomes the source. --- .../uloop-execute-dynamic-code/SKILL.md | 12 +++- .agents/skills/uloop-pause-point/SKILL.md | 57 +++++++++++++++++++ .../skills/uloop-simulate-keyboard/SKILL.md | 2 +- .../uloop-simulate-mouse-input/SKILL.md | 2 +- .../uloop-execute-dynamic-code/SKILL.md | 12 +++- .claude/skills/uloop-pause-point/SKILL.md | 57 +++++++++++++++++++ .../skills/uloop-simulate-keyboard/SKILL.md | 2 +- .../uloop-simulate-mouse-input/SKILL.md | 2 +- .../CliOnlyTools~/PausePoint/Skill/SKILL.md | 57 +++++++++++++++++++ .../ExecuteDynamicCode/Skill/SKILL.md | 12 +++- .../SimulateKeyboard/Skill/SKILL.md | 2 +- .../SimulateMouseInput/Skill/SKILL.md | 2 +- 12 files changed, 204 insertions(+), 15 deletions(-) diff --git a/.agents/skills/uloop-execute-dynamic-code/SKILL.md b/.agents/skills/uloop-execute-dynamic-code/SKILL.md index 23aec2b682..f02d3336d9 100644 --- a/.agents/skills/uloop-execute-dynamic-code/SKILL.md +++ b/.agents/skills/uloop-execute-dynamic-code/SKILL.md @@ -16,10 +16,16 @@ Live state injection: when a running PlayMode session is merely in the wrong sta ## Parameters -- `--code ''`: Inline C# statements to execute. Use direct statements only; `return` is optional, and `using` directives may appear at the top of the snippet. +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--code` | string | - | Inline C# statements to execute. Direct statements only; `return` is optional, and `using` directives may appear at the top of the snippet. | +| `--parameters` | object | - | Shell-quoted JSON object literal for reusing a snippet with varying data or keeping values outside the code. Values are exposed as `parameters["param0"]`, `parameters["param1"]`, and so on. Omit for most snippets; never pass a JSON string value. | +| `--wait-for-domain-reload` | flag | - | Wait for Domain Reload recovery after snippets that intentionally trigger Unity script reload or import work. Omit for normal inspection and editor-state workflows. | +| `--yield-to-foreground-requests` | flag | - | Allow foreground requests to preempt this execution | + +CLI-only flag, accepted instead of a schema parameter: + - `--code-file `: Read the C# statements from a file instead of `--code`. Use this when the active shell or launcher cannot preserve inline code exactly. Exactly one of `--code` or `--code-file` is required; combining them is an error. -- `--parameters {}` (advanced, optional): Pass a shell-quoted JSON object literal when reusing a snippet with varying data or when keeping values outside the code. Values are exposed as `parameters["param0"]`, `parameters["param1"]`, and so on. Omit this flag for most snippets. Do not pass a JSON string value such as `"{\"param0\":\"value\"}"`. -- `--wait-for-domain-reload` (optional): Wait for Domain Reload recovery after snippets that intentionally trigger Unity script reload or import work. Omit this for normal inspection and editor-state workflows. ## Code Rules diff --git a/.agents/skills/uloop-pause-point/SKILL.md b/.agents/skills/uloop-pause-point/SKILL.md index 31e64add12..d68d90dfd2 100644 --- a/.agents/skills/uloop-pause-point/SKILL.md +++ b/.agents/skills/uloop-pause-point/SKILL.md @@ -32,6 +32,63 @@ The response returns the derived marker `Id` (`Assets/Scripts/Enemy.cs:42`), the A hit pauses Unity at the next frame boundary — the patched method and the rest of that frame still run to completion. Only `CapturedVariables` is evidence of the values at the patched line; state read after the pause (for example via `execute-dynamic-code`) may already have advanced past it. +## Parameters + +One skill covers several commands, so each command's schema parameters have their own table below. +CLI-only flags (`--await`, `--trigger`, `--resume-play`, `--expect`, `--captured-variables`, +`--captured-variable-names`, `--matching-logs-max-count`) are described in the sections above; only +parameters Unity itself accepts appear here. + +### enable-pause-point + +Enable a pause point so Unity pauses when that code path is reached, either by a named UloopPausePoint.Pause marker (Id) or by resolving a source file and line (File+Line) + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--id` | string | - | Named pause point id passed to UloopPausePoint.Pause. Mutually exclusive with File/Line | +| `--file` | string | - | Project-relative source file path to patch a pause point into. Requires Line; mutually exclusive with Id | +| `--line` | integer | - | 1-based source line to resolve within File. Requires File; mutually exclusive with Id | +| `--timeout-seconds` | integer | `30` | Seconds before the enable request expires and stops pausing late hits | +| `--mode` | enum | `single-shot` | Capture mode: single-shot pauses once, continuous pauses on every hit, trace records hits without pausing | +| `--max-history` | integer | `20` | Maximum number of captured hit frames to retain (1-100) | +| `--max-preview-elements` | integer | `10` | Maximum number of elements to include in a captured collection's preview (1-1000). The value set at enable time also caps the previews in every later pause-point-status response for that marker; status has no flag to change it. | + +### clear-pause-point + +Clear one or all named UloopPausePoint.Pause markers + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--id` | string | - | Named pause point id to clear | +| `--all` | flag | - | Clear every active pause point marker | + +### enable-watch + +Register a C# expression to evaluate on each paused Play Mode step + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--id` | string | - | Unique watch expression identifier | +| `--expression` | string | - | C# expression returning an object; UloopPausePoint.TryGetCapturedValue can read the latest raw capture | +| `--max-history` | integer | `20` | Maximum number of watch evaluations to retain (1-100) | + +### get-watch-values + +Show registered watch expression values and bounded evaluation history + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--id` | string | - | Optional watch expression identifier; omit to return all watches | + +### clear-watch + +Clear one or all registered C# watch expressions + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--id` | string | - | Watch expression identifier to clear | +| `--all` | flag | - | Clear every registered watch expression | + ## Capture Modes and History Choose the capture mode when enabling a pause point: diff --git a/.agents/skills/uloop-simulate-keyboard/SKILL.md b/.agents/skills/uloop-simulate-keyboard/SKILL.md index a342d9a2bb..7fc8612fb0 100644 --- a/.agents/skills/uloop-simulate-keyboard/SKILL.md +++ b/.agents/skills/uloop-simulate-keyboard/SKILL.md @@ -1,7 +1,7 @@ --- name: uloop-simulate-keyboard toolName: simulate-keyboard -description: "Simulate keyboard input in PlayMode through Unity Input System. Use for key presses, holds (via Press --duration or KeyDown/KeyUp), releases, and game controls such as WASD or Space." +description: "Simulate keyboard input in PlayMode through Unity Input System. Use for key presses, holds (via Press --duration or KeyDown/KeyUp), releases, and game controls such as WASD or Space. Requires the Input System package (com.unity.inputsystem)." --- # Task diff --git a/.agents/skills/uloop-simulate-mouse-input/SKILL.md b/.agents/skills/uloop-simulate-mouse-input/SKILL.md index a8be03a2e7..605f7f3aaf 100644 --- a/.agents/skills/uloop-simulate-mouse-input/SKILL.md +++ b/.agents/skills/uloop-simulate-mouse-input/SKILL.md @@ -1,7 +1,7 @@ --- name: uloop-simulate-mouse-input toolName: simulate-mouse-input -description: "Simulate Mouse.current input in PlayMode through Unity Input System. Use for gameplay mouse clicks, long-press (LongPress), movement delta (MoveDelta/SmoothDelta), or scroll. Use simulate-mouse-ui for UI." +description: "Simulate Mouse.current input in PlayMode through Unity Input System. Use for gameplay mouse clicks, long-press (LongPress), movement delta (MoveDelta/SmoothDelta), or scroll. Use simulate-mouse-ui for UI. Requires the Input System package and Active Input Handling set to 'Input System Package (New)' or 'Both'." --- # Task diff --git a/.claude/skills/uloop-execute-dynamic-code/SKILL.md b/.claude/skills/uloop-execute-dynamic-code/SKILL.md index 23aec2b682..f02d3336d9 100644 --- a/.claude/skills/uloop-execute-dynamic-code/SKILL.md +++ b/.claude/skills/uloop-execute-dynamic-code/SKILL.md @@ -16,10 +16,16 @@ Live state injection: when a running PlayMode session is merely in the wrong sta ## Parameters -- `--code ''`: Inline C# statements to execute. Use direct statements only; `return` is optional, and `using` directives may appear at the top of the snippet. +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--code` | string | - | Inline C# statements to execute. Direct statements only; `return` is optional, and `using` directives may appear at the top of the snippet. | +| `--parameters` | object | - | Shell-quoted JSON object literal for reusing a snippet with varying data or keeping values outside the code. Values are exposed as `parameters["param0"]`, `parameters["param1"]`, and so on. Omit for most snippets; never pass a JSON string value. | +| `--wait-for-domain-reload` | flag | - | Wait for Domain Reload recovery after snippets that intentionally trigger Unity script reload or import work. Omit for normal inspection and editor-state workflows. | +| `--yield-to-foreground-requests` | flag | - | Allow foreground requests to preempt this execution | + +CLI-only flag, accepted instead of a schema parameter: + - `--code-file `: Read the C# statements from a file instead of `--code`. Use this when the active shell or launcher cannot preserve inline code exactly. Exactly one of `--code` or `--code-file` is required; combining them is an error. -- `--parameters {}` (advanced, optional): Pass a shell-quoted JSON object literal when reusing a snippet with varying data or when keeping values outside the code. Values are exposed as `parameters["param0"]`, `parameters["param1"]`, and so on. Omit this flag for most snippets. Do not pass a JSON string value such as `"{\"param0\":\"value\"}"`. -- `--wait-for-domain-reload` (optional): Wait for Domain Reload recovery after snippets that intentionally trigger Unity script reload or import work. Omit this for normal inspection and editor-state workflows. ## Code Rules diff --git a/.claude/skills/uloop-pause-point/SKILL.md b/.claude/skills/uloop-pause-point/SKILL.md index 31e64add12..d68d90dfd2 100644 --- a/.claude/skills/uloop-pause-point/SKILL.md +++ b/.claude/skills/uloop-pause-point/SKILL.md @@ -32,6 +32,63 @@ The response returns the derived marker `Id` (`Assets/Scripts/Enemy.cs:42`), the A hit pauses Unity at the next frame boundary — the patched method and the rest of that frame still run to completion. Only `CapturedVariables` is evidence of the values at the patched line; state read after the pause (for example via `execute-dynamic-code`) may already have advanced past it. +## Parameters + +One skill covers several commands, so each command's schema parameters have their own table below. +CLI-only flags (`--await`, `--trigger`, `--resume-play`, `--expect`, `--captured-variables`, +`--captured-variable-names`, `--matching-logs-max-count`) are described in the sections above; only +parameters Unity itself accepts appear here. + +### enable-pause-point + +Enable a pause point so Unity pauses when that code path is reached, either by a named UloopPausePoint.Pause marker (Id) or by resolving a source file and line (File+Line) + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--id` | string | - | Named pause point id passed to UloopPausePoint.Pause. Mutually exclusive with File/Line | +| `--file` | string | - | Project-relative source file path to patch a pause point into. Requires Line; mutually exclusive with Id | +| `--line` | integer | - | 1-based source line to resolve within File. Requires File; mutually exclusive with Id | +| `--timeout-seconds` | integer | `30` | Seconds before the enable request expires and stops pausing late hits | +| `--mode` | enum | `single-shot` | Capture mode: single-shot pauses once, continuous pauses on every hit, trace records hits without pausing | +| `--max-history` | integer | `20` | Maximum number of captured hit frames to retain (1-100) | +| `--max-preview-elements` | integer | `10` | Maximum number of elements to include in a captured collection's preview (1-1000). The value set at enable time also caps the previews in every later pause-point-status response for that marker; status has no flag to change it. | + +### clear-pause-point + +Clear one or all named UloopPausePoint.Pause markers + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--id` | string | - | Named pause point id to clear | +| `--all` | flag | - | Clear every active pause point marker | + +### enable-watch + +Register a C# expression to evaluate on each paused Play Mode step + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--id` | string | - | Unique watch expression identifier | +| `--expression` | string | - | C# expression returning an object; UloopPausePoint.TryGetCapturedValue can read the latest raw capture | +| `--max-history` | integer | `20` | Maximum number of watch evaluations to retain (1-100) | + +### get-watch-values + +Show registered watch expression values and bounded evaluation history + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--id` | string | - | Optional watch expression identifier; omit to return all watches | + +### clear-watch + +Clear one or all registered C# watch expressions + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--id` | string | - | Watch expression identifier to clear | +| `--all` | flag | - | Clear every registered watch expression | + ## Capture Modes and History Choose the capture mode when enabling a pause point: diff --git a/.claude/skills/uloop-simulate-keyboard/SKILL.md b/.claude/skills/uloop-simulate-keyboard/SKILL.md index a342d9a2bb..7fc8612fb0 100644 --- a/.claude/skills/uloop-simulate-keyboard/SKILL.md +++ b/.claude/skills/uloop-simulate-keyboard/SKILL.md @@ -1,7 +1,7 @@ --- name: uloop-simulate-keyboard toolName: simulate-keyboard -description: "Simulate keyboard input in PlayMode through Unity Input System. Use for key presses, holds (via Press --duration or KeyDown/KeyUp), releases, and game controls such as WASD or Space." +description: "Simulate keyboard input in PlayMode through Unity Input System. Use for key presses, holds (via Press --duration or KeyDown/KeyUp), releases, and game controls such as WASD or Space. Requires the Input System package (com.unity.inputsystem)." --- # Task diff --git a/.claude/skills/uloop-simulate-mouse-input/SKILL.md b/.claude/skills/uloop-simulate-mouse-input/SKILL.md index a8be03a2e7..605f7f3aaf 100644 --- a/.claude/skills/uloop-simulate-mouse-input/SKILL.md +++ b/.claude/skills/uloop-simulate-mouse-input/SKILL.md @@ -1,7 +1,7 @@ --- name: uloop-simulate-mouse-input toolName: simulate-mouse-input -description: "Simulate Mouse.current input in PlayMode through Unity Input System. Use for gameplay mouse clicks, long-press (LongPress), movement delta (MoveDelta/SmoothDelta), or scroll. Use simulate-mouse-ui for UI." +description: "Simulate Mouse.current input in PlayMode through Unity Input System. Use for gameplay mouse clicks, long-press (LongPress), movement delta (MoveDelta/SmoothDelta), or scroll. Use simulate-mouse-ui for UI. Requires the Input System package and Active Input Handling set to 'Input System Package (New)' or 'Both'." --- # Task diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md index 31e64add12..d68d90dfd2 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md @@ -32,6 +32,63 @@ The response returns the derived marker `Id` (`Assets/Scripts/Enemy.cs:42`), the A hit pauses Unity at the next frame boundary — the patched method and the rest of that frame still run to completion. Only `CapturedVariables` is evidence of the values at the patched line; state read after the pause (for example via `execute-dynamic-code`) may already have advanced past it. +## Parameters + +One skill covers several commands, so each command's schema parameters have their own table below. +CLI-only flags (`--await`, `--trigger`, `--resume-play`, `--expect`, `--captured-variables`, +`--captured-variable-names`, `--matching-logs-max-count`) are described in the sections above; only +parameters Unity itself accepts appear here. + +### enable-pause-point + +Enable a pause point so Unity pauses when that code path is reached, either by a named UloopPausePoint.Pause marker (Id) or by resolving a source file and line (File+Line) + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--id` | string | - | Named pause point id passed to UloopPausePoint.Pause. Mutually exclusive with File/Line | +| `--file` | string | - | Project-relative source file path to patch a pause point into. Requires Line; mutually exclusive with Id | +| `--line` | integer | - | 1-based source line to resolve within File. Requires File; mutually exclusive with Id | +| `--timeout-seconds` | integer | `30` | Seconds before the enable request expires and stops pausing late hits | +| `--mode` | enum | `single-shot` | Capture mode: single-shot pauses once, continuous pauses on every hit, trace records hits without pausing | +| `--max-history` | integer | `20` | Maximum number of captured hit frames to retain (1-100) | +| `--max-preview-elements` | integer | `10` | Maximum number of elements to include in a captured collection's preview (1-1000). The value set at enable time also caps the previews in every later pause-point-status response for that marker; status has no flag to change it. | + +### clear-pause-point + +Clear one or all named UloopPausePoint.Pause markers + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--id` | string | - | Named pause point id to clear | +| `--all` | flag | - | Clear every active pause point marker | + +### enable-watch + +Register a C# expression to evaluate on each paused Play Mode step + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--id` | string | - | Unique watch expression identifier | +| `--expression` | string | - | C# expression returning an object; UloopPausePoint.TryGetCapturedValue can read the latest raw capture | +| `--max-history` | integer | `20` | Maximum number of watch evaluations to retain (1-100) | + +### get-watch-values + +Show registered watch expression values and bounded evaluation history + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--id` | string | - | Optional watch expression identifier; omit to return all watches | + +### clear-watch + +Clear one or all registered C# watch expressions + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--id` | string | - | Watch expression identifier to clear | +| `--all` | flag | - | Clear every registered watch expression | + ## Capture Modes and History Choose the capture mode when enabling a pause point: diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/SKILL.md index 23aec2b682..f02d3336d9 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/SKILL.md @@ -16,10 +16,16 @@ Live state injection: when a running PlayMode session is merely in the wrong sta ## Parameters -- `--code ''`: Inline C# statements to execute. Use direct statements only; `return` is optional, and `using` directives may appear at the top of the snippet. +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--code` | string | - | Inline C# statements to execute. Direct statements only; `return` is optional, and `using` directives may appear at the top of the snippet. | +| `--parameters` | object | - | Shell-quoted JSON object literal for reusing a snippet with varying data or keeping values outside the code. Values are exposed as `parameters["param0"]`, `parameters["param1"]`, and so on. Omit for most snippets; never pass a JSON string value. | +| `--wait-for-domain-reload` | flag | - | Wait for Domain Reload recovery after snippets that intentionally trigger Unity script reload or import work. Omit for normal inspection and editor-state workflows. | +| `--yield-to-foreground-requests` | flag | - | Allow foreground requests to preempt this execution | + +CLI-only flag, accepted instead of a schema parameter: + - `--code-file `: Read the C# statements from a file instead of `--code`. Use this when the active shell or launcher cannot preserve inline code exactly. Exactly one of `--code` or `--code-file` is required; combining them is an error. -- `--parameters {}` (advanced, optional): Pass a shell-quoted JSON object literal when reusing a snippet with varying data or when keeping values outside the code. Values are exposed as `parameters["param0"]`, `parameters["param1"]`, and so on. Omit this flag for most snippets. Do not pass a JSON string value such as `"{\"param0\":\"value\"}"`. -- `--wait-for-domain-reload` (optional): Wait for Domain Reload recovery after snippets that intentionally trigger Unity script reload or import work. Omit this for normal inspection and editor-state workflows. ## Code Rules diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md index a342d9a2bb..7fc8612fb0 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md @@ -1,7 +1,7 @@ --- name: uloop-simulate-keyboard toolName: simulate-keyboard -description: "Simulate keyboard input in PlayMode through Unity Input System. Use for key presses, holds (via Press --duration or KeyDown/KeyUp), releases, and game controls such as WASD or Space." +description: "Simulate keyboard input in PlayMode through Unity Input System. Use for key presses, holds (via Press --duration or KeyDown/KeyUp), releases, and game controls such as WASD or Space. Requires the Input System package (com.unity.inputsystem)." --- # Task diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md index a8be03a2e7..605f7f3aaf 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md @@ -1,7 +1,7 @@ --- name: uloop-simulate-mouse-input toolName: simulate-mouse-input -description: "Simulate Mouse.current input in PlayMode through Unity Input System. Use for gameplay mouse clicks, long-press (LongPress), movement delta (MoveDelta/SmoothDelta), or scroll. Use simulate-mouse-ui for UI." +description: "Simulate Mouse.current input in PlayMode through Unity Input System. Use for gameplay mouse clicks, long-press (LongPress), movement delta (MoveDelta/SmoothDelta), or scroll. Use simulate-mouse-ui for UI. Requires the Input System package and Active Input Handling set to 'Input System Package (New)' or 'Both'." --- # Task From e3c156fd380edd874ede7019033ef867afe3b957 Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 27 Jul 2026 08:30:03 +0900 Subject: [PATCH 02/16] Render tool help and list descriptions from the installed skill tables Descriptions lived in three hand-maintained places at once: the skill prose an agent reads, Unity's schema attributes, and the catalog compiled into the CLI. Editing one left the others stale, and help is where that staleness costs the most. The new cli/common/skilldocs package reads the package's own SKILL.md parameter tables at render time and lets them win over every other source, so the file an agent reads and the help an agent runs cannot disagree. - Parses both skill layouts (one skill per tool, and the multi-command pause-point skill), tolerating CRLF, a BOM and escaped pipes. - Matches schema properties through tooldocs.OptionNameForProperty so no second kebab-conversion rule exists. - Falls back silently to the previous descriptions when the package, the file or the table is missing, recording the reason in the CLI vibe log: help that prints stale text is a nuisance, help that fails to print is a broken CLI. - Wires the three paths that print Unity tool descriptions: single-command help, the project-resolved command list, and `uloop list`. - Registers the package in both release-input allow-lists, without which the renderer would never reach a released binary while CI stayed green. --- cli/common/skilldocs/apply.go | 56 ++++ cli/common/skilldocs/apply_test.go | 153 ++++++++++ cli/common/skilldocs/discover.go | 101 +++++++ cli/common/skilldocs/parse.go | 261 ++++++++++++++++++ cli/common/skilldocs/parse_test.go | 196 +++++++++++++ cli/common/skilldocs/skill_docs.go | 19 ++ .../internal/dispatcher/command_help.go | 14 +- .../command_help_skill_docs_test.go | 108 ++++++++ .../internal/dispatcher/run_help.go | 4 + cli/dispatcher/shared-inputs-stamp.json | 2 +- .../internal/projectrunner/list_output.go | 7 +- .../projectrunner/list_output_test.go | 86 +++++- .../internal/projectrunner/run.go | 2 +- cli/project-runner/shared-inputs-stamp.json | 2 +- .../automation/release_trigger_guard.go | 1 + scripts/stamp-release-inputs.sh | 1 + 16 files changed, 1004 insertions(+), 9 deletions(-) create mode 100644 cli/common/skilldocs/apply.go create mode 100644 cli/common/skilldocs/apply_test.go create mode 100644 cli/common/skilldocs/discover.go create mode 100644 cli/common/skilldocs/parse.go create mode 100644 cli/common/skilldocs/parse_test.go create mode 100644 cli/common/skilldocs/skill_docs.go create mode 100644 cli/dispatcher/internal/dispatcher/command_help_skill_docs_test.go diff --git a/cli/common/skilldocs/apply.go b/cli/common/skilldocs/apply.go new file mode 100644 index 0000000000..a26b9d4791 --- /dev/null +++ b/cli/common/skilldocs/apply.go @@ -0,0 +1,56 @@ +package skilldocs + +import ( + "github.com/hatayama/unity-cli-loop/common/tooldocs" + "github.com/hatayama/unity-cli-loop/common/tools" +) + +// ApplyToCatalog overlays the installed package's skill documentation onto a tool catalog. The skill +// wins over every other source: it is the text a human reviewed and an agent reads, while the schema +// carries generated placeholders and the embedded catalog is a snapshot of an older generation. +// Tools with no skill (project-local custom commands) pass through untouched. +func ApplyToCatalog(catalog tools.ToolCatalog, projectRoot string) tools.ToolCatalog { + docs := Load(projectRoot) + if len(docs) == 0 { + return catalog + } + + for index, tool := range catalog.Tools { + catalog.Tools[index] = applyToolDocs(tool, docs) + } + return catalog +} + +// ApplyToTool is ApplyToCatalog for the single-command help path. +func ApplyToTool(tool tools.ToolDefinition, projectRoot string) tools.ToolDefinition { + docs := Load(projectRoot) + if len(docs) == 0 { + return tool + } + return applyToolDocs(tool, docs) +} + +func applyToolDocs(tool tools.ToolDefinition, docs map[string]ToolDocs) tools.ToolDefinition { + toolDocs, ok := docs[tool.Name] + if !ok { + return tool + } + + if toolDocs.ToolDescription != "" { + tool.Description = toolDocs.ToolDescription + } + + // Properties are matched by their CLI option name, produced by the one kebab-conversion in the + // codebase. Writing the inverse conversion here would be a second rule to keep in step. + schema := tool.EffectiveInputSchema() + for propertyName, property := range schema.Properties { + optionName := tooldocs.OptionNameForProperty(tool.Name, propertyName, property) + description, ok := toolDocs.ParamDescriptions[optionName] + if !ok || description == "" { + continue + } + property.Description = description + schema.Properties[propertyName] = property + } + return tool +} diff --git a/cli/common/skilldocs/apply_test.go b/cli/common/skilldocs/apply_test.go new file mode 100644 index 0000000000..93e9e33cd0 --- /dev/null +++ b/cli/common/skilldocs/apply_test.go @@ -0,0 +1,153 @@ +package skilldocs + +import ( + "os" + "path/filepath" + "testing" + + "github.com/hatayama/unity-cli-loop/common/tools" +) + +// writeFixtureProject builds a Unity project whose Packages/src holds the uloop package, which is +// the layout ResolvePackageRoot recognizes for a locally embedded package. +func writeFixtureProject(t *testing.T, skills map[string]string) string { + t.Helper() + + projectRoot := t.TempDir() + packageRoot := filepath.Join(projectRoot, "Packages", "src") + if err := os.MkdirAll(packageRoot, 0o755); err != nil { + t.Fatalf("failed to create the package root: %v", err) + } + manifest := []byte(`{"name":"io.github.hatayama.uloopmcp"}`) + if err := os.WriteFile(filepath.Join(packageRoot, "package.json"), manifest, 0o644); err != nil { + t.Fatalf("failed to write the package manifest: %v", err) + } + // Editor/FirstPartyTools is how ResolvePackageRoot recognizes a candidate as the uloop package, + // so it exists in every install regardless of which skills this fixture writes. + if err := os.MkdirAll(filepath.Join(packageRoot, "Editor", "FirstPartyTools"), 0o755); err != nil { + t.Fatalf("failed to create the first-party tools directory: %v", err) + } + + for relativeDirectory, content := range skills { + skillDirectory := filepath.Join(packageRoot, "Editor", relativeDirectory, "Skill") + if err := os.MkdirAll(skillDirectory, 0o755); err != nil { + t.Fatalf("failed to create %s: %v", skillDirectory, err) + } + if err := os.WriteFile(filepath.Join(skillDirectory, "SKILL.md"), []byte(content), 0o644); err != nil { + t.Fatalf("failed to write %s: %v", skillDirectory, err) + } + } + return projectRoot +} + +func fixtureCatalog() tools.ToolCatalog { + return tools.ToolCatalog{Tools: []tools.ToolDefinition{ + { + Name: "simulate-keyboard", + Description: "Stale catalog description.", + InputSchema: tools.ToolInputSchema{ + Type: "object", + Properties: map[string]tools.ToolProperty{ + "Action": {Type: "string", Description: "Parameter: Action"}, + "Duration": {Type: "number", Description: "Stale duration description."}, + }, + }, + }, + { + Name: "my-custom-command", + Description: "A project-local command with no skill.", + InputSchema: tools.ToolInputSchema{ + Type: "object", + Properties: map[string]tools.ToolProperty{"Amount": {Type: "number", Description: "Author's own text."}}, + }, + }, + }} +} + +// Verifies the skill table replaces both the placeholder and the non-placeholder descriptions a +// catalog carries, which is the drift this renderer exists to remove. +func TestApplyToCatalogPrefersTheSkillTable(t *testing.T) { + projectRoot := writeFixtureProject(t, map[string]string{"FirstPartyTools/SimulateKeyboard": singleToolSkill}) + + catalog := ApplyToCatalog(fixtureCatalog(), projectRoot) + + tool, ok := tools.Find(catalog, "simulate-keyboard") + if !ok { + t.Fatal("simulate-keyboard is missing from the catalog") + } + if tool.Description != "Simulate keyboard input in PlayMode." { + t.Errorf("tool description was not taken from the skill: %q", tool.Description) + } + properties := tool.EffectiveInputSchema().Properties + if got := properties["Action"].Description; got != "Press | KeyDown | KeyUp" { + t.Errorf("Action description was not taken from the skill: %q", got) + } + if got := properties["Duration"].Description; got != "Hold duration in seconds." { + t.Errorf("a real-looking description must still lose to the skill: %q", got) + } +} + +// Verifies a command the package documents nowhere keeps the description its own author wrote, so +// custom commands are unaffected by this layer. +func TestApplyToCatalogLeavesUndocumentedCommandsAlone(t *testing.T) { + projectRoot := writeFixtureProject(t, map[string]string{"FirstPartyTools/SimulateKeyboard": singleToolSkill}) + + catalog := ApplyToCatalog(fixtureCatalog(), projectRoot) + + tool, ok := tools.Find(catalog, "my-custom-command") + if !ok { + t.Fatal("my-custom-command is missing from the catalog") + } + if tool.Description != "A project-local command with no skill." { + t.Errorf("tool description changed: %q", tool.Description) + } + if got := tool.EffectiveInputSchema().Properties["Amount"].Description; got != "Author's own text." { + t.Errorf("property description changed: %q", got) + } +} + +// Verifies a project with no uloop package installed keeps the catalog exactly as it was: a missing +// skill must degrade the text, never the command. +func TestApplyToCatalogFallsBackWhenNoPackageIsInstalled(t *testing.T) { + catalog := ApplyToCatalog(fixtureCatalog(), t.TempDir()) + + tool, _ := tools.Find(catalog, "simulate-keyboard") + if tool.Description != "Stale catalog description." { + t.Errorf("tool description changed without a package: %q", tool.Description) + } + if got := tool.EffectiveInputSchema().Properties["Duration"].Description; got != "Stale duration description." { + t.Errorf("property description changed without a package: %q", got) + } +} + +// Verifies an empty project root is a no-op, the shape taken by help resolved outside any project. +func TestApplyToToolWithoutAProjectRootIsANoOp(t *testing.T) { + tool := ApplyToTool(fixtureCatalog().Tools[0], "") + + if tool.Description != "Stale catalog description." { + t.Errorf("tool description changed with no project root: %q", tool.Description) + } +} + +// Verifies the skills of the CLI-only commands are read too, so the pause-point family - documented +// by a single multi-command skill in CliOnlyTools~ - is covered. +func TestApplyToToolReadsCliOnlySkills(t *testing.T) { + projectRoot := writeFixtureProject(t, map[string]string{"CliOnlyTools~/PausePoint": multiToolSkill}) + + tool := ApplyToTool(tools.ToolDefinition{ + Name: "enable-pause-point", + Description: "Stale.", + InputSchema: tools.ToolInputSchema{ + Type: "object", + Properties: map[string]tools.ToolProperty{"MaxHistory": {Type: "integer", Description: "Parameter: MaxHistory"}}, + }, + }, projectRoot) + + if tool.Description != "Enable a pause point so Unity pauses when that code path is reached" { + t.Errorf("tool description was not taken from the skill subsection: %q", tool.Description) + } + expected := "Maximum number of captured hit frames to retain (1-100)" + if got := tool.EffectiveInputSchema().Properties["MaxHistory"].Description; got != expected { + t.Errorf("MaxHistory description: %q", got) + } +} diff --git a/cli/common/skilldocs/discover.go b/cli/common/skilldocs/discover.go new file mode 100644 index 0000000000..572b7382ab --- /dev/null +++ b/cli/common/skilldocs/discover.go @@ -0,0 +1,101 @@ +package skilldocs + +import ( + "os" + "path/filepath" + "sort" + + "github.com/hatayama/unity-cli-loop/common/skillscan" + "github.com/hatayama/unity-cli-loop/common/vibelog" +) + +const ( + editorDirectoryName = "Editor" + firstPartyToolsDirName = "FirstPartyTools" + cliOnlyToolsDirName = "CliOnlyTools~" + skillDirectoryName = "Skill" + + skillDocsLogOperation = "skill_docs_render" +) + +// Load reads every skill in the installed uloop package and returns what each one documents, keyed +// by tool name. A project without the package, an unreadable file, or a skill that documents nothing +// yields no entry for the affected tools; callers then fall back to the descriptions they already +// had. Help that prints stale text is a nuisance, help that fails to print is a broken CLI. +func Load(projectRoot string) map[string]ToolDocs { + if projectRoot == "" { + return nil + } + + packageRoot := skillscan.ResolvePackageRoot(projectRoot) + if packageRoot == "" { + logSkillDocsFallback(projectRoot, "uloop package root not found; keeping embedded descriptions", nil) + return nil + } + + result := map[string]ToolDocs{} + for _, skillPath := range skillFilePaths(packageRoot) { + content, err := os.ReadFile(skillPath) + if err != nil { + logSkillDocsFallback(projectRoot, "skill file could not be read", map[string]any{ + "skill_path": skillPath, + "error": err.Error(), + }) + continue + } + + parsed := ParseSkill(string(content)) + if len(parsed) == 0 { + logSkillDocsFallback(projectRoot, "skill file documented no tool", map[string]any{ + "skill_path": skillPath, + }) + continue + } + for toolName, docs := range parsed { + result[toolName] = docs + } + } + return result +} + +// skillFilePaths lists the skill sources shipped inside the package. Both containers are read +// wholesale rather than by a hard-coded tool list, so a new tool's skill is picked up by adding the +// folder alone. CliOnlyTools~ holds the skills for commands with no Unity tool class; the ones that +// name no live tool simply never match a catalog entry. +func skillFilePaths(packageRoot string) []string { + paths := []string{} + for _, containerName := range []string{firstPartyToolsDirName, cliOnlyToolsDirName} { + containerPath := filepath.Join(packageRoot, editorDirectoryName, containerName) + entries, err := os.ReadDir(containerPath) + if err != nil { + continue + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + skillPath := filepath.Join(containerPath, entry.Name(), skillDirectoryName, skillscan.SkillFileName) + if _, err := os.Stat(skillPath); err != nil { + continue + } + paths = append(paths, skillPath) + } + } + sort.Strings(paths) + return paths +} + +// logSkillDocsFallback records why a layer was skipped. The fallback is deliberately silent on +// stdout - a diagnostic line would corrupt `uloop list` output - so this log is the only trace. +func logSkillDocsFallback(projectRoot string, message string, context map[string]any) { + if !vibelog.IsCLIVibeLogEnabled() { + return + } + _ = vibelog.WriteCLIVibeLog(projectRoot, vibelog.CLIVibeLogEntry{ + Level: "WARNING", + Operation: skillDocsLogOperation, + Message: message, + Context: context, + HumanNote: "Help and list fell back to the descriptions compiled into this binary.", + }) +} diff --git a/cli/common/skilldocs/parse.go b/cli/common/skilldocs/parse.go new file mode 100644 index 0000000000..f67648a9a3 --- /dev/null +++ b/cli/common/skilldocs/parse.go @@ -0,0 +1,261 @@ +package skilldocs + +import ( + "strings" + + "github.com/hatayama/unity-cli-loop/common/skillscan" +) + +const ( + parametersSectionHeading = "## Parameters" + skillNamePrefix = "uloop-" + + sectionHeadingPrefix = "## " + subsectionHeadingPrefix = "### " + byteOrderMark = "\uFEFF" +) + +// standardParameterTableCells is the header every parameter table in this repository uses. Tables +// with any other header (action matrices, comparison tables) are prose and must not be read as +// parameter documentation. +var standardParameterTableCells = []string{"Parameter", "Type", "Default", "Description"} + +// ParseSkill reads one SKILL.md body. Two layouts exist and both are supported: +// +// (i) one skill per tool - frontmatter toolName/description plus a single parameter table +// anywhere in the file (first-party tool skills put it under "### Parameters"). +// (ii) one skill covering several commands (pause-point) - a "## Parameters" section whose +// "### " subsections each carry a description line and their own table. +// +// A file that documents no parameters still yields its tool description, which is why the result is +// keyed by tool name rather than returned only when a table was found. +func ParseSkill(content string) map[string]ToolDocs { + lines := normalizedLines(content) + sectionLines, ok := parametersSectionLines(lines) + if ok && hasSubsectionHeading(sectionLines) { + return parseMultiToolSkill(sectionLines) + } + return parseSingleToolSkill(content, lines) +} + +// normalizedLines makes the parser indifferent to how the checkout wrote the file. A Windows +// checkout produces CRLF and some editors prepend a BOM; neither may change what help prints. +func normalizedLines(content string) []string { + content = strings.TrimPrefix(content, byteOrderMark) + content = strings.ReplaceAll(content, "\r\n", "\n") + content = strings.ReplaceAll(content, "\r", "\n") + return strings.Split(content, "\n") +} + +func parseSingleToolSkill(content string, lines []string) map[string]ToolDocs { + frontmatter := skillscan.ParseSkillFrontmatter(strings.TrimPrefix(content, byteOrderMark)) + toolName := singleSkillToolName(frontmatter) + if toolName == "" { + return map[string]ToolDocs{} + } + + docs := ToolDocs{ + ToolDescription: frontmatter["description"], + ParamDescriptions: map[string]string{}, + } + if headerIndex, ok := findParameterTableHeader(lines, 0); ok { + docs.ParamDescriptions = parseParameterTable(lines, headerIndex) + } + return map[string]ToolDocs{toolName: docs} +} + +// singleSkillToolName resolves the tool a one-tool skill documents. toolName is authoritative when +// present; otherwise the skill name carries it, since every skill in this package is named +// "uloop-" (focus-window's skill declares no toolName). +func singleSkillToolName(frontmatter map[string]string) string { + if toolName := frontmatter["toolName"]; toolName != "" { + return toolName + } + name := frontmatter["name"] + if !strings.HasPrefix(name, skillNamePrefix) { + return "" + } + return strings.TrimPrefix(name, skillNamePrefix) +} + +func parseMultiToolSkill(sectionLines []string) map[string]ToolDocs { + result := map[string]ToolDocs{} + for index := 0; index < len(sectionLines); index++ { + line := strings.TrimSpace(sectionLines[index]) + if !strings.HasPrefix(line, subsectionHeadingPrefix) { + continue + } + + toolName := strings.TrimSpace(strings.TrimPrefix(line, subsectionHeadingPrefix)) + if toolName == "" { + continue + } + blockLines := subsectionLines(sectionLines, index) + docs := ToolDocs{ + ToolDescription: firstProseLine(blockLines), + ParamDescriptions: map[string]string{}, + } + if headerIndex, ok := findParameterTableHeader(blockLines, 0); ok { + docs.ParamDescriptions = parseParameterTable(blockLines, headerIndex) + } + result[toolName] = docs + } + return result +} + +// parametersSectionLines returns the body of the "## Parameters" section, which is where a +// multi-tool skill keeps its per-command subsections. +func parametersSectionLines(lines []string) ([]string, bool) { + for index, line := range lines { + if strings.TrimSpace(line) != parametersSectionHeading { + continue + } + for end := index + 1; end < len(lines); end++ { + if strings.HasPrefix(strings.TrimSpace(lines[end]), sectionHeadingPrefix) { + return lines[index+1 : end], true + } + } + return lines[index+1:], true + } + return nil, false +} + +func hasSubsectionHeading(lines []string) bool { + for _, line := range lines { + if strings.HasPrefix(strings.TrimSpace(line), subsectionHeadingPrefix) { + return true + } + } + return false +} + +func subsectionLines(sectionLines []string, headingIndex int) []string { + for end := headingIndex + 1; end < len(sectionLines); end++ { + if strings.HasPrefix(strings.TrimSpace(sectionLines[end]), subsectionHeadingPrefix) { + return sectionLines[headingIndex+1 : end] + } + } + return sectionLines[headingIndex+1:] +} + +// firstProseLine is the tool description in a multi-tool skill: the first non-empty line under the +// tool's heading that is not part of a table. +func firstProseLine(lines []string) string { + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "|") { + continue + } + return trimmed + } + return "" +} + +func findParameterTableHeader(lines []string, startIndex int) (int, bool) { + for index := startIndex; index < len(lines); index++ { + if isStandardParameterTableHeader(lines[index]) { + return index, true + } + } + return 0, false +} + +func isStandardParameterTableHeader(line string) bool { + if !strings.HasPrefix(strings.TrimSpace(line), "|") { + return false + } + cells := splitTableRow(line) + if len(cells) != len(standardParameterTableCells) { + return false + } + for index, expected := range standardParameterTableCells { + if cells[index] != expected { + return false + } + } + return true +} + +// parseParameterTable reads the rows under a standard header into option name -> description. +func parseParameterTable(lines []string, headerIndex int) map[string]string { + descriptions := map[string]string{} + index := headerIndex + 1 + // The separator row (|---|---|) carries no data; a table without one is malformed, and reading + // it as a row would register a parameter named "---". + if index < len(lines) && isTableSeparatorRow(lines[index]) { + index++ + } + + for ; index < len(lines); index++ { + if !strings.HasPrefix(strings.TrimSpace(lines[index]), "|") { + break + } + cells := splitTableRow(lines[index]) + if len(cells) < len(standardParameterTableCells) { + continue + } + optionName := optionNameFromCell(cells[0]) + description := cells[len(standardParameterTableCells)-1] + if optionName == "" || description == "" { + continue + } + descriptions[optionName] = description + } + return descriptions +} + +func isTableSeparatorRow(line string) bool { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "|") { + return false + } + return strings.Trim(trimmed, "|-: \t") == "" +} + +// optionNameFromCell turns a first-column cell such as "`--max-history`" into "max-history", the +// form tooldocs.OptionNameForProperty produces for a schema property. +func optionNameFromCell(cell string) string { + name := strings.TrimSpace(strings.ReplaceAll(cell, "`", "")) + if fields := strings.Fields(name); len(fields) > 0 { + name = fields[0] + } + return strings.TrimPrefix(name, "--") +} + +// splitTableRow splits a Markdown table row on unescaped pipes. Descriptions legitimately contain +// "|" (enum alternations such as "Press|KeyDown"), which the table escapes as "\|"; splitting +// naively would truncate those cells and shift every later column. +func splitTableRow(line string) []string { + trimmed := strings.TrimSpace(line) + trimmed = strings.TrimPrefix(trimmed, "|") + trimmed = strings.TrimSuffix(trimmed, "|") + + cells := []string{} + current := strings.Builder{} + escaped := false + for _, char := range trimmed { + if escaped { + // A backslash only escapes the separator. Anything else keeps its backslash so prose + // such as "\n" survives verbatim. + if char != '|' { + current.WriteRune('\\') + } + current.WriteRune(char) + escaped = false + continue + } + switch char { + case '\\': + escaped = true + case '|': + cells = append(cells, strings.TrimSpace(current.String())) + current.Reset() + default: + current.WriteRune(char) + } + } + if escaped { + current.WriteRune('\\') + } + return append(cells, strings.TrimSpace(current.String())) +} diff --git a/cli/common/skilldocs/parse_test.go b/cli/common/skilldocs/parse_test.go new file mode 100644 index 0000000000..b96c4bcde7 --- /dev/null +++ b/cli/common/skilldocs/parse_test.go @@ -0,0 +1,196 @@ +package skilldocs + +import ( + "strings" + "testing" +) + +const singleToolSkill = `--- +name: uloop-simulate-keyboard +toolName: simulate-keyboard +description: "Simulate keyboard input in PlayMode." +--- + +# Task + +## Actions + +| Action | Behavior | Use Case | +|--------|----------|----------| +| ` + "`Press`" + ` | KeyDown then KeyUp | One-shot tap | + +## Tool Reference + +### Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| ` + "`--action`" + ` | enum | ` + "`Press`" + ` | Press \| KeyDown \| KeyUp | +| ` + "`--duration`" + ` | number | ` + "`0`" + ` | Hold duration in seconds. | +| ` + "`--ignored`" + ` | string | - | | + +## Notes + +Prose after the table. +` + +const multiToolSkill = `--- +name: uloop-pause-point +description: "Pauses Unity playback at any source file:line." +--- + +# uloop await-pause-point + +## Parameters + +CLI-only flags are described in the sections above. + +### enable-pause-point + +Enable a pause point so Unity pauses when that code path is reached + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| ` + "`--id`" + ` | string | - | Named pause point id | +| ` + "`--max-history`" + ` | integer | ` + "`20`" + ` | Maximum number of captured hit frames to retain (1-100) | + +### clear-watch + +Clear one or all registered C# watch expressions + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| ` + "`--all`" + ` | flag | - | Clear every registered watch expression | + +## Capture Modes and History + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| ` + "`--not-a-parameter`" + ` | string | - | This table is outside the Parameters section | +` + +// Verifies a one-tool skill yields its frontmatter description plus the rows of its parameter table, +// and that tables with other headers are not read as parameter documentation. +func TestParseSkillReadsASingleToolSkill(t *testing.T) { + docs := ParseSkill(singleToolSkill) + + if len(docs) != 1 { + t.Fatalf("expected exactly one documented tool, got %v", docs) + } + toolDocs, ok := docs["simulate-keyboard"] + if !ok { + t.Fatalf("simulate-keyboard is missing: %v", docs) + } + if toolDocs.ToolDescription != "Simulate keyboard input in PlayMode." { + t.Errorf("tool description not taken from frontmatter: %q", toolDocs.ToolDescription) + } + if got := toolDocs.ParamDescriptions["duration"]; got != "Hold duration in seconds." { + t.Errorf("--duration description: %q", got) + } + if _, ok := toolDocs.ParamDescriptions["Press"]; ok { + t.Error("the Actions table must not be read as parameter documentation") + } + if _, ok := toolDocs.ParamDescriptions["ignored"]; ok { + t.Error("an empty description cell must not register a parameter") + } +} + +// Verifies an escaped pipe inside a description survives as a literal pipe instead of truncating the +// cell and shifting every later column. +func TestParseSkillUnescapesPipesInDescriptions(t *testing.T) { + docs := ParseSkill(singleToolSkill) + + if got := docs["simulate-keyboard"].ParamDescriptions["action"]; got != "Press | KeyDown | KeyUp" { + t.Errorf("escaped pipes were not restored: %q", got) + } +} + +// Verifies a skill covering several commands documents each one from its own subsection, and that a +// parameter table outside the Parameters section is ignored. +func TestParseSkillReadsAMultiToolSkill(t *testing.T) { + docs := ParseSkill(multiToolSkill) + + if len(docs) != 2 { + t.Fatalf("expected the two documented commands, got %v", docs) + } + enable, ok := docs["enable-pause-point"] + if !ok { + t.Fatalf("enable-pause-point is missing: %v", docs) + } + if enable.ToolDescription != "Enable a pause point so Unity pauses when that code path is reached" { + t.Errorf("tool description not taken from the line under the heading: %q", enable.ToolDescription) + } + if got := enable.ParamDescriptions["max-history"]; got != "Maximum number of captured hit frames to retain (1-100)" { + t.Errorf("--max-history description: %q", got) + } + if got := docs["clear-watch"].ParamDescriptions["all"]; got != "Clear every registered watch expression" { + t.Errorf("--all description: %q", got) + } + if _, ok := docs["pause-point"]; ok { + t.Error("a multi-tool skill must not register its own skill name as a tool") + } +} + +// Verifies a CRLF checkout parses identically to an LF one, since Windows checkouts rewrite line +// endings and help text must not depend on which platform read the file. +func TestParseSkillToleratesCRLFLineEndings(t *testing.T) { + crlf := strings.ReplaceAll(singleToolSkill, "\n", "\r\n") + + if got := ParseSkill(crlf); got["simulate-keyboard"].ParamDescriptions["duration"] != "Hold duration in seconds." { + t.Errorf("CRLF content parsed differently: %v", got) + } + crlfMultiTool := strings.ReplaceAll(multiToolSkill, "\n", "\r\n") + if got := ParseSkill(crlfMultiTool); len(got) != 2 { + t.Errorf("CRLF multi-tool content parsed differently: %v", got) + } +} + +// Verifies a leading byte order mark does not hide the frontmatter, which would otherwise drop the +// tool name and silently document nothing. +func TestParseSkillToleratesAByteOrderMark(t *testing.T) { + docs := ParseSkill(byteOrderMark + singleToolSkill) + + if _, ok := docs["simulate-keyboard"]; !ok { + t.Errorf("a BOM must not hide the frontmatter: %v", docs) + } +} + +// Verifies a skill with no parameter table still reports its tool description, which is the only +// documentation a tool with no parameters has. +func TestParseSkillKeepsTheDescriptionOfATablelessSkill(t *testing.T) { + docs := ParseSkill(`--- +name: uloop-focus-window +description: "Bring the Unity Editor window to front." +--- + +# uloop focus-window +`) + + toolDocs, ok := docs["focus-window"] + if !ok { + t.Fatalf("focus-window is missing: %v", docs) + } + if toolDocs.ToolDescription != "Bring the Unity Editor window to front." { + t.Errorf("tool description: %q", toolDocs.ToolDescription) + } + if len(toolDocs.ParamDescriptions) != 0 { + t.Errorf("a skill with no table documents no parameters: %v", toolDocs.ParamDescriptions) + } +} + +// Verifies a skill whose frontmatter names no tool documents nothing rather than guessing a name. +func TestParseSkillIgnoresASkillWithNoToolName(t *testing.T) { + docs := ParseSkill(`--- +name: some-unrelated-skill +description: "Not a uloop tool skill." +--- + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| ` + "`--flag`" + ` | flag | - | Should not be registered | +`) + + if len(docs) != 0 { + t.Errorf("expected no documented tool, got %v", docs) + } +} diff --git a/cli/common/skilldocs/skill_docs.go b/cli/common/skilldocs/skill_docs.go new file mode 100644 index 0000000000..91cf04fe9c --- /dev/null +++ b/cli/common/skilldocs/skill_docs.go @@ -0,0 +1,19 @@ +// Package skilldocs reads the parameter tables out of the package's own SKILL.md files and uses +// them as the source of truth for help text. Descriptions used to live in three hand-maintained +// places (the skill prose, Unity's schema attributes, and the catalog embedded in this binary), so +// editing one left the others stale. Reading the skill at render time removes that drift for good: +// the file an agent reads and the help an agent runs cannot disagree. +// +// This package intentionally depends only on tools, tooldocs, skillscan and vibelog. clicore +// already imports tools and tooldocs, so importing it here would create an import cycle. +package skilldocs + +// ToolDocs is what one skill file says about one tool. +type ToolDocs struct { + // ToolDescription is the tool's own summary line (frontmatter description, or the line under + // the tool's heading in a multi-tool skill). Empty when the skill states none. + ToolDescription string + // ParamDescriptions is keyed by CLI option name without the leading "--", which is what + // tooldocs.OptionNameForProperty produces for a schema property. + ParamDescriptions map[string]string +} diff --git a/cli/dispatcher/internal/dispatcher/command_help.go b/cli/dispatcher/internal/dispatcher/command_help.go index 56d9172c43..f0505b03af 100644 --- a/cli/dispatcher/internal/dispatcher/command_help.go +++ b/cli/dispatcher/internal/dispatcher/command_help.go @@ -5,6 +5,7 @@ import ( "sort" clierrors "github.com/hatayama/unity-cli-loop/common/errors" + "github.com/hatayama/unity-cli-loop/common/skilldocs" "github.com/hatayama/unity-cli-loop/common/tooldocs" "github.com/hatayama/unity-cli-loop/common/clicore" @@ -25,7 +26,9 @@ func tryHandleCommandHelp(command string, startPath string, projectPath string, if err != nil { if projectPath == "" { if tool, ok := clicore.FindDefaultTool(command); ok { - printToolHelp(tool, stdout) + // No project resolved, so no installed package to read skills from: this path + // renders from the catalog embedded in this binary, as it always has. + printToolHelp(tool, "", stdout) return true, 0 } } @@ -45,7 +48,7 @@ func tryHandleCommandHelp(command string, startPath string, projectPath string, return true, 1 } - printToolHelp(tool, stdout) + printToolHelp(tool, connection.ProjectRoot, stdout) return true, 0 } @@ -83,7 +86,12 @@ func printNativeSingleCommandHelp(command string, stdout io.Writer) { printSkillGuidanceHelp(command, stdout) } -func printToolHelp(tool clicore.ToolDefinition, stdout io.Writer) { +// printToolHelp renders one Unity tool command's help. Descriptions come from the installed +// package's SKILL.md tables when a project is resolved, so the help and the skill an agent reads +// cannot disagree; without a project root the embedded catalog is used unchanged. +func printToolHelp(tool clicore.ToolDefinition, projectRoot string, stdout io.Writer) { + tool = skilldocs.ApplyToTool(tool, projectRoot) + clicore.WriteLine(stdout, "Usage:") clicore.WriteFormat(stdout, " uloop %s", tool.Name) if len(tooldocs.VisibleOptionHelpEntriesForTool(tool)) > 0 { diff --git a/cli/dispatcher/internal/dispatcher/command_help_skill_docs_test.go b/cli/dispatcher/internal/dispatcher/command_help_skill_docs_test.go new file mode 100644 index 0000000000..c013c910b3 --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/command_help_skill_docs_test.go @@ -0,0 +1,108 @@ +package dispatcher + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +const skillDocsFixtureDurationDescription = "Hold duration written only in the fixture skill." + +// Verifies option and tool help text come from the installed package's SKILL.md table rather than +// from the descriptions compiled into this binary, which is the drift this reader removes. +func TestCommandHelpReadsDescriptionsFromTheInstalledSkill(t *testing.T) { + projectRoot := createLaunchTestProject(t) + writeSkillDocsFixturePackage(t, projectRoot, skillDocsFixtureDurationDescription) + writeToolCache(t, projectRoot, `{ + "tools": [ + { + "name": "simulate-keyboard", + "inputSchema": { + "type": "object", + "properties": { + "Duration": {"type": "number", "description": "Parameter: Duration"} + } + } + } + ] +}`) + var stdout bytes.Buffer + var stderr bytes.Buffer + + handled, code := tryHandleCommandHelp("simulate-keyboard", projectRoot, projectRoot, &stdout, &stderr) + + if !handled || code != 0 { + t.Fatalf("simulate-keyboard help was not handled: handled=%v code=%d stderr=%s", handled, code, stderr.String()) + } + output := stdout.String() + if !strings.Contains(output, skillDocsFixtureDurationDescription) { + t.Errorf("the option description was not read from the skill:\n%s", output) + } + if !strings.Contains(output, "Simulate keyboard input from the fixture skill.") { + t.Errorf("the tool description was not read from the skill:\n%s", output) + } +} + +// Verifies a project with no installed package still prints full help from the embedded catalog: a +// missing or unreadable skill may only cost freshness, never the help itself. +func TestCommandHelpKeepsEmbeddedDescriptionsWithoutAnInstalledSkill(t *testing.T) { + projectRoot := createLaunchTestProject(t) + writeToolCache(t, projectRoot, `{ + "tools": [ + { + "name": "simulate-keyboard", + "inputSchema": { + "type": "object", + "properties": { + "Duration": {"type": "number", "description": "Parameter: Duration"} + } + } + } + ] +}`) + var stdout bytes.Buffer + var stderr bytes.Buffer + + handled, code := tryHandleCommandHelp("simulate-keyboard", projectRoot, projectRoot, &stdout, &stderr) + + if !handled || code != 0 { + t.Fatalf("simulate-keyboard help was not handled: handled=%v code=%d stderr=%s", handled, code, stderr.String()) + } + output := stdout.String() + if !strings.Contains(output, "--duration") { + t.Fatalf("the option was not listed at all:\n%s", output) + } + if strings.Contains(output, "Parameter: Duration") { + t.Errorf("the placeholder description survived instead of the embedded text:\n%s", output) + } +} + +// writeSkillDocsFixturePackage installs a uloop package inside the project whose simulate-keyboard +// skill documents --duration with the given text. +func writeSkillDocsFixturePackage(t *testing.T, projectRoot string, durationDescription string) { + t.Helper() + + packageRoot := filepath.Join(projectRoot, "Packages", "src") + skillDirectory := filepath.Join(packageRoot, "Editor", "FirstPartyTools", "SimulateKeyboard", "Skill") + if err := os.MkdirAll(skillDirectory, 0o755); err != nil { + t.Fatalf("failed to create the skill directory: %v", err) + } + manifest := []byte(`{"name":"io.github.hatayama.uloopmcp"}`) + if err := os.WriteFile(filepath.Join(packageRoot, "package.json"), manifest, 0o644); err != nil { + t.Fatalf("failed to write the package manifest: %v", err) + } + + skill := "---\n" + + "name: uloop-simulate-keyboard\n" + + "toolName: simulate-keyboard\n" + + "description: \"Simulate keyboard input from the fixture skill.\"\n" + + "---\n\n" + + "| Parameter | Type | Default | Description |\n" + + "|-----------|------|---------|-------------|\n" + + "| `--duration` | number | `0` | " + durationDescription + " |\n" + if err := os.WriteFile(filepath.Join(skillDirectory, "SKILL.md"), []byte(skill), 0o644); err != nil { + t.Fatalf("failed to write the fixture skill: %v", err) + } +} diff --git a/cli/dispatcher/internal/dispatcher/run_help.go b/cli/dispatcher/internal/dispatcher/run_help.go index bba2632150..cee38be47b 100644 --- a/cli/dispatcher/internal/dispatcher/run_help.go +++ b/cli/dispatcher/internal/dispatcher/run_help.go @@ -5,6 +5,7 @@ import ( "os" "strings" + "github.com/hatayama/unity-cli-loop/common/skilldocs" "github.com/hatayama/unity-cli-loop/common/tooldocs" "github.com/hatayama/unity-cli-loop/common/clicontract" @@ -40,6 +41,9 @@ func printHelpForResolvedProject(stdout io.Writer, explicitProjectPath string) { } cache, ok := clicore.LoadProjectToolCache(connection.ProjectRoot) + // The command list prints one description per tool, so it reads the installed package's skills + // for the same reason single-command help does. + cache = skilldocs.ApplyToCatalog(cache, connection.ProjectRoot) printMainHelp(stdout, clicontract.ProjectRunnerVersion(), nativeCLIDescription, cache, ok) } diff --git a/cli/dispatcher/shared-inputs-stamp.json b/cli/dispatcher/shared-inputs-stamp.json index aa7345dc75..ca43e4ecfc 100644 --- a/cli/dispatcher/shared-inputs-stamp.json +++ b/cli/dispatcher/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "1f2aea4c3b64942bc6b25492861c6935620ccd84" + "sharedInputsHash": "0d024bff9e8e18ba7544d8db8def6026d2293f6e" } diff --git a/cli/project-runner/internal/projectrunner/list_output.go b/cli/project-runner/internal/projectrunner/list_output.go index d9978c9756..2984989dbd 100644 --- a/cli/project-runner/internal/projectrunner/list_output.go +++ b/cli/project-runner/internal/projectrunner/list_output.go @@ -4,6 +4,7 @@ import ( "encoding/json" "sort" + "github.com/hatayama/unity-cli-loop/common/skilldocs" "github.com/hatayama/unity-cli-loop/common/tooldocs" "github.com/hatayama/unity-cli-loop/common/clicontract" @@ -31,7 +32,7 @@ type listOption struct { Values []string `json:"Values,omitempty"` } -func formatToolListResult(result json.RawMessage) json.RawMessage { +func formatToolListResult(result json.RawMessage, projectRoot string) json.RawMessage { var cache clicore.ToolsCache if err := json.Unmarshal(result, &cache); err != nil { return result @@ -42,6 +43,10 @@ func formatToolListResult(result json.RawMessage) json.RawMessage { // descriptions while `list` kept reporting "Parameter: ". cache = clicore.ApplyEmbeddedDescriptionFallback(cache) + // The installed package's SKILL.md tables win over both the cache and the embedded catalog, so + // list reports the same text `uloop --help` does. + cache = skilldocs.ApplyToCatalog(cache, projectRoot) + content, err := json.Marshal(newListCatalog(cache)) if err != nil { panic(err) diff --git a/cli/project-runner/internal/projectrunner/list_output_test.go b/cli/project-runner/internal/projectrunner/list_output_test.go index c6d6d50628..0116516603 100644 --- a/cli/project-runner/internal/projectrunner/list_output_test.go +++ b/cli/project-runner/internal/projectrunner/list_output_test.go @@ -2,6 +2,8 @@ package projectrunner import ( "encoding/json" + "os" + "path/filepath" "testing" "github.com/hatayama/unity-cli-loop/common/tooldocs" @@ -32,7 +34,7 @@ func TestFormatToolListResultUsesCliOptionNames(t *testing.T) { } } ] -}`)) +}`), "") catalog := decodeListCatalog(t, result) tool := findListTool(t, catalog, "screenshot") @@ -166,7 +168,7 @@ func TestFormatToolListResultFillsPlaceholderDescriptions(t *testing.T) { } } ] -}`)) +}`), "") catalog := decodeListCatalog(t, content) simulateKeyboard := findListTool(t, catalog, "simulate-keyboard") @@ -196,6 +198,86 @@ func TestNewListCatalogOmitsEmptyStringDefaults(t *testing.T) { } } +// Tests that list reports the description written in the installed package's SKILL.md table, so the +// table an agent reads and the list an agent queries cannot disagree. +func TestFormatToolListResultReadsDescriptionsFromTheInstalledSkill(t *testing.T) { + projectRoot := writeSkillFixtureProject(t, "Parsed straight out of the skill table.") + + content := formatToolListResult([]byte(`{ + "tools": [ + { + "name": "simulate-keyboard", + "parameterSchema": { + "Properties": { + "Duration": {"Type": "number", "Description": "Parameter: Duration"} + } + } + } + ] +}`), projectRoot) + + simulateKeyboard := findListTool(t, decodeListCatalog(t, content), "simulate-keyboard") + if simulateKeyboard.Description != "Simulate keyboard input from the fixture skill." { + t.Errorf("tool description was not read from the skill: %q", simulateKeyboard.Description) + } + option := findListOption(t, simulateKeyboard, "--duration") + if option.Description != "Parsed straight out of the skill table." { + t.Errorf("option description was not read from the skill: %q", option.Description) + } +} + +// Tests that a project with no installed package keeps the previous output, since a missing skill +// must only cost freshness and never the command itself. +func TestFormatToolListResultKeepsEmbeddedTextWithoutASkill(t *testing.T) { + content := formatToolListResult([]byte(`{ + "tools": [ + { + "name": "simulate-keyboard", + "parameterSchema": { + "Properties": { + "Duration": {"Type": "number", "Description": "Parameter: Duration"} + } + } + } + ] +}`), t.TempDir()) + + option := findListOption(t, findListTool(t, decodeListCatalog(t, content), "simulate-keyboard"), "--duration") + if option.Description == "" || option.Description == "Parameter: Duration" { + t.Errorf("the embedded description was lost: %q", option.Description) + } +} + +// writeSkillFixtureProject builds a Unity project holding a uloop package whose simulate-keyboard +// skill documents --duration with the given text. +func writeSkillFixtureProject(t *testing.T, durationDescription string) string { + t.Helper() + + projectRoot := t.TempDir() + packageRoot := filepath.Join(projectRoot, "Packages", "src") + skillDirectory := filepath.Join(packageRoot, "Editor", "FirstPartyTools", "SimulateKeyboard", "Skill") + if err := os.MkdirAll(skillDirectory, 0o755); err != nil { + t.Fatalf("failed to create the skill directory: %v", err) + } + manifest := []byte(`{"name":"io.github.hatayama.uloopmcp"}`) + if err := os.WriteFile(filepath.Join(packageRoot, "package.json"), manifest, 0o644); err != nil { + t.Fatalf("failed to write the package manifest: %v", err) + } + + skill := "---\n" + + "name: uloop-simulate-keyboard\n" + + "toolName: simulate-keyboard\n" + + "description: \"Simulate keyboard input from the fixture skill.\"\n" + + "---\n\n" + + "| Parameter | Type | Default | Description |\n" + + "|-----------|------|---------|-------------|\n" + + "| `--duration` | number | `0` | " + durationDescription + " |\n" + if err := os.WriteFile(filepath.Join(skillDirectory, "SKILL.md"), []byte(skill), 0o644); err != nil { + t.Fatalf("failed to write the fixture skill: %v", err) + } + return projectRoot +} + func decodeListCatalog(t *testing.T, content []byte) listCatalog { t.Helper() diff --git a/cli/project-runner/internal/projectrunner/run.go b/cli/project-runner/internal/projectrunner/run.go index 245bd4f786..39b86ede22 100644 --- a/cli/project-runner/internal/projectrunner/run.go +++ b/cli/project-runner/internal/projectrunner/run.go @@ -261,7 +261,7 @@ func runList(ctx context.Context, connection unityipc.Connection, stdout io.Writ }) return 1 } - clicore.WriteJSON(stdout, formatToolListResult(outcome.Result)) + clicore.WriteJSON(stdout, formatToolListResult(outcome.Result, connection.ProjectRoot)) return 0 } diff --git a/cli/project-runner/shared-inputs-stamp.json b/cli/project-runner/shared-inputs-stamp.json index 7025b36f3f..2f53e37822 100644 --- a/cli/project-runner/shared-inputs-stamp.json +++ b/cli/project-runner/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "cbfdc9ea86e2166a5be8ca466799820ef2aed727" + "sharedInputsHash": "ef302b69852a644c6715547b4487c090db0eac5f" } diff --git a/cli/release-automation/internal/automation/release_trigger_guard.go b/cli/release-automation/internal/automation/release_trigger_guard.go index 3e60059b79..90ea7b0d6d 100644 --- a/cli/release-automation/internal/automation/release_trigger_guard.go +++ b/cli/release-automation/internal/automation/release_trigger_guard.go @@ -156,6 +156,7 @@ var sharedCommonPackageRoots = []string{ "cli/common/ipcendpoint/", "cli/common/progress/", "cli/common/project/", + "cli/common/skilldocs/", "cli/common/skillscan/", "cli/common/tooldocs/", "cli/common/tools/", diff --git a/scripts/stamp-release-inputs.sh b/scripts/stamp-release-inputs.sh index 4795ef7706..a627293d7c 100755 --- a/scripts/stamp-release-inputs.sh +++ b/scripts/stamp-release-inputs.sh @@ -23,6 +23,7 @@ list_shared_common_inputs() { 'cli/common/ipcendpoint/' \ 'cli/common/progress/' \ 'cli/common/project/' \ + 'cli/common/skilldocs/' \ 'cli/common/skillscan/' \ 'cli/common/tooldocs/' \ 'cli/common/tools/' \ From c7774567d121f1995ae9e654696b9e2c651c9587 Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 27 Jul 2026 08:33:48 +0900 Subject: [PATCH 03/16] Strip code-span backticks from rendered skill table cells Reading the tables verbatim carried their Markdown into places that cannot render it: `--action` printed "`Press`, `KeyDown`" in terminal help and in list JSON alike. The escape resolution the parser already performed proves a cell-to-plain-text step exists, so backtick removal belongs in that same step rather than in a second one. NormalizeCellText is now the only Markdown-to-text conversion in this package, and the catalog generator and the CI drift check will run cells through it too, so all three surfaces compare and emit the same string. Its scope stays code spans alone: any other Markdown reaching a cell shows up verbatim in help, which is the signal to fix the table. --- cli/common/skilldocs/parse.go | 36 +++++++++++++++++++++--------- cli/common/skilldocs/parse_test.go | 11 +++++++++ 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/cli/common/skilldocs/parse.go b/cli/common/skilldocs/parse.go index f67648a9a3..32b00acd2a 100644 --- a/cli/common/skilldocs/parse.go +++ b/cli/common/skilldocs/parse.go @@ -195,7 +195,7 @@ func parseParameterTable(lines []string, headerIndex int) map[string]string { continue } optionName := optionNameFromCell(cells[0]) - description := cells[len(standardParameterTableCells)-1] + description := NormalizeCellText(cells[len(standardParameterTableCells)-1]) if optionName == "" || description == "" { continue } @@ -215,16 +215,34 @@ func isTableSeparatorRow(line string) bool { // optionNameFromCell turns a first-column cell such as "`--max-history`" into "max-history", the // form tooldocs.OptionNameForProperty produces for a schema property. func optionNameFromCell(cell string) string { - name := strings.TrimSpace(strings.ReplaceAll(cell, "`", "")) + name := NormalizeCellText(cell) if fields := strings.Fields(name); len(fields) > 0 { name = fields[0] } return strings.TrimPrefix(name, "--") } -// splitTableRow splits a Markdown table row on unescaped pipes. Descriptions legitimately contain -// "|" (enum alternations such as "Press|KeyDown"), which the table escapes as "\|"; splitting -// naively would truncate those cells and shift every later column. +// NormalizeCellText turns one raw table cell into the plain text CLI surfaces print. It is the only +// Markdown-to-text step in this package, and every consumer of a skill table - this renderer, the +// catalog generator, and the CI drift check - must run cells through it so all three compare and +// emit exactly the same string. +// +// Two conversions happen, and deliberately no more: +// - "\|" becomes "|", the escape a table needs for enum alternations such as "Press|KeyDown". +// - code-span backticks are dropped, because "`Press`, `KeyDown`" reads as noise in terminal help +// and in list JSON alike. +// +// No other Markdown is interpreted. If bold or a link ever appears in a cell it shows up verbatim in +// help, which is a visible signal to fix the table rather than a reason to grow a Markdown renderer. +func NormalizeCellText(cell string) string { + text := strings.ReplaceAll(cell, `\|`, "|") + return strings.TrimSpace(strings.ReplaceAll(text, "`", "")) +} + +// splitTableRow splits a Markdown table row on unescaped pipes, leaving each cell's text otherwise +// untouched for NormalizeCellText to convert. Descriptions legitimately contain "|" (enum +// alternations such as "Press|KeyDown"), which the table escapes as "\|"; splitting naively would +// truncate those cells and shift every later column. func splitTableRow(line string) []string { trimmed := strings.TrimSpace(line) trimmed = strings.TrimPrefix(trimmed, "|") @@ -235,11 +253,9 @@ func splitTableRow(line string) []string { escaped := false for _, char := range trimmed { if escaped { - // A backslash only escapes the separator. Anything else keeps its backslash so prose - // such as "\n" survives verbatim. - if char != '|' { - current.WriteRune('\\') - } + // The escape sequence is kept intact; only NormalizeCellText resolves it, so the split + // stays a purely structural step. + current.WriteRune('\\') current.WriteRune(char) escaped = false continue diff --git a/cli/common/skilldocs/parse_test.go b/cli/common/skilldocs/parse_test.go index b96c4bcde7..5435f230e0 100644 --- a/cli/common/skilldocs/parse_test.go +++ b/cli/common/skilldocs/parse_test.go @@ -105,6 +105,17 @@ func TestParseSkillUnescapesPipesInDescriptions(t *testing.T) { } } +// Verifies the single normalization step resolves an escaped pipe and drops code-span backticks in +// one pass, and leaves other Markdown alone. Every consumer of a skill table goes through this +// function, so a change here would silently move help, list and the generated catalog apart. +func TestNormalizeCellTextResolvesEscapesAndCodeSpans(t *testing.T) { + got := NormalizeCellText(" `Press` " + `\|` + " `KeyDown`, see **Actions** ") + + if got != "Press | KeyDown, see **Actions**" { + t.Errorf("unexpected normalization: %q", got) + } +} + // Verifies a skill covering several commands documents each one from its own subsection, and that a // parameter table outside the Parameters section is ignored. func TestParseSkillReadsAMultiToolSkill(t *testing.T) { From 879464bf02309422a0abd635fdefaa2c09fbe25b Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 27 Jul 2026 08:48:19 +0900 Subject: [PATCH 04/16] Document record-input's countdown delay and overlay flags The skill's parameter table listed three of the five flags record-input accepts, so an agent reading it had no way to learn that recording waits three seconds by default or that the countdown overlay can be turned off. Both rows describe the flag's effect, matching how replay-input documents the same overlay. --- .agents/skills/uloop-record-input/SKILL.md | 2 ++ .claude/skills/uloop-record-input/SKILL.md | 2 ++ Packages/src/Editor/FirstPartyTools/RecordInput/Skill/SKILL.md | 2 ++ 3 files changed, 6 insertions(+) diff --git a/.agents/skills/uloop-record-input/SKILL.md b/.agents/skills/uloop-record-input/SKILL.md index 43f966dc3d..301586a6a4 100644 --- a/.agents/skills/uloop-record-input/SKILL.md +++ b/.agents/skills/uloop-record-input/SKILL.md @@ -31,6 +31,8 @@ uloop record-input --action Stop --output-path scripts/my-play.json | `--action` | enum | `Start` | `Start` - begin recording, `Stop` - stop and save | | `--output-path` | string | auto | Save path. Auto-generates under `.uloop/outputs/InputRecordings/` | | `--keys` | string | `""` | Comma-separated key filter. Empty = all common game keys | +| `--delay-seconds` | integer | `3` | Countdown delay in seconds before recording starts (0-10). Gives time to switch focus to Game View. | +| `--no-show-overlay` | flag | - | Hide the recording countdown and REC indicator overlay | ## Deterministic Replay diff --git a/.claude/skills/uloop-record-input/SKILL.md b/.claude/skills/uloop-record-input/SKILL.md index 43f966dc3d..301586a6a4 100644 --- a/.claude/skills/uloop-record-input/SKILL.md +++ b/.claude/skills/uloop-record-input/SKILL.md @@ -31,6 +31,8 @@ uloop record-input --action Stop --output-path scripts/my-play.json | `--action` | enum | `Start` | `Start` - begin recording, `Stop` - stop and save | | `--output-path` | string | auto | Save path. Auto-generates under `.uloop/outputs/InputRecordings/` | | `--keys` | string | `""` | Comma-separated key filter. Empty = all common game keys | +| `--delay-seconds` | integer | `3` | Countdown delay in seconds before recording starts (0-10). Gives time to switch focus to Game View. | +| `--no-show-overlay` | flag | - | Hide the recording countdown and REC indicator overlay | ## Deterministic Replay diff --git a/Packages/src/Editor/FirstPartyTools/RecordInput/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/RecordInput/Skill/SKILL.md index 43f966dc3d..301586a6a4 100644 --- a/Packages/src/Editor/FirstPartyTools/RecordInput/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/RecordInput/Skill/SKILL.md @@ -31,6 +31,8 @@ uloop record-input --action Stop --output-path scripts/my-play.json | `--action` | enum | `Start` | `Start` - begin recording, `Stop` - stop and save | | `--output-path` | string | auto | Save path. Auto-generates under `.uloop/outputs/InputRecordings/` | | `--keys` | string | `""` | Comma-separated key filter. Empty = all common game keys | +| `--delay-seconds` | integer | `3` | Countdown delay in seconds before recording starts (0-10). Gives time to switch focus to Game View. | +| `--no-show-overlay` | flag | - | Hide the recording countdown and REC indicator overlay | ## Deterministic Replay From 93aed52ef0a0fb5e148c07b5801f79f615f9aef9 Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 27 Jul 2026 08:48:19 +0900 Subject: [PATCH 05/16] Print negated boolean flag help from the skill table instead of synthesizing it A negated boolean's help text was decided by wording: unless the description began with "Disable" or "Do not" it was thrown away and replaced with a synthesized "Disable ", with two commands special-cased to fixed strings. So `--no-include-components` printed "Disable include components" while its reviewed table row read "Exclude component information", and no amount of editing the table could change the help. The branch is now on provenance, not wording: a description that came from a skill parameter table prints verbatim, because those rows are already written from the flag's point of view. Synthesis survives only for a description with no skill behind it - a project-local custom command, whose author wrote the property in the positive sense - so its `--no-` flag still does not claim the opposite of what it does. The two special-cased strings are retired; the table rows they duplicated say the same thing more precisely ("before test execution" rather than "before execution"). Their predicates stay: the option names and the "auto-save enabled" default suffixes still need them. --- cli/common/skilldocs/apply.go | 1 + cli/common/tooldocs/tool_option_help.go | 21 +++---- cli/common/tooldocs/tool_option_help_test.go | 65 ++++++++++++++++++++ cli/common/tools/catalog.go | 35 +++++++---- cli/common/tools/description_fallback.go | 1 + cli/common/tools/types.go | 21 ++++--- 6 files changed, 114 insertions(+), 30 deletions(-) create mode 100644 cli/common/tooldocs/tool_option_help_test.go diff --git a/cli/common/skilldocs/apply.go b/cli/common/skilldocs/apply.go index a26b9d4791..f7d8297cbe 100644 --- a/cli/common/skilldocs/apply.go +++ b/cli/common/skilldocs/apply.go @@ -50,6 +50,7 @@ func applyToolDocs(tool tools.ToolDefinition, docs map[string]ToolDocs) tools.To continue } property.Description = description + property.SkillSourcedDescription = true schema.Properties[propertyName] = property } return tool diff --git a/cli/common/tooldocs/tool_option_help.go b/cli/common/tooldocs/tool_option_help.go index a1a34e6fcf..121feab89b 100644 --- a/cli/common/tooldocs/tool_option_help.go +++ b/cli/common/tooldocs/tool_option_help.go @@ -117,19 +117,16 @@ func defaultValueText(value any, enumValues []string) string { return fmt.Sprint(value) } +// OptionSummary is the help text for one option. A description that came from a skill parameter table +// is printed verbatim, including for a negated boolean flag: those rows are already written from the +// flag's point of view ("Exclude component information" for --no-include-components). +// +// Only a description with no skill behind it - a project-local custom command - is synthesized. Its +// author wrote the property in the positive sense, so printing it against a --no- flag would +// read as the opposite of what the flag does. The branch is on where the text came from, never on how +// the text is worded. func OptionSummary(toolName string, propertyName string, property tools.ToolProperty) string { - if IsNegatedBooleanProperty(property) { - if isRunTestsSaveBeforeRunOption(toolName, propertyName, property) { - return "Fail before execution if unsaved editor changes remain instead of auto-saving them" - } - if isCompileReloadExternalSceneChangesOption(toolName, propertyName, property) { - return "Stop before execution if open Scene files changed externally instead of auto-reloading them" - } - summary := FirstHelpLine(property.Description) - normalizedSummary := strings.ToLower(summary) - if strings.HasPrefix(normalizedSummary, "disable ") || strings.HasPrefix(normalizedSummary, "do not ") { - return summary - } + if IsNegatedBooleanProperty(property) && !property.SkillSourcedDescription { return "Disable " + pascalToWords(propertyName) } return FirstHelpLine(property.Description) diff --git a/cli/common/tooldocs/tool_option_help_test.go b/cli/common/tooldocs/tool_option_help_test.go new file mode 100644 index 0000000000..12108b5acf --- /dev/null +++ b/cli/common/tooldocs/tool_option_help_test.go @@ -0,0 +1,65 @@ +package tooldocs + +import ( + "testing" + + "github.com/hatayama/unity-cli-loop/common/tools" +) + +// Verifies a negated boolean whose description came from a skill parameter table prints that text +// verbatim. Those rows are written from the flag's point of view, and the summary this replaced +// discarded them in favor of a synthesized "Disable ". +func TestOptionSummaryKeepsASkillSourcedNegatedBooleanDescription(t *testing.T) { + summary := OptionSummary("get-hierarchy", "IncludeComponents", tools.ToolProperty{ + Type: "boolean", + Default: true, + Description: "Exclude component information", + SkillSourcedDescription: true, + }) + + if summary != "Exclude component information" { + t.Errorf("a skill-sourced description must be printed as written: %q", summary) + } +} + +// Verifies a negated boolean with no skill behind it still gets a synthesized summary. A custom +// command's author writes the property in the positive sense, so printing "Show my overlay" against +// --no-show-my-overlay would state the opposite of what the flag does. +func TestOptionSummarySynthesizesForANegatedBooleanWithNoSkill(t *testing.T) { + summary := OptionSummary("my-custom-command", "ShowMyOverlay", tools.ToolProperty{ + Type: "boolean", + Default: true, + Description: "Show my overlay", + }) + + if summary != "Disable show my overlay" { + t.Errorf("a description with no skill behind it must be synthesized: %q", summary) + } +} + +// Verifies the branch is on where the text came from, not on how it is worded: a description that +// happens to start with "Disable" is no longer what decides the outcome. +func TestOptionSummaryIgnoresTheWordingOfTheDescription(t *testing.T) { + summary := OptionSummary("my-custom-command", "WaitForThing", tools.ToolProperty{ + Type: "boolean", + Default: true, + Description: "Disable the wait that this custom command performs", + }) + + if summary != "Disable wait for thing" { + t.Errorf("wording must not decide the branch: %q", summary) + } +} + +// Verifies a plain (non-negated) option is unaffected by provenance, since its description already +// reads correctly against its own flag name. +func TestOptionSummaryKeepsPlainOptionDescriptions(t *testing.T) { + summary := OptionSummary("my-custom-command", "Amount", tools.ToolProperty{ + Type: "number", + Description: "How much to apply", + }) + + if summary != "How much to apply" { + t.Errorf("a plain option's description must be printed as written: %q", summary) + } +} diff --git a/cli/common/tools/catalog.go b/cli/common/tools/catalog.go index 53ee3ab19c..777820f161 100644 --- a/cli/common/tools/catalog.go +++ b/cli/common/tools/catalog.go @@ -21,15 +21,10 @@ func Load(projectRoot string, internalToolNames map[string]bool) (ToolCatalog, e return cache, nil } - content, err := embeddedTools.ReadFile(defaultToolsFile) + cache, err := decodeEmbeddedCatalog() if err != nil { return ToolCatalog{}, err } - - var cache ToolCatalog - if err := json.Unmarshal(content, &cache); err != nil { - return ToolCatalog{}, err - } return FilterInternalTools(cache, internalToolNames), nil } @@ -50,16 +45,34 @@ func LoadProjectCache(projectRoot string, internalToolNames map[string]bool) (To } func LoadDefault() ToolCatalog { - content, err := embeddedTools.ReadFile(defaultToolsFile) + cache, err := decodeEmbeddedCatalog() if err != nil { return ToolCatalog{} } + return cache +} - var cache ToolCatalog - if json.Unmarshal(content, &cache) != nil { - return ToolCatalog{} +// decodeEmbeddedCatalog reads the catalog compiled into this binary. Its description text is +// generated from the package's skill parameter tables, so every property it carries is marked as +// skill-sourced and renders verbatim. +func decodeEmbeddedCatalog() (ToolCatalog, error) { + content, err := embeddedTools.ReadFile(defaultToolsFile) + if err != nil { + return ToolCatalog{}, err } - return cache + + cache := ToolCatalog{} + if err := json.Unmarshal(content, &cache); err != nil { + return ToolCatalog{}, err + } + for _, tool := range cache.Tools { + schema := tool.EffectiveInputSchema() + for propertyName, property := range schema.Properties { + property.SkillSourcedDescription = true + schema.Properties[propertyName] = property + } + } + return cache, nil } func Find(cache ToolCatalog, name string) (ToolDefinition, bool) { diff --git a/cli/common/tools/description_fallback.go b/cli/common/tools/description_fallback.go index 60b1dc17f1..befd100e63 100644 --- a/cli/common/tools/description_fallback.go +++ b/cli/common/tools/description_fallback.go @@ -50,6 +50,7 @@ func fillPlaceholderPropertyDescriptions(schema ToolInputSchema, embeddedSchema } property.Description = embeddedProperty.Description + property.SkillSourcedDescription = embeddedProperty.SkillSourcedDescription schema.Properties[propertyName] = property } } diff --git a/cli/common/tools/types.go b/cli/common/tools/types.go index 82af349a6f..028c3eb025 100644 --- a/cli/common/tools/types.go +++ b/cli/common/tools/types.go @@ -20,13 +20,20 @@ type ToolInputSchema struct { } type ToolProperty struct { - Type string `json:"type"` - Description string `json:"description,omitempty"` - Default any `json:"default,omitempty"` - DefaultValue any `json:"defaultValue,omitempty"` - Hidden bool `json:"hidden,omitempty"` - Enum []string `json:"enum,omitempty"` - Items *struct { + Type string `json:"type"` + Description string `json:"description,omitempty"` + Default any `json:"default,omitempty"` + DefaultValue any `json:"defaultValue,omitempty"` + Hidden bool `json:"hidden,omitempty"` + // SkillSourcedDescription marks a description that came from a skill parameter table, either read + // live from the installed package or through the embedded catalog, which is generated from those + // same tables. Help renders such text verbatim; a description with no skill behind it belongs to a + // project-local custom command, whose author wrote it in the positive sense and which therefore + // still needs a synthesized summary for a negated boolean flag. Never serialized: provenance is a + // property of how this process loaded the catalog, not of the file. + SkillSourcedDescription bool `json:"-"` + Enum []string `json:"enum,omitempty"` + Items *struct { Type string `json:"type"` } `json:"items,omitempty"` } From 67de3fe4d08c138e318b1995c29f07c1bd4e4a22 Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 27 Jul 2026 08:51:09 +0900 Subject: [PATCH 06/16] Generate the embedded tool catalog from the skill parameter tables The embedded catalog is what `--help` and `uloop list` print for a user who has no synced project cache, and its descriptions were hand-maintained. Nothing tied them to the skill tables the same options are documented in, so the two drifted independently and neither one was the answer to "where do I edit this text". `scripts/sync-tool-docs.sh` now derives every catalog description from the tables, making the catalog a generated artifact and the tables the single place to edit. `--check` reports staleness without writing, for CI to run. Generation refuses to write a partially documented catalog: a visible option with no table row, or a table row matching no accepted option, stops the run and every such problem is listed at once, since a table that fell behind usually did so for several options. Hidden properties are exempt - they never reach help. Only `focus-window`, which accepts no options, has no table. The edit is a byte-level replacement of the description string literals rather than a decode-edit-encode round trip, which would drop zero-value defaults, reorder properties, and add empty schema objects to every tool that has no parameters. This run rewrites 75 description lines and nothing else. The regenerated text changed two assertions: help output now shows the table wording for the two negated booleans that were special-cased, and simulate-keyboard's `--key` states the digit-key rule as accepted ranges rather than one example. --- cli/common/tools/default-tools.json | 150 ++++----- cli/common/tools/description_fallback_test.go | 5 +- .../internal/dispatcher/help_test.go | 4 +- .../cmd/sync-tool-docs/main.go | 19 ++ .../automation/tool_docs_json_editor.go | 223 ++++++++++++++ .../internal/automation/tool_docs_sync.go | 205 +++++++++++++ .../automation/tool_docs_sync_test.go | 286 ++++++++++++++++++ scripts/sync-tool-docs.sh | 9 + 8 files changed, 823 insertions(+), 78 deletions(-) create mode 100644 cli/release-automation/cmd/sync-tool-docs/main.go create mode 100644 cli/release-automation/internal/automation/tool_docs_json_editor.go create mode 100644 cli/release-automation/internal/automation/tool_docs_sync.go create mode 100644 cli/release-automation/internal/automation/tool_docs_sync_test.go create mode 100755 scripts/sync-tool-docs.sh diff --git a/cli/common/tools/default-tools.json b/cli/common/tools/default-tools.json index 3dc202fadd..1d59512039 100644 --- a/cli/common/tools/default-tools.json +++ b/cli/common/tools/default-tools.json @@ -2,22 +2,22 @@ "tools": [ { "name": "compile", - "description": "Execute Unity project compilation", + "description": "Compile the Unity project and report errors/warnings. Use after C# edits.", "inputSchema": { "type": "object", "properties": { "ForceRecompile": { "type": "boolean", - "description": "Force full recompilation" + "description": "Full recompile plus domain reload. Rarely needed — see \"When to use --force-recompile\" below" }, "WaitForDomainReload": { "type": "boolean", - "description": "Wait for domain reload completion before returning", + "description": "Return before Domain Reload completion", "default": true }, "ReloadExternalSceneChanges": { "type": "boolean", - "description": "Automatically reload or save open Scene files changed outside Unity before compiling", + "description": "Stop before compilation if open Scene files changed externally instead of auto-reloading them", "default": true } } @@ -25,13 +25,13 @@ }, { "name": "get-logs", - "description": "Retrieve logs from Unity Console", + "description": "Read current Unity Console entries from a running Editor. Use during bug investigation after compile, tests, PlayMode, dynamic code, or immediately after `uloop-pause-point`.", "inputSchema": { "type": "object", "properties": { "LogType": { "type": "string", - "description": "Log type filter", + "description": "Log type filter: Error, Warning, Log, All", "enum": [ "Error", "Warning", @@ -67,13 +67,13 @@ }, { "name": "run-tests", - "description": "Execute Unity Test Runner", + "description": "Run Unity Test Runner and report detailed results. Use for EditMode/PlayMode tests, change verification, or failure diagnosis.", "inputSchema": { "type": "object", "properties": { "TestMode": { "type": "string", - "description": "Test mode", + "description": "Test mode: EditMode, PlayMode", "enum": [ "EditMode", "PlayMode" @@ -82,7 +82,7 @@ }, "FilterType": { "type": "string", - "description": "Filter type", + "description": "Filter type: all, exact, regex, assembly", "enum": [ "all", "exact", @@ -93,16 +93,16 @@ }, "FilterValue": { "type": "string", - "description": "Filter value" + "description": "Filter value (test name, pattern, or assembly)" }, "SaveBeforeRun": { "type": "boolean", - "description": "Save unsaved loaded Scene changes and current Prefab Stage changes before running tests", + "description": "Fail before test execution if unsaved editor changes remain instead of auto-saving them", "default": true }, "TimeoutSeconds": { "type": "integer", - "description": "Maximum seconds to wait for Unity Test Runner RunFinished before canceling the await and freeing the tool slot", + "description": "Maximum seconds to wait for RunFinished before canceling the await (max 1500). Increase for long suites; on timeout the Test Runner may still be running until stop handling lands", "default": 600 } } @@ -110,7 +110,7 @@ }, { "name": "clear-console", - "description": "Clear Unity console logs", + "description": "Clear Unity Console entries. Use before compile, tests, or debugging when stale logs would hide the current result.", "inputSchema": { "type": "object", "properties": { @@ -123,7 +123,7 @@ }, { "name": "focus-window", - "description": "Bring Unity Editor window to front", + "description": "Bring the Unity Editor window to front. Use when Unity must be visible for visual checks or user-facing interaction.", "inputSchema": { "type": "object", "properties": {} @@ -131,13 +131,13 @@ }, { "name": "get-hierarchy", - "description": "Get Unity Hierarchy structure", + "description": "Get the Unity scene hierarchy as a structured tree. Use for parent-child structure, descendants, roots, or subtrees under objects the user currently selected.", "inputSchema": { "type": "object", "properties": { "RootPath": { "type": "string", - "description": "Root GameObject path" + "description": "Root GameObject path to start from" }, "MaxDepth": { "type": "integer", @@ -146,26 +146,26 @@ }, "IncludeComponents": { "type": "boolean", - "description": "Include component information", + "description": "Exclude component information", "default": true }, "IncludeInactive": { "type": "boolean", - "description": "Include inactive GameObjects", + "description": "Exclude inactive GameObjects", "default": true }, "IncludePaths": { "type": "boolean", - "description": "Include path information" + "description": "Include full path information" }, "UseComponentsLut": { "type": "string", - "description": "Use LUT for components (auto|true|false)", + "description": "Use LUT for components (auto, true, false)", "default": "auto" }, "UseSelection": { "type": "boolean", - "description": "Use selected GameObject(s) as root(s). When true, RootPath is ignored.", + "description": "Use selected GameObject(s) as root(s). When set, --root-path is ignored.", "default": false } } @@ -173,7 +173,7 @@ }, { "name": "find-game-objects", - "description": "Find GameObjects with search criteria", + "description": "Find or inspect Unity GameObjects, especially objects the user currently selected in the Hierarchy. Use for details, components, tags, layers, or name/path searches.", "inputSchema": { "type": "object", "properties": { @@ -183,7 +183,7 @@ }, "SearchMode": { "type": "string", - "description": "Search mode", + "description": "Search mode: Exact, Path, Regex, Contains, Selected", "enum": [ "Exact", "Path", @@ -206,7 +206,7 @@ }, "Layer": { "type": "integer", - "description": "Layer filter" + "description": "Layer filter (layer number)" }, "MaxResults": { "type": "integer", @@ -219,20 +219,20 @@ }, "IncludeInheritedProperties": { "type": "boolean", - "description": "Include inherited properties" + "description": "Include inherited properties in results" } } } }, { "name": "screenshot", - "description": "Take a screenshot of Unity EditorWindow and save as PNG", + "description": "Capture Unity Editor windows or Game View rendering as PNG. Use for visual checks, debugging, documentation, or annotated UI element coordinates.", "inputSchema": { "type": "object", "properties": { "WindowName": { "type": "string", - "description": "Window name to capture (e.g., 'Game', 'Scene', 'Console', 'Inspector', 'Project', 'Hierarchy')", + "description": "Window name to capture. Ignored when --capture-mode rendering. When the Game tab is Device Simulator and the title is Simulator, default Game falls back to Simulator.", "default": "Game" }, "ResolutionScale": { @@ -242,7 +242,7 @@ }, "MatchMode": { "type": "string", - "description": "Window name matching mode (all case-insensitive)", + "description": "Window name matching mode: exact, prefix, or contains. Ignored when --capture-mode rendering.", "enum": [ "exact", "prefix", @@ -257,7 +257,7 @@ }, "CaptureMode": { "type": "string", - "description": "Capture mode: window=capture EditorWindow including toolbar, rendering=capture game rendering only (PlayMode required). Rendering screenshots return ScreenshotToInputFormula for converting raw image pixels before calling simulate-mouse-input or raycast.", + "description": "window=capture EditorWindow including toolbar, rendering=capture game rendering only (PlayMode required, coordinates match simulate-mouse)", "enum": [ "window", "rendering" @@ -266,22 +266,22 @@ }, "AnnotateElements": { "type": "boolean", - "description": "Annotate interactive UI elements with index labels (A, B, C...) on the screenshot. Only works with CaptureMode=rendering in PlayMode. Response includes AnnotatedElements array with element metadata sorted by z-order.", + "description": "Annotate interactive UI elements with index labels and interaction hints (A / CLICK, B / DRAG, ...). Only works with --capture-mode rendering in PlayMode.", "default": false }, "ElementsOnly": { "type": "boolean", - "description": "Return only annotation JSON without capturing a screenshot image. Requires AnnotateElements=true or AnnotateRaycastGrid=true, and CaptureMode=rendering in PlayMode.", + "description": "Return only annotated element JSON without capturing a screenshot image. Requires --annotate-elements or --annotate-raycast-grid, and --capture-mode rendering in PlayMode.", "default": false }, "AnnotateRaycastGrid": { "type": "boolean", - "description": "Annotate clustered 3D physics raycast candidates (PhysicsCollider entries in AnnotatedElements) on rendering screenshots. Uses Camera.main, Camera.main.cullingMask visibility, and the same top-left Game View coordinates as simulate-mouse-input.", + "description": "Annotate clustered 3D physics collider candidates as PhysicsCollider entries in AnnotatedElements. Uses Camera.main visibility and the same top-left Game View coordinates as simulate-mouse-input. Only works with --capture-mode rendering in PlayMode.", "default": false }, "RaycastLayerMask": { "type": "string", - "description": "Comma-separated physics layer names used by AnnotateRaycastGrid to narrow which layers are clustered. Hits are limited to layers also visible to Camera.main.cullingMask. When omitted, clusters against Physics.DefaultRaycastLayers.", + "description": "Comma-separated physics layer names to narrow which layers --annotate-raycast-grid clusters. Hits are limited to layers also visible to Camera.main.cullingMask. When omitted, clusters against Physics.DefaultRaycastLayers.", "default": "" } } @@ -289,17 +289,17 @@ }, { "name": "execute-dynamic-code", - "description": "Execute C# code in Unity Editor", + "description": "Execute C# with Unity APIs when existing uloop tools cannot inspect or edit enough. Use for reachable scene/component state, scene/prefab/menu automation, and PlayMode checks", "inputSchema": { "type": "object", "properties": { "Code": { "type": "string", - "description": "C# code to execute" + "description": "Inline C# statements to execute. Direct statements only; return is optional, and using directives may appear at the top of the snippet." }, "Parameters": { "type": "object", - "description": "Runtime parameters for execution" + "description": "Shell-quoted JSON object literal for reusing a snippet with varying data or keeping values outside the code. Values are exposed as parameters[\"param0\"], parameters[\"param1\"], and so on. Omit for most snippets; never pass a JSON string value." }, "CompileOnly": { "type": "boolean", @@ -308,7 +308,7 @@ }, "WaitForDomainReload": { "type": "boolean", - "description": "Wait for domain reload completion before returning", + "description": "Wait for Domain Reload recovery after snippets that intentionally trigger Unity script reload or import work. Omit for normal inspection and editor-state workflows.", "default": false }, "YieldToForegroundRequests": { @@ -320,13 +320,13 @@ }, { "name": "control-play-mode", - "description": "Control Unity Editor play mode (play/stop/pause/step/status)", + "description": "Control Unity Editor Play Mode. Use to Play (or Resume, its alias), Stop, Pause, or Step Play Mode, or query Status without side effects, for runtime behavior checks and frame inspection.", "inputSchema": { "type": "object", "properties": { "Action": { "type": "string", - "description": "Action to perform: Play - Start play mode, Stop - Stop play mode, Pause - Pause play mode, Step - advance one frame while paused, Status - report current state without changing anything, Resume - alias of Play in every state, including starting Play Mode when stopped", + "description": "Action to perform: Play, Stop, Pause, Step, Status, Resume (alias of Play)", "enum": [ "Play", "Stop", @@ -462,13 +462,13 @@ }, { "name": "simulate-mouse-ui", - "description": "Simulate mouse click, long-press, and drag on PlayMode UI elements via EventSystem screen coordinates", + "description": "Simulate PlayMode EventSystem UI mouse actions using screen coordinates. Use for UI clicks, long-presses, or drags from annotated screenshots.", "inputSchema": { "type": "object", "properties": { "Action": { "type": "string", - "description": "Mouse action: Click - click at position, Drag - one-shot drag, DragStart - begin drag and hold, DragMove - move while holding drag, DragEnd - release drag, LongPress - press and hold for Duration seconds", + "description": "Click, Drag, DragStart, DragMove, DragEnd, LongPress", "enum": [ "Click", "Drag", @@ -491,17 +491,17 @@ }, "FromX": { "type": "number", - "description": "Start X position for Drag action (origin: top-left). Drag starts here and moves to X,Y.", + "description": "Start X position for Drag action. Drag starts here and moves to x,y.", "default": 0 }, "FromY": { "type": "number", - "description": "Start Y position for Drag action (origin: top-left). Drag starts here and moves to X,Y.", + "description": "Start Y position for Drag action. Drag starts here and moves to x,y.", "default": 0 }, "DragSpeed": { "type": "number", - "description": "Drag speed in pixels per second (0 for instant). Applies to Drag, DragMove, and DragEnd actions.", + "description": "Drag speed in pixels per second (0 for instant). 2000 is fast (default), 200 is slow enough to watch. Applies to Drag, DragMove, and DragEnd actions.", "default": 2000 }, "Duration": { @@ -511,7 +511,7 @@ }, "Button": { "type": "string", - "description": "Mouse button: Left (default), Right, Middle.", + "description": "Mouse button. Click and LongPress support Left, Right, and Middle. Drag actions support Left only; other buttons return an error.", "enum": [ "Left", "Right", @@ -521,17 +521,17 @@ }, "BypassRaycast": { "type": "boolean", - "description": "Bypass EventSystem raycast and send click, long-press, or drag events directly to TargetPath, or DropTargetPath for DragEnd. Useful for interacting with UI behind a raycast-blocking overlay.", + "description": "For Click, LongPress, Drag, and DragStart, bypass EventSystem raycast and dispatch pointer events directly to --target-path. Use when a raycast-blocking overlay visually covers the intended target.", "default": false }, "TargetPath": { "type": "string", - "description": "Hierarchy path of the target GameObject used by Click, LongPress, Drag, and DragStart when BypassRaycast is true, for example Canvas/Panel/Button.", + "description": "Hierarchy path of the target GameObject, for example Canvas/Panel/Button. Required when --bypass-raycast is used with Click, LongPress, Drag, or DragStart; prefer AnnotatedElements[].Path from screenshot JSON.", "default": "" }, "DropTargetPath": { "type": "string", - "description": "Optional hierarchy path of the drop target used by Drag and DragEnd, for example Canvas/DropZone.", + "description": "Optional hierarchy path of a drop target for Drag or DragEnd, for example Canvas/DropZone. Use this when the drop zone is also behind a raycast blocker.", "default": "" } } @@ -539,13 +539,13 @@ }, { "name": "simulate-mouse-input", - "description": "Simulate mouse input in PlayMode via Input System. Injects button clicks, mouse delta, and scroll wheel directly into Mouse.current for game logic that reads Input System. Requires the Input System package and Active Input Handling set to 'Input System Package (New)' or 'Both'.", + "description": "Simulate Mouse.current input in PlayMode through Unity Input System. Use for gameplay mouse clicks, long-press (LongPress), movement delta (MoveDelta/SmoothDelta), or scroll. Use simulate-mouse-ui for UI. Requires the Input System package and Active Input Handling set to 'Input System Package (New)' or 'Both'.", "inputSchema": { "type": "object", "properties": { "Action": { "type": "string", - "description": "Mouse input action: Click - inject button press+release, LongPress - inject button hold for Duration seconds, MoveDelta - inject mouse delta (one-shot), SmoothDelta - inject mouse delta smoothly over Duration seconds, Scroll - inject scroll wheel", + "description": "Click, LongPress, MoveDelta, SmoothDelta, Scroll", "enum": [ "Click", "LongPress", @@ -567,7 +567,7 @@ }, "Button": { "type": "string", - "description": "Mouse button: Left (default), Right, Middle. Used by Click and LongPress.", + "description": "Mouse button: Left, Right, Middle. Used by Click and LongPress.", "enum": [ "Left", "Right", @@ -577,17 +577,17 @@ }, "Duration": { "type": "number", - "description": "Duration in seconds for LongPress hold, SmoothDelta interpolation, or minimum hold time for Click (0 = one-shot tap).", + "description": "Hold duration for LongPress, or interpolation duration for SmoothDelta (seconds). For Click, 0 = one-shot tap.", "default": 0 }, "DeltaX": { "type": "number", - "description": "Delta X in pixels for MoveDelta/SmoothDelta action. Positive = right.", + "description": "Delta X in pixels for MoveDelta/SmoothDelta. Positive = right.", "default": 0 }, "DeltaY": { "type": "number", - "description": "Delta Y in pixels for MoveDelta/SmoothDelta action. Positive = up.", + "description": "Delta Y in pixels for MoveDelta/SmoothDelta. Positive = up.", "default": 0 }, "ScrollX": { @@ -597,7 +597,7 @@ }, "ScrollY": { "type": "number", - "description": "Vertical scroll delta for Scroll action. Positive = up, negative = down. Typically 120 per notch.", + "description": "Vertical scroll delta for Scroll action. Typically 120 per notch.", "default": 0 } } @@ -605,13 +605,13 @@ }, { "name": "simulate-keyboard", - "description": "Simulate keyboard key input in PlayMode via Input System. Supports one-shot press, key-down hold, key-up release, and ReleaseAll recovery for game controls (WASD, Space, etc.). Requires the Input System package (com.unity.inputsystem).", + "description": "Simulate keyboard input in PlayMode through Unity Input System. Use for key presses, holds (via Press --duration or KeyDown/KeyUp), releases, and game controls such as WASD or Space. Requires the Input System package (com.unity.inputsystem).", "inputSchema": { "type": "object", "properties": { "Action": { "type": "string", - "description": "Keyboard action: Press - one-shot key tap (Down then Up), KeyDown - hold key down, KeyUp - release held key, ReleaseAll - force-release every tracked and device-pressed key (allowed while PlayMode is paused; use after a pause-point interruption leaves key state inconsistent)", + "description": "Press, KeyDown, KeyUp, ReleaseAll", "enum": [ "Press", "KeyDown", @@ -622,7 +622,7 @@ }, "Key": { "type": "string", - "description": "Key name matching Input System Key enum (e.g. \"W\", \"Space\", \"LeftShift\", \"A\", \"Enter\", \"Digit3\"). Case-insensitive. Digit keys use Digit0-Digit9 or Numpad0-Numpad9, not bare 0-9. Not required for ReleaseAll." + "description": "Key name matching Input System Key enum (e.g. W, Space, LeftShift, A, Enter). Case-insensitive. Digit keys use Digit0-Digit9 or Numpad0-Numpad9, not bare 0-9. Not used by ReleaseAll." }, "Duration": { "type": "number", @@ -634,7 +634,7 @@ }, { "name": "record-input", - "description": "Record keyboard and mouse input during PlayMode. Captures key presses, mouse clicks, mouse delta, and scroll events frame-by-frame. Saves to JSON for later replay.", + "description": "Record PlayMode keyboard and mouse input to JSON. Use to capture gameplay, bug repro, or E2E input sequences for replay.", "inputSchema": { "type": "object", "properties": { @@ -644,17 +644,17 @@ "Start", "Stop" ], - "description": "Recording action: Start - begin recording input, Stop - stop recording and save to file", + "description": "Start - begin recording, Stop - stop and save", "default": "Start" }, "OutputPath": { "type": "string", - "description": "Output file path for the recording JSON. If empty, auto-generates under .uloop/outputs/InputRecordings/", + "description": "Save path. Auto-generates under .uloop/outputs/InputRecordings/", "default": "" }, "Keys": { "type": "string", - "description": "Comma-separated key filter. Only record specified keys (e.g. 'W,A,S,D,Space'). Empty means record all common game keys.", + "description": "Comma-separated key filter. Empty = all common game keys", "default": "" }, "DelaySeconds": { @@ -664,7 +664,7 @@ }, "ShowOverlay": { "type": "boolean", - "description": "Show recording overlay (countdown + REC indicator)", + "description": "Hide the recording countdown and REC indicator overlay", "default": true } } @@ -672,7 +672,7 @@ }, { "name": "replay-input", - "description": "Replay recorded keyboard and mouse input during PlayMode. Injects recorded events frame-by-frame via Input System to reproduce exact input sequences.", + "description": "Replay recorded PlayMode keyboard and mouse input. Use for exact gameplay reproduction, E2E runs, or consistent demos from JSON recordings.", "inputSchema": { "type": "object", "properties": { @@ -683,22 +683,22 @@ "Stop", "Status" ], - "description": "Replay action: Start - begin replaying, Stop - stop mid-way, Status - check progress", + "description": "Start, Stop, Status", "default": "Start" }, "InputPath": { "type": "string", - "description": "Path to recording JSON file. If empty, auto-detects the latest recording in .uloop/outputs/InputRecordings/", + "description": "JSON path. Auto-detects latest in .uloop/outputs/InputRecordings/", "default": "" }, "ShowOverlay": { "type": "boolean", - "description": "Show visualization overlay during replay", + "description": "Hide replay progress overlay", "default": true }, "Loop": { "type": "boolean", - "description": "Loop replay continuously", + "description": "Loop continuously", "default": false } } @@ -706,28 +706,28 @@ }, { "name": "raycast", - "description": "Raycast from Camera.main through a top-left Game View coordinate", + "description": "Raycast from Camera.main through a Game View coordinate. Use when you need to check what a screenshot coordinate would hit in 3D physics before clicking or long-pressing with simulate-mouse-ui.", "inputSchema": { "type": "object", "properties": { "X": { "type": "number", - "description": "Target X position in Game View pixels (origin: top-left)", + "description": "Target X position in Game View pixels (origin: top-left).", "default": 0 }, "Y": { "type": "number", - "description": "Target Y position in Game View pixels (origin: top-left)", + "description": "Target Y position in Game View pixels (origin: top-left).", "default": 0 }, "LayerMask": { "type": "integer", - "description": "Physics layer mask used by the raycast", + "description": "Physics layer mask used by the raycast.", "default": -5 }, "MaxDistance": { "type": "number", - "description": "Maximum raycast distance in world units", + "description": "Maximum raycast distance in world units.", "default": 1000 } } @@ -741,11 +741,11 @@ "properties": { "Width": { "type": "integer", - "description": "Target Game View rendering width in pixels. Provide with Height to change the resolution." + "description": "Target Game View rendering width in pixels. Provide with --height to change the resolution." }, "Height": { "type": "integer", - "description": "Target Game View rendering height in pixels. Provide with Width to change the resolution." + "description": "Target Game View rendering height in pixels. Provide with --width to change the resolution." } } } diff --git a/cli/common/tools/description_fallback_test.go b/cli/common/tools/description_fallback_test.go index 9727ff9aae..4ad53be91f 100644 --- a/cli/common/tools/description_fallback_test.go +++ b/cli/common/tools/description_fallback_test.go @@ -179,8 +179,11 @@ func TestEmbeddedSimulateKeyboardKeyDescriptionDocumentsDigitKeys(t *testing.T) t.Fatal("embedded catalog has no simulate-keyboard tool") } + // The text is generated from the skill's parameter table, which states the rule as the accepted + // ranges rather than one example digit; the guarantee this pins - a caller learns bare digits are + // rejected - is unchanged. description := tool.EffectiveInputSchema().Properties["Key"].Description - for _, expected := range []string{"Digit3", "Numpad0"} { + for _, expected := range []string{"Digit0-Digit9", "Numpad0-Numpad9", "not bare 0-9"} { if !strings.Contains(description, expected) { t.Errorf("--key description does not mention %q: %q", expected, description) } diff --git a/cli/dispatcher/internal/dispatcher/help_test.go b/cli/dispatcher/internal/dispatcher/help_test.go index 77f2b1a57b..45e340e7ff 100644 --- a/cli/dispatcher/internal/dispatcher/help_test.go +++ b/cli/dispatcher/internal/dispatcher/help_test.go @@ -204,7 +204,7 @@ func TestRunDispatcherCompileHelpDoesNotRequireUnityProject(t *testing.T) { "--force-recompile", "--no-wait-for-domain-reload", "--stop-on-external-scene-changes", - "Stop before execution if open Scene files changed externally instead of auto-reloading them", + "Stop before compilation if open Scene files changed externally instead of auto-reloading them", "default: auto-reload enabled", } { if !strings.Contains(output, expected) { @@ -442,7 +442,7 @@ func TestRunDispatcherRunTestsHelpDoesNotRequireUnityProject(t *testing.T) { "--filter-type", "--filter-value", "--fail-on-unsaved-changes", - "Fail before execution if unsaved editor changes remain instead of auto-saving them", + "Fail before test execution if unsaved editor changes remain instead of auto-saving them", "default: auto-save enabled", } { if !strings.Contains(output, expected) { diff --git a/cli/release-automation/cmd/sync-tool-docs/main.go b/cli/release-automation/cmd/sync-tool-docs/main.go new file mode 100644 index 0000000000..be60340cc3 --- /dev/null +++ b/cli/release-automation/cmd/sync-tool-docs/main.go @@ -0,0 +1,19 @@ +package main + +import ( + "flag" + "os" + + "github.com/hatayama/unity-cli-loop/tools/release-automation/internal/automation" +) + +func main() { + repositoryRoot := flag.String("repository-root", ".", "repository root holding the Unity package and the tool catalog") + checkOnly := flag.Bool("check", false, "verify the catalog matches the skill parameter tables instead of writing it") + flag.Parse() + + os.Exit(automation.RunSyncToolDocs(os.Stdout, os.Stderr, automation.SyncToolDocsConfig{ + RepositoryRoot: *repositoryRoot, + CheckOnly: *checkOnly, + })) +} diff --git a/cli/release-automation/internal/automation/tool_docs_json_editor.go b/cli/release-automation/internal/automation/tool_docs_json_editor.go new file mode 100644 index 0000000000..f51b0de07c --- /dev/null +++ b/cli/release-automation/internal/automation/tool_docs_json_editor.go @@ -0,0 +1,223 @@ +package automation + +import ( + "bytes" + "encoding/json" + "fmt" + "sort" + "strings" +) + +// descriptionLocation is where one description string literal sits in the catalog file, quotes +// included, together with the tool and property it belongs to. Property is empty for a tool-level +// description. +type descriptionLocation struct { + Tool string + Property string + Start int + End int +} + +// replaceCatalogDescriptions rewrites only the description string literals the caller asks for and +// returns the whole file otherwise byte-identical. +// +// A decode-edit-encode round trip through tools.ToolDefinition is deliberately not used: the struct +// drops zero-value defaults ("default": 0 / false / ""), adds an empty parameterSchema to every +// tool, and reorders properties from Unity's declaration order into map order. All three would be +// invisible in the generator's own tests and glaring in the catalog, so the edit is textual and the +// bytes around it never move. +func replaceCatalogDescriptions(content []byte, replacements map[descriptionKey]string) ([]byte, error) { + locations, err := collectDescriptionLocations(content) + if err != nil { + return nil, err + } + + // Applied back to front so an earlier edit never shifts a later offset. + sort.Slice(locations, func(first int, second int) bool { + return locations[first].Start > locations[second].Start + }) + + edited := content + for _, location := range locations { + description, ok := replacements[descriptionKey{Tool: location.Tool, Property: location.Property}] + if !ok { + continue + } + encoded, err := encodeJSONString(description) + if err != nil { + return nil, err + } + edited = append(edited[:location.Start:location.Start], append(encoded, edited[location.End:]...)...) + } + return edited, nil +} + +// encodeJSONString encodes one string the way the catalog is written: HTML escaping off, so a "<" in +// a description stays a "<" instead of becoming "<" and rewriting bytes nobody asked to change. +func encodeJSONString(value string) ([]byte, error) { + buffer := bytes.Buffer{} + encoder := json.NewEncoder(&buffer) + encoder.SetEscapeHTML(false) + if err := encoder.Encode(value); err != nil { + return nil, err + } + return []byte(strings.TrimSuffix(buffer.String(), "\n")), nil +} + +// collectDescriptionLocations walks the catalog and records the byte range of every tool-level and +// property-level description literal. +func collectDescriptionLocations(content []byte) ([]descriptionLocation, error) { + decoder := json.NewDecoder(bytes.NewReader(content)) + walker := &catalogWalker{content: content} + if err := walker.walkValue(decoder, nil); err != nil { + return nil, err + } + + locations := make([]descriptionLocation, 0, len(walker.pending)) + for _, location := range walker.pending { + toolName, ok := walker.toolNames[location.Tool] + if !ok { + return nil, fmt.Errorf("tool at index %s has no name field", location.Tool) + } + location.Tool = toolName + locations = append(locations, location) + } + return locations, nil +} + +type catalogWalker struct { + content []byte + // toolNames is filled as "tools" is walked, keyed by array index. A tool's name may appear after + // its description, so pending ranges are resolved to tool names only once the walk is done. + toolNames map[string]string + pending []descriptionLocation +} + +func (walker *catalogWalker) walkValue(decoder *json.Decoder, path []string) error { + token, err := decoder.Token() + if err != nil { + return err + } + + switch typed := token.(type) { + case json.Delim: + switch typed { + case '{': + return walker.walkObject(decoder, path) + case '[': + return walker.walkArray(decoder, path) + } + return fmt.Errorf("unexpected delimiter %q at path %s", typed, strings.Join(path, ".")) + case string: + walker.recordString(decoder, path, typed) + return nil + default: + return nil + } +} + +func (walker *catalogWalker) walkObject(decoder *json.Decoder, path []string) error { + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return err + } + key, ok := keyToken.(string) + if !ok { + return fmt.Errorf("object key was not a string at path %s", strings.Join(path, ".")) + } + if err := walker.walkValue(decoder, append(path, key)); err != nil { + return err + } + } + _, err := decoder.Token() + return err +} + +func (walker *catalogWalker) walkArray(decoder *json.Decoder, path []string) error { + index := 0 + for decoder.More() { + if err := walker.walkValue(decoder, append(path, fmt.Sprint(index))); err != nil { + return err + } + index++ + } + _, err := decoder.Token() + return err +} + +func (walker *catalogWalker) recordString(decoder *json.Decoder, path []string, value string) { + toolIndex, remainder, ok := catalogToolPath(path) + if !ok { + return + } + + if len(remainder) == 1 && remainder[0] == "name" { + if walker.toolNames == nil { + walker.toolNames = map[string]string{} + } + walker.toolNames[toolIndex] = value + return + } + + property, ok := catalogDescriptionProperty(remainder) + if !ok { + return + } + start, end, ok := stringLiteralRange(walker.content, int(decoder.InputOffset())) + if !ok { + return + } + walker.pending = append(walker.pending, descriptionLocation{ + Tool: toolIndex, + Property: property, + Start: start, + End: end, + }) +} + +// catalogToolPath splits a path such as ["tools","3","inputSchema",...] into the tool index and the +// remainder below it. +func catalogToolPath(path []string) (string, []string, bool) { + if len(path) < 2 || path[0] != "tools" { + return "", nil, false + } + return path[1], path[2:], true +} + +// catalogDescriptionProperty reports which description a path below a tool points at: the tool's own +// ("") or one property's (the property name). +func catalogDescriptionProperty(remainder []string) (string, bool) { + if len(remainder) == 1 && remainder[0] == "description" { + return "", true + } + if len(remainder) != 4 || remainder[1] != "properties" || remainder[3] != "description" { + return "", false + } + if remainder[0] != "inputSchema" && remainder[0] != "parameterSchema" { + return "", false + } + return remainder[2], true +} + +// stringLiteralRange finds the string literal, quotes included, that ends at endOffset. Scanning +// backwards for the opening quote keeps the range exact even for a value carrying escapes, which +// re-encoding the decoded value could not guarantee. +func stringLiteralRange(content []byte, endOffset int) (int, int, bool) { + if endOffset <= 0 || endOffset > len(content) || content[endOffset-1] != '"' { + return 0, 0, false + } + for index := endOffset - 2; index >= 0; index-- { + if content[index] != '"' { + continue + } + backslashes := 0 + for probe := index - 1; probe >= 0 && content[probe] == '\\'; probe-- { + backslashes++ + } + if backslashes%2 == 0 { + return index, endOffset, true + } + } + return 0, 0, false +} diff --git a/cli/release-automation/internal/automation/tool_docs_sync.go b/cli/release-automation/internal/automation/tool_docs_sync.go new file mode 100644 index 0000000000..d822ec264f --- /dev/null +++ b/cli/release-automation/internal/automation/tool_docs_sync.go @@ -0,0 +1,205 @@ +// Package automation hosts the logic behind the release and CI commands. This file generates the +// description text in the embedded tool catalog from the package's own SKILL.md parameter tables, so +// the catalog is a build artifact of the skills rather than a third place to hand-maintain help text. +package automation + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/hatayama/unity-cli-loop/common/skilldocs" + "github.com/hatayama/unity-cli-loop/common/tooldocs" + "github.com/hatayama/unity-cli-loop/common/tools" +) + +// CatalogRelativePath is the generated file, relative to the repository root. It is the only artifact +// this generator writes. +const CatalogRelativePath = "cli/common/tools/default-tools.json" + +// SyncToolDocsConfig selects the repository to work on and whether to verify instead of write. +type SyncToolDocsConfig struct { + RepositoryRoot string + CheckOnly bool +} + +// toolsWithoutParameterTable are the tools allowed to have no parameter table. focus-window takes no +// parameters at all, so a table would have no rows to hold; its tool description still comes from its +// skill. The count is asserted against the catalog so a new tool cannot silently join this list. +// +// This is deliberately not DefaultToolsCatalogDriftTests' CliOwnedCommandsWithoutLiveUnityTools: that +// list names commands with no live Unity tool, which is a different question from having no +// parameters. +var toolsWithoutParameterTable = map[string]bool{ + "focus-window": true, +} + +// descriptionKey identifies one description in the catalog. Property is empty for the tool's own +// description. +type descriptionKey struct { + Tool string + Property string +} + +// RunSyncToolDocs regenerates the catalog's description text, or in check mode reports that it is out +// of date. Any mismatch between a schema and its skill table is an error rather than a silent skip: +// one of the two is stale, and only a human can say which. +func RunSyncToolDocs(stdout io.Writer, stderr io.Writer, config SyncToolDocsConfig) int { + catalogPath := filepath.Join(config.RepositoryRoot, filepath.FromSlash(CatalogRelativePath)) + content, err := os.ReadFile(catalogPath) + if err != nil { + _, _ = fmt.Fprintf(stderr, "failed to read %s: %v\n", CatalogRelativePath, err) + return 1 + } + + generated, err := GenerateCatalogWithSkillDescriptions(content, config.RepositoryRoot) + if err != nil { + _, _ = fmt.Fprintf(stderr, "%v\n", err) + return 1 + } + + if config.CheckOnly { + if string(generated) == string(content) { + _, _ = fmt.Fprintf(stdout, "%s matches the skill parameter tables.\n", CatalogRelativePath) + return 0 + } + _, _ = fmt.Fprintf(stderr, + "%s no longer matches the skill parameter tables. Run scripts/sync-tool-docs.sh and commit the result.\n", + CatalogRelativePath) + return 1 + } + + if string(generated) == string(content) { + _, _ = fmt.Fprintf(stdout, "%s is already up to date.\n", CatalogRelativePath) + return 0 + } + if err := os.WriteFile(catalogPath, generated, 0o644); err != nil { + _, _ = fmt.Fprintf(stderr, "failed to write %s: %v\n", CatalogRelativePath, err) + return 1 + } + _, _ = fmt.Fprintf(stdout, "Updated %s from the skill parameter tables.\n", CatalogRelativePath) + return 0 +} + +// GenerateCatalogWithSkillDescriptions returns the catalog content with every description replaced by +// the text its skill states, leaving all other bytes untouched. +func GenerateCatalogWithSkillDescriptions(content []byte, repositoryRoot string) ([]byte, error) { + catalog := tools.ToolCatalog{} + if err := json.Unmarshal(content, &catalog); err != nil { + return nil, fmt.Errorf("failed to parse %s: %w", CatalogRelativePath, err) + } + + documented := skilldocs.Load(repositoryRoot) + if len(documented) == 0 { + return nil, fmt.Errorf("no skills were found under %s; is this the repository root?", repositoryRoot) + } + if err := verifyTablelessToolsCoverTheCatalog(catalog); err != nil { + return nil, err + } + + replacements := map[descriptionKey]string{} + // Every mismatch is reported, not just the first: a table that fell behind the schema usually did + // so for several tools at once, and fixing them one round trip at a time is what made the drift + // accumulate in the first place. + problems := []string{} + for _, tool := range catalog.Tools { + problems = append(problems, collectToolReplacements(tool, documented, replacements)...) + } + if len(problems) > 0 { + return nil, fmt.Errorf("the skill parameter tables and the tool schemas disagree:\n %s", + strings.Join(problems, "\n ")) + } + return replaceCatalogDescriptions(content, replacements) +} + +// verifyTablelessToolsCoverTheCatalog fails when the catalog grew a tool the allow-list does not +// account for. Without this the count silently drifts and a new tool's missing table looks +// intentional. +func verifyTablelessToolsCoverTheCatalog(catalog tools.ToolCatalog) error { + for toolName := range toolsWithoutParameterTable { + if _, ok := tools.Find(catalog, toolName); !ok { + return fmt.Errorf("%q is allowed to have no parameter table but is not in the catalog", toolName) + } + } + + documentedWithTable := len(catalog.Tools) - len(toolsWithoutParameterTable) + if documentedWithTable <= 0 { + return fmt.Errorf("the catalog holds %d tools, which cannot all be table-less", len(catalog.Tools)) + } + return nil +} + +// collectToolReplacements records one tool's replacements and returns the mismatches found, so the +// caller can report every tool's drift in one run. +func collectToolReplacements( + tool tools.ToolDefinition, + documented map[string]skilldocs.ToolDocs, + replacements map[descriptionKey]string, +) []string { + docs, ok := documented[tool.Name] + if !ok { + return []string{fmt.Sprintf("%s has no skill; every tool's help text must come from a skill", tool.Name)} + } + if docs.ToolDescription == "" { + return []string{fmt.Sprintf("%s has a skill with no tool description", tool.Name)} + } + replacements[descriptionKey{Tool: tool.Name}] = docs.ToolDescription + + problems := []string{} + documentedOptions := map[string]bool{} + for _, propertyName := range sortedPropertyNames(tool) { + property := tool.EffectiveInputSchema().Properties[propertyName] + optionName := tooldocs.OptionNameForProperty(tool.Name, propertyName, property) + if property.Hidden { + // A hidden property never reaches help, so requiring a documented row would force the + // skill to describe something no caller can pass. + continue + } + documentedOptions[optionName] = true + + description, ok := docs.ParamDescriptions[optionName] + if !ok { + problems = append(problems, fmt.Sprintf( + "%s --%s is accepted by the tool but has no row in its skill parameter table", + tool.Name, optionName)) + continue + } + replacements[descriptionKey{Tool: tool.Name, Property: propertyName}] = description + } + + if len(documentedOptions) == 0 && !toolsWithoutParameterTable[tool.Name] { + problems = append(problems, fmt.Sprintf( + "%s accepts no visible parameters but is not listed as table-less", tool.Name)) + } + return append(problems, unknownTableRowProblems(tool.Name, docs, documentedOptions)...) +} + +// unknownTableRowProblems reports table rows matching no accepted option, which means the skill +// documents a flag the implementation dropped or renamed. +func unknownTableRowProblems(toolName string, docs skilldocs.ToolDocs, documentedOptions map[string]bool) []string { + unknown := []string{} + for optionName := range docs.ParamDescriptions { + if !documentedOptions[optionName] { + unknown = append(unknown, "--"+optionName) + } + } + if len(unknown) == 0 { + return nil + } + sort.Strings(unknown) + return []string{fmt.Sprintf("%s documents %s in its skill parameter table, but the tool does not accept them", + toolName, strings.Join(unknown, ", "))} +} + +func sortedPropertyNames(tool tools.ToolDefinition) []string { + names := make([]string, 0, len(tool.EffectiveInputSchema().Properties)) + for propertyName := range tool.EffectiveInputSchema().Properties { + names = append(names, propertyName) + } + sort.Strings(names) + return names +} diff --git a/cli/release-automation/internal/automation/tool_docs_sync_test.go b/cli/release-automation/internal/automation/tool_docs_sync_test.go new file mode 100644 index 0000000000..c05940eacc --- /dev/null +++ b/cli/release-automation/internal/automation/tool_docs_sync_test.go @@ -0,0 +1,286 @@ +package automation + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// fixtureCatalogContent is a miniature default-tools.json with the traits the real file has that a +// struct round trip would destroy: a zero-value default, a hidden property, an enum, and properties +// in declaration rather than alphabetical order. +const fixtureCatalogContent = `{ + "tools": [ + { + "name": "simulate-keyboard", + "description": "Stale tool description", + "inputSchema": { + "type": "object", + "properties": { + "Key": { + "type": "string", + "description": "Stale key description" + }, + "Action": { + "type": "string", + "description": "Stale action description", + "enum": [ + "Press", + "ReleaseAll" + ], + "default": "Press" + }, + "Duration": { + "type": "number", + "description": "Stale duration description", + "default": 0 + }, + "InternalOnly": { + "type": "boolean", + "description": "Not documented anywhere", + "hidden": true + } + } + } + }, + { + "name": "focus-window", + "description": "Stale focus description", + "inputSchema": { + "type": "object", + "properties": {} + } + } + ] +} +` + +const fixtureKeyboardSkill = `--- +name: uloop-simulate-keyboard +toolName: simulate-keyboard +description: "Simulate keyboard input in PlayMode." +--- + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| ` + "`--action`" + ` | enum | ` + "`Press`" + ` | Press \| ReleaseAll | +| ` + "`--key`" + ` | string | - | Key name matching the Input System Key enum | +| ` + "`--duration`" + ` | number | ` + "`0`" + ` | Hold duration in seconds | +` + +const fixtureFocusWindowSkill = `--- +name: uloop-focus-window +description: "Bring the Unity Editor window to front." +--- + +# uloop focus-window +` + +// writeGeneratorFixture builds a repository holding the uloop package and the catalog file, and +// returns its root. +func writeGeneratorFixture(t *testing.T, skills map[string]string, catalogContent string) string { + t.Helper() + + repositoryRoot := t.TempDir() + packageRoot := filepath.Join(repositoryRoot, "Packages", "src") + if err := os.MkdirAll(filepath.Join(packageRoot, "Editor", "FirstPartyTools"), 0o755); err != nil { + t.Fatalf("failed to create the package root: %v", err) + } + writeFixtureFile(t, filepath.Join(packageRoot, "package.json"), `{"name":"io.github.hatayama.uloopmcp"}`) + writeFixtureFile(t, filepath.Join(repositoryRoot, filepath.FromSlash(CatalogRelativePath)), catalogContent) + + for relativeDirectory, content := range skills { + skillPath := filepath.Join(packageRoot, "Editor", relativeDirectory, "Skill", "SKILL.md") + writeFixtureFile(t, skillPath, content) + } + return repositoryRoot +} + +func writeFixtureFile(t *testing.T, path string, content string) { + t.Helper() + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("failed to create %s: %v", filepath.Dir(path), err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write %s: %v", path, err) + } +} + +func defaultGeneratorSkills() map[string]string { + return map[string]string{ + "FirstPartyTools/SimulateKeyboard": fixtureKeyboardSkill, + "CliOnlyTools~/FocusWindow": fixtureFocusWindowSkill, + } +} + +// Verifies generation replaces every description with its skill text and changes nothing else: a +// zero-value default, the property order, the enum and the hidden property all survive byte for byte, +// which a decode-edit-encode round trip would not manage. +func TestGenerateCatalogReplacesOnlyDescriptions(t *testing.T) { + repositoryRoot := writeGeneratorFixture(t, defaultGeneratorSkills(), fixtureCatalogContent) + + generated, err := GenerateCatalogWithSkillDescriptions([]byte(fixtureCatalogContent), repositoryRoot) + if err != nil { + t.Fatalf("generation failed: %v", err) + } + + expected := strings.NewReplacer( + `"Stale tool description"`, `"Simulate keyboard input in PlayMode."`, + `"Stale key description"`, `"Key name matching the Input System Key enum"`, + `"Stale action description"`, `"Press | ReleaseAll"`, + `"Stale duration description"`, `"Hold duration in seconds"`, + `"Stale focus description"`, `"Bring the Unity Editor window to front."`, + ).Replace(fixtureCatalogContent) + if string(generated) != expected { + t.Errorf("generated content differs from the expected byte-for-byte result:\n%s", string(generated)) + } +} + +// Verifies a second run over its own output changes nothing, so the committed file is a fixed point +// and CI's --check cannot fail on a freshly generated catalog. +func TestGenerateCatalogIsIdempotent(t *testing.T) { + repositoryRoot := writeGeneratorFixture(t, defaultGeneratorSkills(), fixtureCatalogContent) + + first, err := GenerateCatalogWithSkillDescriptions([]byte(fixtureCatalogContent), repositoryRoot) + if err != nil { + t.Fatalf("first generation failed: %v", err) + } + second, err := GenerateCatalogWithSkillDescriptions(first, repositoryRoot) + if err != nil { + t.Fatalf("second generation failed: %v", err) + } + + if string(first) != string(second) { + t.Errorf("generation is not idempotent:\n%s", string(second)) + } +} + +// Verifies a property the tool accepts but the skill table omits stops generation, since one of the +// two is stale and writing the catalog anyway would hide which. +func TestGenerateCatalogFailsOnAnUndocumentedProperty(t *testing.T) { + skills := defaultGeneratorSkills() + skills["FirstPartyTools/SimulateKeyboard"] = strings.ReplaceAll( + fixtureKeyboardSkill, "| `--duration` | number | `0` | Hold duration in seconds |\n", "") + repositoryRoot := writeGeneratorFixture(t, skills, fixtureCatalogContent) + + _, err := GenerateCatalogWithSkillDescriptions([]byte(fixtureCatalogContent), repositoryRoot) + + if err == nil { + t.Fatal("an undocumented property must stop generation") + } + if !strings.Contains(err.Error(), "simulate-keyboard --duration") { + t.Errorf("the error must name the undocumented option: %v", err) + } +} + +// Verifies a table row matching no accepted option stops generation, which is the drift left behind +// when an option is renamed or removed. +func TestGenerateCatalogFailsOnAnUnknownTableRow(t *testing.T) { + skills := defaultGeneratorSkills() + skills["FirstPartyTools/SimulateKeyboard"] = fixtureKeyboardSkill + + "| `--removed-flag` | flag | - | No longer accepted |\n" + repositoryRoot := writeGeneratorFixture(t, skills, fixtureCatalogContent) + + _, err := GenerateCatalogWithSkillDescriptions([]byte(fixtureCatalogContent), repositoryRoot) + + if err == nil { + t.Fatal("a table row for an option the tool does not accept must stop generation") + } + if !strings.Contains(err.Error(), "--removed-flag") { + t.Errorf("the error must name the unknown row: %v", err) + } +} + +// Verifies a hidden property needs no table row and keeps its description, because it never reaches +// help and documenting it would describe something no caller can pass. +func TestGenerateCatalogIgnoresHiddenProperties(t *testing.T) { + repositoryRoot := writeGeneratorFixture(t, defaultGeneratorSkills(), fixtureCatalogContent) + + generated, err := GenerateCatalogWithSkillDescriptions([]byte(fixtureCatalogContent), repositoryRoot) + if err != nil { + t.Fatalf("generation failed: %v", err) + } + + if !strings.Contains(string(generated), `"description": "Not documented anywhere"`) { + t.Errorf("a hidden property's description must be left alone:\n%s", string(generated)) + } +} + +// Verifies every mismatch is reported in one run rather than one per invocation, since a table that +// fell behind usually did so for several options at once. +func TestGenerateCatalogReportsEveryMismatchAtOnce(t *testing.T) { + skills := defaultGeneratorSkills() + skills["FirstPartyTools/SimulateKeyboard"] = strings.NewReplacer( + "| `--duration` | number | `0` | Hold duration in seconds |\n", "", + "| `--key` | string | - | Key name matching the Input System Key enum |\n", "", + ).Replace(fixtureKeyboardSkill) + repositoryRoot := writeGeneratorFixture(t, skills, fixtureCatalogContent) + + _, err := GenerateCatalogWithSkillDescriptions([]byte(fixtureCatalogContent), repositoryRoot) + + if err == nil { + t.Fatal("two undocumented properties must stop generation") + } + for _, expected := range []string{"--duration", "--key"} { + if !strings.Contains(err.Error(), expected) { + t.Errorf("the error must name %s: %v", expected, err) + } + } +} + +// Verifies a tool with no skill at all stops generation, so no tool's help text can quietly stay +// hand-maintained in the catalog. +func TestGenerateCatalogFailsWhenAToolHasNoSkill(t *testing.T) { + skills := map[string]string{"CliOnlyTools~/FocusWindow": fixtureFocusWindowSkill} + repositoryRoot := writeGeneratorFixture(t, skills, fixtureCatalogContent) + + _, err := GenerateCatalogWithSkillDescriptions([]byte(fixtureCatalogContent), repositoryRoot) + + if err == nil { + t.Fatal("a tool with no skill must stop generation") + } + if !strings.Contains(err.Error(), "simulate-keyboard has no skill") { + t.Errorf("the error must name the undocumented tool: %v", err) + } +} + +// Verifies check mode reports the committed catalog as stale without writing it, which is what CI +// needs: a red step and an untouched working tree. +func TestRunSyncToolDocsCheckModeReportsAStaleCatalog(t *testing.T) { + repositoryRoot := writeGeneratorFixture(t, defaultGeneratorSkills(), fixtureCatalogContent) + stdout := strings.Builder{} + stderr := strings.Builder{} + + code := RunSyncToolDocs(&stdout, &stderr, SyncToolDocsConfig{RepositoryRoot: repositoryRoot, CheckOnly: true}) + + if code == 0 { + t.Fatalf("a stale catalog must fail check mode: %s", stdout.String()) + } + if !strings.Contains(stderr.String(), "scripts/sync-tool-docs.sh") { + t.Errorf("check mode must name the command that fixes it: %s", stderr.String()) + } + content, err := os.ReadFile(filepath.Join(repositoryRoot, filepath.FromSlash(CatalogRelativePath))) + if err != nil { + t.Fatalf("failed to read the catalog back: %v", err) + } + if string(content) != fixtureCatalogContent { + t.Error("check mode must not write the catalog") + } +} + +// Verifies writing then checking leaves check mode green, the sequence a developer runs before +// committing. +func TestRunSyncToolDocsWritesThenPassesCheck(t *testing.T) { + repositoryRoot := writeGeneratorFixture(t, defaultGeneratorSkills(), fixtureCatalogContent) + stdout := strings.Builder{} + stderr := strings.Builder{} + + if code := RunSyncToolDocs(&stdout, &stderr, SyncToolDocsConfig{RepositoryRoot: repositoryRoot}); code != 0 { + t.Fatalf("write mode failed: %s", stderr.String()) + } + if code := RunSyncToolDocs(&stdout, &stderr, SyncToolDocsConfig{RepositoryRoot: repositoryRoot, CheckOnly: true}); code != 0 { + t.Fatalf("check mode failed right after writing: %s", stderr.String()) + } +} diff --git a/scripts/sync-tool-docs.sh b/scripts/sync-tool-docs.sh new file mode 100755 index 0000000000..0a799ad104 --- /dev/null +++ b/scripts/sync-tool-docs.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# Regenerate cli/common/tools/default-tools.json descriptions from the package's SKILL.md parameter +# tables. Pass --check to verify instead of write, which is what CI runs. +set -eu + +ROOT_DIR=$(CDPATH= cd "$(dirname "$0")/.." && pwd) + +cd "$ROOT_DIR/cli/release-automation" +exec go run ./cmd/sync-tool-docs --repository-root "$ROOT_DIR" "$@" From 87113b5a1d5e9a47c14268ce5ca9c2b7955a9c50 Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 27 Jul 2026 08:55:04 +0900 Subject: [PATCH 07/16] Make compile's --force-recompile help text stand on its own The table cell ended in "see \"When to use --force-recompile\" below", which works in the skill file but not in `compile --help`, where there is no "below" to look at - a caller was pointed at a section they cannot reach. Now that help renders from this cell, the cell has to be self-contained. It now states the reason not to use the flag directly: a plain compile already detects externally edited files, and the forced reload can freeze large projects and return COMPILE_RESULT_UNKNOWN. The skill's detailed section stays for readers of the skill itself. --- .agents/skills/uloop-compile/SKILL.md | 2 +- .claude/skills/uloop-compile/SKILL.md | 2 +- Packages/src/Editor/FirstPartyTools/Compile/Skill/SKILL.md | 2 +- cli/common/tools/default-tools.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/skills/uloop-compile/SKILL.md b/.agents/skills/uloop-compile/SKILL.md index 108abc5a84..3febf89300 100644 --- a/.agents/skills/uloop-compile/SKILL.md +++ b/.agents/skills/uloop-compile/SKILL.md @@ -18,7 +18,7 @@ uloop compile [--force-recompile] [--no-wait-for-domain-reload] [--stop-on-exter | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--force-recompile` | flag | - | Full recompile plus domain reload. Rarely needed — see "When to use --force-recompile" below | +| `--force-recompile` | flag | - | Full recompile plus domain reload. Almost never needed: a plain compile already detects externally edited files, and the forced reload can freeze large projects and come back as `COMPILE_RESULT_UNKNOWN`. | | `--no-wait-for-domain-reload` | flag | - | Return before Domain Reload completion | | `--stop-on-external-scene-changes` | flag | - | Stop before compilation if open Scene files changed externally instead of auto-reloading them | diff --git a/.claude/skills/uloop-compile/SKILL.md b/.claude/skills/uloop-compile/SKILL.md index 108abc5a84..3febf89300 100644 --- a/.claude/skills/uloop-compile/SKILL.md +++ b/.claude/skills/uloop-compile/SKILL.md @@ -18,7 +18,7 @@ uloop compile [--force-recompile] [--no-wait-for-domain-reload] [--stop-on-exter | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--force-recompile` | flag | - | Full recompile plus domain reload. Rarely needed — see "When to use --force-recompile" below | +| `--force-recompile` | flag | - | Full recompile plus domain reload. Almost never needed: a plain compile already detects externally edited files, and the forced reload can freeze large projects and come back as `COMPILE_RESULT_UNKNOWN`. | | `--no-wait-for-domain-reload` | flag | - | Return before Domain Reload completion | | `--stop-on-external-scene-changes` | flag | - | Stop before compilation if open Scene files changed externally instead of auto-reloading them | diff --git a/Packages/src/Editor/FirstPartyTools/Compile/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/Compile/Skill/SKILL.md index 108abc5a84..3febf89300 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/Compile/Skill/SKILL.md @@ -18,7 +18,7 @@ uloop compile [--force-recompile] [--no-wait-for-domain-reload] [--stop-on-exter | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--force-recompile` | flag | - | Full recompile plus domain reload. Rarely needed — see "When to use --force-recompile" below | +| `--force-recompile` | flag | - | Full recompile plus domain reload. Almost never needed: a plain compile already detects externally edited files, and the forced reload can freeze large projects and come back as `COMPILE_RESULT_UNKNOWN`. | | `--no-wait-for-domain-reload` | flag | - | Return before Domain Reload completion | | `--stop-on-external-scene-changes` | flag | - | Stop before compilation if open Scene files changed externally instead of auto-reloading them | diff --git a/cli/common/tools/default-tools.json b/cli/common/tools/default-tools.json index 1d59512039..d0fceac137 100644 --- a/cli/common/tools/default-tools.json +++ b/cli/common/tools/default-tools.json @@ -8,7 +8,7 @@ "properties": { "ForceRecompile": { "type": "boolean", - "description": "Full recompile plus domain reload. Rarely needed — see \"When to use --force-recompile\" below" + "description": "Full recompile plus domain reload. Almost never needed: a plain compile already detects externally edited files, and the forced reload can freeze large projects and come back as COMPILE_RESULT_UNKNOWN." }, "WaitForDomainReload": { "type": "boolean", From f723e2803d5b5b401f74db3af5a03ab2dce238fe Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 27 Jul 2026 09:07:42 +0900 Subject: [PATCH 08/16] Fail the tests when a tool parameter has no skill table row Help, `uloop list` and the embedded catalog all render parameter descriptions from the skill tables now, so a parameter with no row is a parameter no agent can discover - and nothing prevented adding one. The generator catches the opposite direction (a row matching no option), but it reads the catalog, not the schema types, so only a C# test can see a property that was just added. The guard walks every live tool in the shipped catalog and requires a kebab-matching row for each visible property. Hidden properties are skipped by reading the catalog's own `hidden` flag rather than a second list here, so this test and the generator cannot disagree about which options reach the command line. The second test requires a non-empty tool description, which is the one-line summary `uloop list` prints. The kebab rule is duplicated from cli/common/tooldocs on purpose: it has to run on both sides of the language boundary, and a divergence is caught from either direction. Table parsing here is smaller than the Go parser - it locates the table and reads its first column, never a description - so the duplicated understanding of the file format is limited to what a missing row depends on. The registry's test double moves out of DefaultToolsCatalogDriftTests so both guards share it. --- .../Editor/AlwaysEnabledToolSettingsPort.cs | 32 ++ .../AlwaysEnabledToolSettingsPort.cs.meta | 11 + .../Editor/DefaultToolsCatalogDriftTests.cs | 24 - .../SkillParameterTableCoverageTests.cs | 481 ++++++++++++++++++ .../SkillParameterTableCoverageTests.cs.meta | 11 + 5 files changed, 535 insertions(+), 24 deletions(-) create mode 100644 Assets/Tests/Editor/AlwaysEnabledToolSettingsPort.cs create mode 100644 Assets/Tests/Editor/AlwaysEnabledToolSettingsPort.cs.meta create mode 100644 Assets/Tests/Editor/SkillParameterTableCoverageTests.cs create mode 100644 Assets/Tests/Editor/SkillParameterTableCoverageTests.cs.meta diff --git a/Assets/Tests/Editor/AlwaysEnabledToolSettingsPort.cs b/Assets/Tests/Editor/AlwaysEnabledToolSettingsPort.cs new file mode 100644 index 0000000000..55c181fbb4 --- /dev/null +++ b/Assets/Tests/Editor/AlwaysEnabledToolSettingsPort.cs @@ -0,0 +1,32 @@ +using System; + +using io.github.hatayama.UnityCliLoop.Domain; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Test-only settings port that exposes every discovered tool. + /// Guard tests compare the whole catalog, so local tool settings must not hide disabled tools and + /// turn a developer's preferences into a failure. + /// + internal sealed class AlwaysEnabledToolSettingsPort : IToolSettingsPort + { + public bool IsToolEnabled(string toolName) + { + return true; + } + + public void SetToolEnabled(string toolName, bool enabled) + { + } + + public string[] GetDisabledTools() + { + return Array.Empty(); + } + + public void InvalidateCache() + { + } + } +} diff --git a/Assets/Tests/Editor/AlwaysEnabledToolSettingsPort.cs.meta b/Assets/Tests/Editor/AlwaysEnabledToolSettingsPort.cs.meta new file mode 100644 index 0000000000..faef3bc00a --- /dev/null +++ b/Assets/Tests/Editor/AlwaysEnabledToolSettingsPort.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1337ab45dda9045f5a60a36cfbf8ca98 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/DefaultToolsCatalogDriftTests.cs b/Assets/Tests/Editor/DefaultToolsCatalogDriftTests.cs index fd6d86afba..5598f3e849 100644 --- a/Assets/Tests/Editor/DefaultToolsCatalogDriftTests.cs +++ b/Assets/Tests/Editor/DefaultToolsCatalogDriftTests.cs @@ -231,29 +231,5 @@ private static JObject RemoveEmbeddedOnlyEnums(JObject embeddedSchema, JObject l return comparableSchema; } - - /// - /// Test-only settings port that exposes every discovered tool. - /// - private sealed class AlwaysEnabledToolSettingsPort : IToolSettingsPort - { - public bool IsToolEnabled(string toolName) - { - return true; - } - - public void SetToolEnabled(string toolName, bool enabled) - { - } - - public string[] GetDisabledTools() - { - return Array.Empty(); - } - - public void InvalidateCache() - { - } - } } } diff --git a/Assets/Tests/Editor/SkillParameterTableCoverageTests.cs b/Assets/Tests/Editor/SkillParameterTableCoverageTests.cs new file mode 100644 index 0000000000..420e6e7922 --- /dev/null +++ b/Assets/Tests/Editor/SkillParameterTableCoverageTests.cs @@ -0,0 +1,481 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.CompositionRoot; +using io.github.hatayama.UnityCliLoop.Domain; +using io.github.hatayama.UnityCliLoop.ToolContracts; +using ToolParameterInfo = io.github.hatayama.UnityCliLoop.ToolContracts.ParameterInfo; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Test fixture that verifies the skill parameter tables document every parameter the tools + /// actually accept. + /// Why this lives in C#: `--help`, `uloop list` and the embedded catalog all render from those + /// tables now, so a parameter with no table row is a parameter no agent can discover - and the set + /// of accepted parameters is decided by the C# schema types, which no Go test can see. The reverse + /// direction (a row for an option no tool accepts) is caught by the catalog generator instead. + /// + public sealed class SkillParameterTableCoverageTests + { + private const string DefaultToolsPath = "cli/common/tools/default-tools.json"; + private const string PackageEditorPath = "Packages/src/Editor"; + private const string SkillFileName = "SKILL.md"; + private const string SkillDirectoryName = "Skill"; + private const string SkillNamePrefix = "uloop-"; + private const string ParametersSectionHeading = "## Parameters"; + private const string SectionHeadingPrefix = "## "; + private const string SubsectionHeadingPrefix = "### "; + private const string FrontmatterFence = "---"; + + // Why: the package keeps first-party tool skills and CLI-only command skills in two containers, + // and the tilde suffix hides the second one from Unity's asset database (so those files need no + // .meta). Both are read here because Unity-side tools are documented in both: the pause-point + // commands live under CliOnlyTools~ even though Unity accepts their parameters. + private static readonly string[] SkillContainerDirectories = { "FirstPartyTools", "CliOnlyTools~" }; + + private static readonly string[] StandardParameterTableCells = { "Parameter", "Type", "Default", "Description" }; + + /// + /// Verifies every parameter a live tool accepts has a matching row in its skill's parameter + /// table, so adding a schema property without documenting it fails here instead of silently + /// producing an option that help cannot describe. + /// + [Test] + public void EverySchemaParameter_HasASkillTableRow() + { + Dictionary skills = ReadSkillDocumentation(); + HashSet hiddenProperties = ReadHiddenPropertyKeys(); + HashSet firstPartyToolNames = new(ReadCatalogToolNames(), StringComparer.Ordinal); + List problems = new(); + + foreach (ToolInfo tool in ReadLiveTools()) + { + // Why the catalog defines the scope: this development project also registers the custom + // command samples and test fixtures under Assets/, which are project-local tools with no + // skill by design. The catalog is exactly the set of commands the CLI ships. + if (!firstPartyToolNames.Contains(tool.Name)) + { + continue; + } + + if (!skills.TryGetValue(tool.Name, out SkillDocumentation documentation)) + { + problems.Add(tool.Name + ": no skill documents this tool"); + continue; + } + + foreach (KeyValuePair property in + tool.ParameterSchema.Properties.OrderBy(property => property.Key, StringComparer.Ordinal)) + { + if (hiddenProperties.Contains(HiddenPropertyKey(tool.Name, property.Key))) + { + continue; + } + + string optionName = OptionNameForProperty(tool.Name, property.Key, property.Value); + if (documentation.DocumentsOption(optionName)) + { + continue; + } + + problems.Add(tool.Name + " --" + optionName + ": no row in " + documentation.SkillRelativePath); + } + } + + Assert.That(problems, Is.Empty, string.Join("\n", problems)); + } + + /// + /// Verifies every command in the embedded catalog has a non-empty tool description in a skill, + /// which is what `uloop list` and `--help` print as the command's one-line summary. + /// + [Test] + public void EveryCatalogTool_HasASkillDescription() + { + Dictionary skills = ReadSkillDocumentation(); + List problems = new(); + + foreach (string toolName in ReadCatalogToolNames()) + { + if (!skills.TryGetValue(toolName, out SkillDocumentation documentation)) + { + problems.Add(toolName + ": no skill documents this tool"); + continue; + } + + if (string.IsNullOrEmpty(documentation.Description)) + { + problems.Add(toolName + ": skill " + documentation.SkillRelativePath + " has an empty description"); + } + } + + Assert.That(problems, Is.Empty, string.Join("\n", problems)); + } + + private static ToolInfo[] ReadLiveTools() + { + UnityCliLoopToolRegistry registry = new UnityCliLoopToolRegistry( + new AlwaysEnabledToolSettingsPort(), + internalToolNameProvider: null, + toolDiscovery: UnityCliLoopToolDiscovery.DiscoverTools); + + return registry.GetRegisteredTools() + .OrderBy(tool => tool.Name, StringComparer.Ordinal) + .ToArray(); + } + + private static string[] ReadCatalogToolNames() + { + JArray tools = ReadCatalog()["tools"] as JArray ?? new JArray(); + return tools + .OfType() + .Select(tool => tool["name"]?.ToString() ?? "") + .Where(name => name.Length > 0) + .OrderBy(name => name, StringComparer.Ordinal) + .ToArray(); + } + + // Why the hidden set is read from the catalog rather than from a list in this file: whether an + // option reaches the command line is a CLI-side decision recorded by the catalog's "hidden" + // flag, and the catalog generator skips exactly those properties. Reading the same flag keeps + // one source for it; a second list here could disagree with the generator. + private static HashSet ReadHiddenPropertyKeys() + { + HashSet hiddenKeys = new(StringComparer.Ordinal); + JArray tools = ReadCatalog()["tools"] as JArray ?? new JArray(); + foreach (JObject tool in tools.OfType()) + { + string toolName = tool["name"]?.ToString() ?? ""; + JObject properties = tool["inputSchema"]?["properties"] as JObject ?? new JObject(); + foreach (JProperty property in properties.Properties()) + { + if (property.Value["hidden"]?.Value() == true) + { + hiddenKeys.Add(HiddenPropertyKey(toolName, property.Name)); + } + } + } + return hiddenKeys; + } + + private static JObject ReadCatalog() + { + string projectRoot = UnityCliLoopPathResolver.GetProjectRoot(); + return JObject.Parse(File.ReadAllText(Path.Combine(projectRoot, DefaultToolsPath))); + } + + private static string HiddenPropertyKey(string toolName, string propertyName) + { + return toolName + "/" + propertyName; + } + + // Why this duplicates cli/common/tooldocs.OptionNameForProperty instead of sharing it: the rule + // has to run on both sides of the language boundary, and the two directions guard each other - + // a divergence here makes this test demand a row that does not exist, and a divergence there + // makes the catalog generator reject a row it cannot match. + private static string OptionNameForProperty(string toolName, string propertyName, ToolParameterInfo property) + { + string kebabName = PascalToKebab(propertyName); + if (!IsNegatedBooleanProperty(property)) + { + return kebabName; + } + + // These two flags read as an action rather than as the negation of a property name. + if (toolName == "run-tests" && propertyName == "SaveBeforeRun") + { + return "fail-on-unsaved-changes"; + } + if (toolName == "compile" && propertyName == "ReloadExternalSceneChanges") + { + return "stop-on-external-scene-changes"; + } + return "no-" + kebabName; + } + + private static bool IsNegatedBooleanProperty(ToolParameterInfo property) + { + return string.Equals(property.Type, "boolean", StringComparison.OrdinalIgnoreCase) && + property.DefaultValue is bool defaultValue && + defaultValue; + } + + private static string PascalToKebab(string value) + { + System.Text.StringBuilder builder = new(); + for (int index = 0; index < value.Length; index++) + { + if (index > 0 && value[index] >= 'A' && value[index] <= 'Z') + { + builder.Append('-'); + } + builder.Append(value[index]); + } + return builder.ToString().ToLowerInvariant(); + } + + private static Dictionary ReadSkillDocumentation() + { + Dictionary documentation = new(StringComparer.Ordinal); + foreach (string skillPath in EnumerateSkillFiles()) + { + string relativePath = Path.GetFileName(Path.GetDirectoryName(Path.GetDirectoryName(skillPath))) + + "/" + SkillDirectoryName + "/" + SkillFileName; + foreach (KeyValuePair entry in ParseSkill(File.ReadAllText(skillPath), relativePath)) + { + documentation[entry.Key] = entry.Value; + } + } + return documentation; + } + + private static string[] EnumerateSkillFiles() + { + string editorRoot = Path.Combine(UnityCliLoopPathResolver.GetProjectRoot(), PackageEditorPath); + return SkillContainerDirectories + .Select(container => Path.Combine(editorRoot, container)) + .Where(Directory.Exists) + .SelectMany(container => Directory.GetDirectories(container)) + .Select(toolDirectory => Path.Combine(toolDirectory, SkillDirectoryName, SkillFileName)) + .Where(File.Exists) + .OrderBy(path => path, StringComparer.Ordinal) + .ToArray(); + } + + // Why the parsing here is deliberately smaller than the Go parser in cli/common/skilldocs: this + // guard only asks whether a row for an option exists, so it never interprets a description's + // text. That keeps the duplicated understanding of the file format to the two things a missing + // row depends on - where the table is and what its first column says. + private static Dictionary ParseSkill(string content, string relativePath) + { + string[] lines = content + .Replace("\r\n", "\n") + .Replace("\r", "\n") + .Split('\n'); + string[] parametersSection = ReadParametersSection(lines); + if (parametersSection.Any(line => line.TrimStart().StartsWith(SubsectionHeadingPrefix, StringComparison.Ordinal))) + { + return ParseMultiToolSkill(parametersSection, relativePath); + } + return ParseSingleToolSkill(lines, relativePath); + } + + private static Dictionary ParseSingleToolSkill(string[] lines, string relativePath) + { + Dictionary frontmatter = ReadFrontmatter(lines); + string toolName = SingleSkillToolName(frontmatter); + Dictionary documentation = new(StringComparer.Ordinal); + if (toolName.Length == 0) + { + return documentation; + } + + documentation[toolName] = new SkillDocumentation( + frontmatter.TryGetValue("description", out string description) ? description : "", + ReadTableOptionNames(lines), + relativePath); + return documentation; + } + + private static Dictionary ParseMultiToolSkill(string[] sectionLines, string relativePath) + { + Dictionary documentation = new(StringComparer.Ordinal); + for (int index = 0; index < sectionLines.Length; index++) + { + string line = sectionLines[index].Trim(); + if (!line.StartsWith(SubsectionHeadingPrefix, StringComparison.Ordinal)) + { + continue; + } + + string toolName = line.Substring(SubsectionHeadingPrefix.Length).Trim(); + if (toolName.Length == 0) + { + continue; + } + + string[] blockLines = ReadSubsection(sectionLines, index); + documentation[toolName] = new SkillDocumentation( + FirstProseLine(blockLines), + ReadTableOptionNames(blockLines), + relativePath); + } + return documentation; + } + + private static string[] ReadParametersSection(string[] lines) + { + for (int index = 0; index < lines.Length; index++) + { + if (lines[index].Trim() != ParametersSectionHeading) + { + continue; + } + + for (int end = index + 1; end < lines.Length; end++) + { + if (lines[end].TrimStart().StartsWith(SectionHeadingPrefix, StringComparison.Ordinal)) + { + return lines.Skip(index + 1).Take(end - index - 1).ToArray(); + } + } + return lines.Skip(index + 1).ToArray(); + } + return Array.Empty(); + } + + private static string[] ReadSubsection(string[] sectionLines, int headingIndex) + { + for (int end = headingIndex + 1; end < sectionLines.Length; end++) + { + if (sectionLines[end].TrimStart().StartsWith(SubsectionHeadingPrefix, StringComparison.Ordinal)) + { + return sectionLines.Skip(headingIndex + 1).Take(end - headingIndex - 1).ToArray(); + } + } + return sectionLines.Skip(headingIndex + 1).ToArray(); + } + + private static string FirstProseLine(string[] lines) + { + foreach (string line in lines) + { + string trimmed = line.Trim(); + if (trimmed.Length == 0 || trimmed.StartsWith("|", StringComparison.Ordinal)) + { + continue; + } + return trimmed; + } + return ""; + } + + private static Dictionary ReadFrontmatter(string[] lines) + { + Dictionary frontmatter = new(StringComparer.Ordinal); + if (lines.Length == 0 || lines[0].Trim() != FrontmatterFence) + { + return frontmatter; + } + + for (int index = 1; index < lines.Length && lines[index].Trim() != FrontmatterFence; index++) + { + int separatorIndex = lines[index].IndexOf(':'); + if (separatorIndex <= 0) + { + continue; + } + + string key = lines[index].Substring(0, separatorIndex).Trim(); + string value = lines[index].Substring(separatorIndex + 1).Trim().Trim('"'); + frontmatter[key] = value; + } + return frontmatter; + } + + // Why the skill name is a fallback: toolName is authoritative when present, and every skill in + // this package is named "uloop-" for the tools that declare no toolName. + private static string SingleSkillToolName(Dictionary frontmatter) + { + if (frontmatter.TryGetValue("toolName", out string toolName) && toolName.Length > 0) + { + return toolName; + } + if (!frontmatter.TryGetValue("name", out string name) || !name.StartsWith(SkillNamePrefix, StringComparison.Ordinal)) + { + return ""; + } + return name.Substring(SkillNamePrefix.Length); + } + + // Only the first standard-header table counts, matching the Go parser: help renders that one + // table, so a row placed in a second table would be documentation this guard accepts and help + // never shows. + private static HashSet ReadTableOptionNames(string[] lines) + { + HashSet optionNames = new(StringComparer.Ordinal); + for (int index = 0; index < lines.Length; index++) + { + if (!IsStandardParameterTableHeader(lines[index])) + { + continue; + } + + for (int row = index + 1; row < lines.Length && lines[row].TrimStart().StartsWith("|", StringComparison.Ordinal); row++) + { + // The separator row's cells are dashes only, which leaves no option name behind. + string optionName = OptionNameFromCell(SplitTableRow(lines[row]).FirstOrDefault() ?? ""); + if (optionName.Length > 0) + { + optionNames.Add(optionName); + } + } + return optionNames; + } + return optionNames; + } + + private static bool IsStandardParameterTableHeader(string line) + { + string[] cells = SplitTableRow(line); + return cells.Length == StandardParameterTableCells.Length && + cells.SequenceEqual(StandardParameterTableCells, StringComparer.Ordinal); + } + + // The first column never contains an escaped pipe, so splitting the row structurally is enough + // to read it; only descriptions carry "\|" and this guard never looks at them. + private static string[] SplitTableRow(string line) + { + string trimmed = line.Trim(); + if (!trimmed.StartsWith("|", StringComparison.Ordinal)) + { + return Array.Empty(); + } + + return trimmed + .Trim('|') + .Split('|') + .Select(cell => cell.Trim()) + .ToArray(); + } + + private static string OptionNameFromCell(string cell) + { + string name = cell.Replace("`", "").Trim(); + string[] fields = name.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries); + if (fields.Length == 0) + { + return ""; + } + return fields[0].TrimStart('-'); + } + + /// + /// One tool's documentation as this guard needs it: the description shown in help and the set + /// of options the parameter table has a row for. + /// + private sealed class SkillDocumentation + { + public readonly string Description; + public readonly string SkillRelativePath; + private readonly HashSet optionNames; + + public SkillDocumentation(string description, HashSet optionNames, string skillRelativePath) + { + Description = description; + SkillRelativePath = skillRelativePath; + this.optionNames = optionNames; + } + + public bool DocumentsOption(string optionName) + { + return optionNames.Contains(optionName); + } + } + } +} diff --git a/Assets/Tests/Editor/SkillParameterTableCoverageTests.cs.meta b/Assets/Tests/Editor/SkillParameterTableCoverageTests.cs.meta new file mode 100644 index 0000000000..64c7e5aa02 --- /dev/null +++ b/Assets/Tests/Editor/SkillParameterTableCoverageTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b1596eda8b7ab43f399723f4aefb83ba +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 92bb14a4e1f285f185586ce93b64e402c588ab47 Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 27 Jul 2026 09:09:52 +0900 Subject: [PATCH 09/16] Fail CI when the tool catalog falls behind the skill tables Nothing checked that the committed catalog still matched the tables it is generated from, so a skill edit without a regeneration would ship help text that disagrees with the documentation an agent reads. The step runs the generator in check mode, which reports the mismatch and names the script that fixes it without writing anything. The repository root is passed explicitly because the step runs inside the module directory, where the catalog's relative path does not resolve. --- .github/workflows/build-and-test.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 13119feec3..6b93d778a8 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -95,6 +95,10 @@ jobs: working-directory: cli/release-automation run: go run ./cmd/check-release-triggers --base "origin/${{ github.base_ref }}" --head HEAD + - name: Check tool documentation drift + working-directory: cli/release-automation + run: go run ./cmd/sync-tool-docs --check --repository-root "$GITHUB_WORKSPACE" + - name: Install golangci-lint run: | go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.0 From 7fc5150bffc835d355a46912b81fbe7141cf6163 Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 27 Jul 2026 09:13:07 +0900 Subject: [PATCH 10/16] Regenerate the tool catalog from a pre-commit hook Forgetting to regenerate the catalog after editing a skill table is caught by CI, but only after a push, which costs a round trip. The hook regenerates it whenever a skill file is staged and adds the result to the same commit, so the mistake stops being possible to make locally. A table that disagrees with its schema fails the generator and blocks the commit, which is where that conflict is cheapest to fix. The hook was never wired up (core.hooksPath is unset in a fresh clone), and wiring it as it stood would have run the full Go check - lint, tests and a binary rebuild - on every commit touching Go. It now runs only gofmt and go vet, seconds rather than minutes; the full check stays where it was, in PR preparation and CI. The one-line opt-in and the catalog rule are documented for agents, who read that file every session. --- .husky/pre-commit | 27 ++++++++++++++++++++++++++- AGENTS.md | 16 ++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index 7f18eb4158..8682bb71a9 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,7 +1,32 @@ #!/usr/bin/env sh +# Fast layers only. The full check is scripts/check-go-cli.sh, which PR preparation and CI run; +# hooks stay in the seconds range so committing never waits on lint, tests, or a binary rebuild. changed_cli_go="$(git diff --cached --name-only --diff-filter=ACMR | grep -E '^cli/.*\.go$' || true)" if [ -n "$changed_cli_go" ]; then - scripts/check-go-cli.sh + unformatted="$(gofmt -l $changed_cli_go)" + if [ -n "$unformatted" ]; then + echo "gofmt reports unformatted files:" >&2 + echo "$unformatted" >&2 + exit 1 + fi + + for module in cli/common cli/dispatcher cli/project-runner cli/release-automation; do + case "$changed_cli_go" in + *"$module/"*) + (cd "$module" && go vet ./...) || exit 1 + ;; + esac + done +fi + +changed_skill_md="$(git diff --cached --name-only --diff-filter=ACMR \ + | grep -E '^Packages/src/Editor/(FirstPartyTools/[^/]+|CliOnlyTools~/(PausePoint|FocusWindow))/Skill/SKILL\.md$' || true)" + +if [ -n "$changed_skill_md" ]; then + # The catalog is generated from these tables, so regenerate it here instead of letting CI reject the + # push. A table that disagrees with its schema fails the generator, which blocks the commit. + scripts/sync-tool-docs.sh || exit 1 + git add cli/common/tools/default-tools.json fi diff --git a/AGENTS.md b/AGENTS.md index 7e36859340..805b981abd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,6 +64,22 @@ These files are generated copies. Update the source skill definitions instead, t - Sources: `Packages/src/Editor/FirstPartyTools//Skill/SKILL.md` and `Packages/src/Editor/CliOnlyTools~//Skill/SKILL.md` (plus each skill's `references/` files, which are copied along with it). - Regenerate: `dist/darwin-arm64/uloop skills install --claude --agents` from the project root, substituting the binary for your platform (e.g. `dist/windows-amd64/uloop.exe` on Windows). Only `.claude/` and `.agents/` are tracked in git; other targets are local-only. +## Generated Tool Catalog + +`cli/common/tools/default-tools.json` is generated from the skill parameter tables — it is what +`--help` and `uloop list` print when no project cache is available, and its descriptions must never +be hand-edited. When you change a parameter table or a tool description in +`Packages/src/Editor/FirstPartyTools//Skill/SKILL.md` or +`Packages/src/Editor/CliOnlyTools~//Skill/SKILL.md`, run `scripts/sync-tool-docs.sh` and +include the regenerated catalog in the same commit. `go run ./cmd/sync-tool-docs --check` in +`cli/release-automation` reports drift without writing, and CI runs it. + +Generation fails when a table and the schema disagree: a visible option with no row, or a row +matching no accepted option. Fix the table or the schema — do not work around the generator. + +Enable the repository hooks once per clone with `git config core.hooksPath .husky`; the pre-commit +hook then regenerates the catalog for you when a skill file is staged. + ## CI Automation Language Write GitHub Actions and release automation logic in Go when it needs JSON parsing, workflow polling, state transitions, or non-trivial branching. From 4bdd24fcfc5dbc033660361e88e8e5b42f7035b8 Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 27 Jul 2026 09:16:45 +0900 Subject: [PATCH 11/16] Ship a release when only a tool description changed The embedded catalog was classified with contract.json as a release-please stamp target, so a change to it counted as no release input at all. That was true while the descriptions were hand-edited rarely; now that the catalog is generated from the skill tables, a pull request that improves a tool's help text and nothing else would have passed the trigger guard and never reached a binary - the improved text would sit in the repository and never arrive on anyone's machine. The catalog now counts as a shared release input for both binaries, which is what it is: `//go:embed` compiles it into each one. contract.json stays excluded, since release-please writes it. The stamp script's file selection is meant to mirror the guard, so it moves in step, and a case there proves the catalog reaches the stamps. --- cli/dispatcher/shared-inputs-stamp.json | 2 +- cli/project-runner/shared-inputs-stamp.json | 2 +- .../automation/release_trigger_guard.go | 13 ++++++--- .../automation/release_trigger_guard_test.go | 29 ++++++++++++++++++- scripts/stamp-release-inputs.sh | 7 +++-- scripts/test-stamp-release-inputs.sh | 18 +++++++++++- 6 files changed, 61 insertions(+), 10 deletions(-) diff --git a/cli/dispatcher/shared-inputs-stamp.json b/cli/dispatcher/shared-inputs-stamp.json index ca43e4ecfc..d7905df2ab 100644 --- a/cli/dispatcher/shared-inputs-stamp.json +++ b/cli/dispatcher/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "0d024bff9e8e18ba7544d8db8def6026d2293f6e" + "sharedInputsHash": "1f44f61e2cada92cf5f4ecba945c1ec2400b0dc3" } diff --git a/cli/project-runner/shared-inputs-stamp.json b/cli/project-runner/shared-inputs-stamp.json index 2f53e37822..5ad8b3a9b0 100644 --- a/cli/project-runner/shared-inputs-stamp.json +++ b/cli/project-runner/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "ef302b69852a644c6715547b4487c090db0eac5f" + "sharedInputsHash": "679552d1e9b44928dc92f0b49ff7476f7f49d60d" } diff --git a/cli/release-automation/internal/automation/release_trigger_guard.go b/cli/release-automation/internal/automation/release_trigger_guard.go index 90ea7b0d6d..87dd7e3965 100644 --- a/cli/release-automation/internal/automation/release_trigger_guard.go +++ b/cli/release-automation/internal/automation/release_trigger_guard.go @@ -187,10 +187,15 @@ func isCommonGoSourceUnderPackageRoots(file string, packageRoots []string) bool if !strings.HasPrefix(file, "cli/common/") { return false } - // JSON files under common (contract.json, default-tools.json) are - // release-please stamp targets rather than binary inputs, and test files - // never ship, so only code and embedded runtime scripts count as release inputs. - if strings.HasSuffix(file, "_test.go") || (!strings.HasSuffix(file, ".go") && !strings.HasSuffix(file, ".ps1")) { + // JSON files under common (contract.json) are release-please stamp targets rather than binary + // inputs, and test files never ship, so only code and embedded runtime scripts count as release + // inputs. The embedded tool catalog is the exception: it is compiled into both binaries and is + // generated from the skill parameter tables, so a change to a tool description that shipped no new + // binary would be help text nobody receives. + if strings.HasSuffix(file, "_test.go") { + return false + } + if file != CatalogRelativePath && !strings.HasSuffix(file, ".go") && !strings.HasSuffix(file, ".ps1") { return false } for _, packageRoot := range packageRoots { diff --git a/cli/release-automation/internal/automation/release_trigger_guard_test.go b/cli/release-automation/internal/automation/release_trigger_guard_test.go index 31c8cab153..47514cb169 100644 --- a/cli/release-automation/internal/automation/release_trigger_guard_test.go +++ b/cli/release-automation/internal/automation/release_trigger_guard_test.go @@ -101,7 +101,34 @@ func TestReleaseTriggerGuardIgnoresNonBinaryCommonChanges(t *testing.T) { "cli/common/clicore/output_test.go", "cli/common/clitest/clitest.go", "cli/common/clicontract/contract.json", - "cli/common/tools/default-tools.json", + }) + + if len(result.Violations) != 0 { + t.Fatalf("expected no violations, got %v", result.Violations) + } +} + +// Verifies the embedded tool catalog counts as a shared release input, since it is compiled into both +// binaries and now changes whenever a skill parameter table does - a description-only change that +// shipped no new binary would be help text nobody receives. +func TestReleaseTriggerGuardCoversTheEmbeddedToolCatalog(t *testing.T) { + result := AnalyzeReleaseTriggerGuard([]string{CatalogRelativePath}) + + if len(result.Violations) != 1 { + t.Fatalf("expected one violation, got %v", result.Violations) + } + if len(result.Violations[0].MissingTriggerRoots) != 2 { + t.Fatalf("expected both release triggers to be required, got %v", result.Violations[0].MissingTriggerRoots) + } +} + +// Verifies the catalog passes once both release triggers are stamped, the sequence a skill edit and a +// regeneration go through together. +func TestReleaseTriggerGuardAcceptsTheEmbeddedToolCatalogWithBothTriggers(t *testing.T) { + result := AnalyzeReleaseTriggerGuard([]string{ + CatalogRelativePath, + "cli/dispatcher/shared-inputs-stamp.json", + "cli/project-runner/shared-inputs-stamp.json", }) if len(result.Violations) != 0 { diff --git a/scripts/stamp-release-inputs.sh b/scripts/stamp-release-inputs.sh index a627293d7c..6b9d7d6d63 100755 --- a/scripts/stamp-release-inputs.sh +++ b/scripts/stamp-release-inputs.sh @@ -12,7 +12,10 @@ cd "$ROOT_DIR" # Input selection mirrors the release trigger guard # (cli/release-automation/internal/automation/release_trigger_guard.go): # only package roots imported by shipped binaries count; release-please stamp -# targets such as contract.json and default-tools.json do not. +# targets such as contract.json do not. The embedded tool catalog is the one +# JSON that does count - it is compiled into both binaries and is generated +# from the skill parameter tables, so a tool description change has to reach a +# release. list_shared_common_inputs() { git ls-files -- \ cli/common/go.mod \ @@ -31,7 +34,7 @@ list_shared_common_inputs() { 'cli/common/unityipc/' \ 'cli/common/unityprocess/' \ 'cli/common/vibelog/' | - grep -E '\.go$|\.ps1$|/go\.mod$|/go\.sum$' | + grep -E '\.go$|\.ps1$|/go\.mod$|/go\.sum$|^cli/common/tools/default-tools\.json$' | grep -v '_test\.go$' || true } diff --git a/scripts/test-stamp-release-inputs.sh b/scripts/test-stamp-release-inputs.sh index 57dda3019d..036fa0286e 100755 --- a/scripts/test-stamp-release-inputs.sh +++ b/scripts/test-stamp-release-inputs.sh @@ -21,7 +21,7 @@ create_fixture_repo() { git config user.email "test@example.com" git config user.name "Test User" - mkdir -p cli/common/clicore/subpkg cli/common/clitest cli/common/version/subpkg cli/dispatcher/internal/install/scripts cli/dispatcher/internal/uninstall/scripts cli/project-runner scripts + mkdir -p cli/common/clicore/subpkg cli/common/clitest cli/common/tools cli/common/version/subpkg cli/dispatcher/internal/install/scripts cli/dispatcher/internal/uninstall/scripts cli/project-runner scripts printf 'package clicore\n' > cli/common/clicore/core.go printf 'package subpkg\n' > cli/common/clicore/subpkg/core.go printf 'package clicore\n\n// test-only content\n' > cli/common/clicore/core_test.go @@ -30,6 +30,7 @@ create_fixture_repo() { printf 'package subpkg\n' > cli/common/version/subpkg/compare.go printf 'module example.test/common\n' > cli/common/go.mod printf '{"projectRunnerVersion": "1.0.0"}\n' > cli/common/contract.json + printf '{"tools":[]}\n' > cli/common/tools/default-tools.json printf 'echo install\n' > scripts/install.sh printf 'Write-Host install\n' > scripts/install.ps1 printf 'echo embedded install\n' > cli/dispatcher/internal/install/scripts/install_darwin.sh @@ -130,6 +131,21 @@ if [ "$runner_hash_after_common" = "$runner_hash_initial" ] || exit 1 fi +# Verifies a change to the embedded tool catalog moves both stamps, since it is compiled into both +# binaries even though it is JSON. +commit_fixture_change "$work_dir" "common source change" +printf '{"tools":[{"name":"compile"}]}\n' > "$work_dir/cli/common/tools/default-tools.json" +run_stamp "$work_dir" +runner_hash_after_catalog=$(stamp_hash "$work_dir" cli/project-runner/shared-inputs-stamp.json) +dispatcher_hash_after_catalog=$(stamp_hash "$work_dir" cli/dispatcher/shared-inputs-stamp.json) +if [ "$runner_hash_after_catalog" = "$runner_hash_after_common" ] || + [ "$dispatcher_hash_after_catalog" = "$dispatcher_hash_after_common" ]; then + echo "Expected an embedded tool catalog change to move both stamps." >&2 + exit 1 +fi +runner_hash_after_common=$runner_hash_after_catalog +dispatcher_hash_after_common=$dispatcher_hash_after_catalog + # Verifies a nested shared common Go source change also moves both stamps. commit_fixture_change "$work_dir" "shared common change" printf 'package subpkg\n\nconst changed = true\n' > "$work_dir/cli/common/clicore/subpkg/core.go" From 0095dfc5ae25ddd06bb9a9a1ed9dfcb57ffaeea0 Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 27 Jul 2026 09:17:31 +0900 Subject: [PATCH 12/16] Record what a skill is now the source of for CLI output The glossary defined a skill as instructions for an agent. It is now also where the tool and parameter descriptions the CLI prints come from, read at render time and compiled into the embedded catalog. Naming that in the definition is what keeps "edit the description in the skill" discoverable from the vocabulary rather than only from the code. --- docs/glossary.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/glossary.md b/docs/glossary.md index e66b9264a8..3a6e1be971 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -91,6 +91,12 @@ A generated instruction document that teaches an AI agent how to use a `uloop` c Skill sources live in the package; the copies under `.agents/` and `.claude/` are generated and must not be edited directly. +A skill is also the single source of truth for the tool and parameter descriptions the CLI +prints. `--help` and `uloop list` read the parameter table out of the installed package's +skill at render time, and the embedded catalog (`cli/common/tools/default-tools.json`) is +generated from those same tables. Descriptions are therefore edited in the skill and nowhere +else. + ### Skill target A destination agent environment into which skills are installed (for example Claude Code, From b9e5050f5d6182bd0677b4f80a185cbece7fc862 Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 27 Jul 2026 09:43:20 +0900 Subject: [PATCH 13/16] Restore the detail the skill tables were missing next to the old catalog Generating descriptions from the tables made the tables authoritative, and fifteen cells were thinner than the catalog text they replaced - so help and `uloop list` lost information that had been there. What went missing was the part a caller cannot guess. Every enum cell had been reduced to a bare list of member names: `Press`, `KeyDown`, `KeyUp`, `ReleaseAll` says nothing about what any of them do, and in particular nothing about `ReleaseAll` being the way out of inconsistent key state after a pause point interrupts PlayMode. The same happened to simulate-mouse-input, simulate-mouse-ui, replay-input, record-input and control-play-mode, where `Step`, `Status` and `Resume` had no explanation at all. Elsewhere it was the fact behind the number or the flag: which direction a positive `--scroll-y` scrolls, that drag origins are top-left, that rendering screenshots return the formula for converting raw pixels, that `--annotate-elements` also returns element metadata. Each cell now carries what its catalog counterpart carried, so the regenerated descriptions read the same as before this change for the reader of `--help` while staying the single place the text is edited. --- .../skills/uloop-control-play-mode/SKILL.md | 2 +- .agents/skills/uloop-record-input/SKILL.md | 6 ++-- .agents/skills/uloop-replay-input/SKILL.md | 4 +-- .agents/skills/uloop-screenshot/SKILL.md | 6 ++-- .../skills/uloop-simulate-keyboard/SKILL.md | 2 +- .../uloop-simulate-mouse-input/SKILL.md | 4 +-- .../skills/uloop-simulate-mouse-ui/SKILL.md | 6 ++-- .../skills/uloop-control-play-mode/SKILL.md | 2 +- .claude/skills/uloop-record-input/SKILL.md | 6 ++-- .claude/skills/uloop-replay-input/SKILL.md | 4 +-- .claude/skills/uloop-screenshot/SKILL.md | 6 ++-- .../skills/uloop-simulate-keyboard/SKILL.md | 2 +- .../uloop-simulate-mouse-input/SKILL.md | 4 +-- .../skills/uloop-simulate-mouse-ui/SKILL.md | 6 ++-- .../ControlPlayMode/Skill/SKILL.md | 2 +- .../RecordInput/Skill/SKILL.md | 6 ++-- .../ReplayInput/Skill/SKILL.md | 4 +-- .../FirstPartyTools/Screenshot/Skill/SKILL.md | 6 ++-- .../SimulateKeyboard/Skill/SKILL.md | 2 +- .../SimulateMouseInput/Skill/SKILL.md | 4 +-- .../SimulateMouseUi/Skill/SKILL.md | 6 ++-- cli/common/tools/default-tools.json | 30 +++++++++---------- 22 files changed, 60 insertions(+), 60 deletions(-) diff --git a/.agents/skills/uloop-control-play-mode/SKILL.md b/.agents/skills/uloop-control-play-mode/SKILL.md index aa8a79b1b7..102b90f0e2 100644 --- a/.agents/skills/uloop-control-play-mode/SKILL.md +++ b/.agents/skills/uloop-control-play-mode/SKILL.md @@ -18,7 +18,7 @@ uloop control-play-mode [options] | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | string | `Play` | Action to perform: `Play`, `Stop`, `Pause`, `Step`, `Status`, `Resume` (alias of `Play`) | +| `--action` | string | `Play` | `Play` - start Play Mode, `Stop` - stop Play Mode, `Pause` - pause Play Mode, `Step` - advance one frame while paused, `Status` - report current state without changing anything, `Resume` - alias of Play in every state, including starting Play Mode when stopped | | `--timeout-seconds` | integer | `180` | Maximum seconds to wait for the requested play mode state | ## Output diff --git a/.agents/skills/uloop-record-input/SKILL.md b/.agents/skills/uloop-record-input/SKILL.md index 301586a6a4..40859135fa 100644 --- a/.agents/skills/uloop-record-input/SKILL.md +++ b/.agents/skills/uloop-record-input/SKILL.md @@ -28,9 +28,9 @@ uloop record-input --action Stop --output-path scripts/my-play.json | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | enum | `Start` | `Start` - begin recording, `Stop` - stop and save | -| `--output-path` | string | auto | Save path. Auto-generates under `.uloop/outputs/InputRecordings/` | -| `--keys` | string | `""` | Comma-separated key filter. Empty = all common game keys | +| `--action` | enum | `Start` | `Start` - begin recording input, `Stop` - stop recording and save to file | +| `--output-path` | string | auto | Save path for the recording JSON. When empty, auto-generates under `.uloop/outputs/InputRecordings/` | +| `--keys` | string | `""` | Comma-separated key filter (for example `W,A,S,D,Space`). Empty records all common game keys | | `--delay-seconds` | integer | `3` | Countdown delay in seconds before recording starts (0-10). Gives time to switch focus to Game View. | | `--no-show-overlay` | flag | - | Hide the recording countdown and REC indicator overlay | diff --git a/.agents/skills/uloop-replay-input/SKILL.md b/.agents/skills/uloop-replay-input/SKILL.md index 051b96759c..1ae5450c37 100644 --- a/.agents/skills/uloop-replay-input/SKILL.md +++ b/.agents/skills/uloop-replay-input/SKILL.md @@ -31,8 +31,8 @@ uloop replay-input --action Stop | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | enum | `Start` | `Start`, `Stop`, `Status` | -| `--input-path` | string | auto | JSON path. Auto-detects latest in `.uloop/outputs/InputRecordings/` | +| `--action` | enum | `Start` | `Start` - begin replaying, `Stop` - stop mid-way, `Status` - check progress | +| `--input-path` | string | auto | Path to the recording JSON. When empty, auto-detects the latest recording in `.uloop/outputs/InputRecordings/` | | `--no-show-overlay` | flag | - | Hide replay progress overlay | | `--loop` | flag | - | Loop continuously | diff --git a/.agents/skills/uloop-screenshot/SKILL.md b/.agents/skills/uloop-screenshot/SKILL.md index 942de5c6cf..435880c53b 100644 --- a/.agents/skills/uloop-screenshot/SKILL.md +++ b/.agents/skills/uloop-screenshot/SKILL.md @@ -18,12 +18,12 @@ uloop screenshot [--window-name ] [--resolution-scale ] [--match-mo | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--window-name` | string | `Game` | Window name to capture. Ignored when `--capture-mode rendering`. When the Game tab is Device Simulator and the title is `Simulator`, default `Game` falls back to `Simulator`. | +| `--window-name` | string | `Game` | Window name to capture (for example `Game`, `Scene`, `Console`, `Inspector`). Ignored when `--capture-mode rendering`. When the Game tab is Device Simulator and the title is Simulator, default Game falls back to Simulator. | | `--resolution-scale` | number | `1.0` | Resolution scale (0.1 to 1.0) | | `--match-mode` | enum | `exact` | Window name matching mode: `exact`, `prefix`, or `contains`. Ignored when `--capture-mode rendering`. | -| `--capture-mode` | enum | `window` | `window`=capture EditorWindow including toolbar, `rendering`=capture game rendering only (PlayMode required, coordinates match simulate-mouse) | +| `--capture-mode` | enum | `window` | `window` - capture EditorWindow including toolbar, `rendering` - capture game rendering only (PlayMode required). Rendering screenshots return `ScreenshotToInputFormula` for converting raw image pixels before calling simulate-mouse-input or raycast. | | `--output-directory` | string | `""` | Output directory path for saving screenshots. When empty, uses default path (.uloop/outputs/Screenshots/). Accepts absolute paths. | -| `--annotate-elements` | flag | - | Annotate interactive UI elements with index labels and interaction hints (A / CLICK, B / DRAG, ...). Only works with `--capture-mode rendering` in PlayMode. | +| `--annotate-elements` | flag | - | Annotate interactive UI elements with index labels and interaction hints (A / CLICK, B / DRAG, ...). The response includes an `AnnotatedElements` array with element metadata sorted by z-order. Only works with `--capture-mode rendering` in PlayMode. | | `--annotate-raycast-grid` | flag | - | Annotate clustered 3D physics collider candidates as `PhysicsCollider` entries in `AnnotatedElements`. Uses `Camera.main` visibility and the same top-left Game View coordinates as `simulate-mouse-input`. Only works with `--capture-mode rendering` in PlayMode. | | `--raycast-layer-mask` | string | `""` | Comma-separated physics layer names to narrow which layers `--annotate-raycast-grid` clusters. Hits are limited to layers also visible to `Camera.main.cullingMask`. When omitted, clusters against `Physics.DefaultRaycastLayers`. | | `--elements-only` | flag | - | Return only annotated element JSON without capturing a screenshot image. Requires `--annotate-elements` or `--annotate-raycast-grid`, and `--capture-mode rendering` in PlayMode. | diff --git a/.agents/skills/uloop-simulate-keyboard/SKILL.md b/.agents/skills/uloop-simulate-keyboard/SKILL.md index 7fc8612fb0..fd8b1887ab 100644 --- a/.agents/skills/uloop-simulate-keyboard/SKILL.md +++ b/.agents/skills/uloop-simulate-keyboard/SKILL.md @@ -27,7 +27,7 @@ uloop simulate-keyboard --action ReleaseAll | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | enum | `Press` | `Press`, `KeyDown`, `KeyUp`, `ReleaseAll` | +| `--action` | enum | `Press` | `Press` - one-shot key tap (Down then Up), `KeyDown` - hold key down, `KeyUp` - release held key, `ReleaseAll` - force-release every tracked and device-pressed key (allowed while PlayMode is paused; use after a pause-point interruption leaves key state inconsistent) | | `--key` | string | (required except `ReleaseAll`) | Key name matching Input System Key enum (e.g. `W`, `Space`, `LeftShift`, `A`, `Enter`). Case-insensitive. Digit keys use `Digit0`-`Digit9` or `Numpad0`-`Numpad9`, not bare `0`-`9`. Not used by `ReleaseAll`. | | `--duration` | number | `0` | Hold duration in seconds for Press action (0 = one-shot tap). Ignored by KeyDown/KeyUp/ReleaseAll. | diff --git a/.agents/skills/uloop-simulate-mouse-input/SKILL.md b/.agents/skills/uloop-simulate-mouse-input/SKILL.md index 605f7f3aaf..cc6a406c73 100644 --- a/.agents/skills/uloop-simulate-mouse-input/SKILL.md +++ b/.agents/skills/uloop-simulate-mouse-input/SKILL.md @@ -32,7 +32,7 @@ uloop simulate-mouse-input --action [options] | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | enum | `Click` | `Click`, `LongPress`, `MoveDelta`, `SmoothDelta`, `Scroll` | +| `--action` | enum | `Click` | `Click` - inject button press+release, `LongPress` - inject button hold for `--duration` seconds, `MoveDelta` - inject mouse delta (one-shot), `SmoothDelta` - inject mouse delta smoothly over `--duration` seconds, `Scroll` - inject scroll wheel | | `--x` | number | `0` | Target X position in Game View pixels (origin: top-left). Used by Click and LongPress. Use `AnnotatedElements[].SimX`, or raw image pixels converted with `ScreenshotToInputFormula`. | | `--y` | number | `0` | Target Y position in Game View pixels (origin: top-left). Used by Click and LongPress. Use `AnnotatedElements[].SimY`, or raw image pixels converted with `ScreenshotToInputFormula`. | | `--button` | enum | `Left` | Mouse button: `Left`, `Right`, `Middle`. Used by Click and LongPress. | @@ -40,7 +40,7 @@ uloop simulate-mouse-input --action [options] | `--delta-x` | number | `0` | Delta X in pixels for MoveDelta/SmoothDelta. Positive = right. | | `--delta-y` | number | `0` | Delta Y in pixels for MoveDelta/SmoothDelta. Positive = up. | | `--scroll-x` | number | `0` | Horizontal scroll delta for Scroll action. | -| `--scroll-y` | number | `0` | Vertical scroll delta for Scroll action. Typically 120 per notch. | +| `--scroll-y` | number | `0` | Vertical scroll delta for Scroll action. Positive = up, negative = down. Typically 120 per notch. | ### Actions diff --git a/.agents/skills/uloop-simulate-mouse-ui/SKILL.md b/.agents/skills/uloop-simulate-mouse-ui/SKILL.md index b317e85619..0a187f0c03 100644 --- a/.agents/skills/uloop-simulate-mouse-ui/SKILL.md +++ b/.agents/skills/uloop-simulate-mouse-ui/SKILL.md @@ -28,11 +28,11 @@ uloop simulate-mouse-ui --action --x --y [options] | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | enum | `Click` | `Click`, `Drag`, `DragStart`, `DragMove`, `DragEnd`, `LongPress` | +| `--action` | enum | `Click` | `Click` - click at position, `Drag` - one-shot drag, `DragStart` - begin drag and hold, `DragMove` - move while holding drag, `DragEnd` - release drag, `LongPress` - press and hold for `--duration` seconds | | `--x` | number | `0` | Target X position in screen pixels (origin: top-left). For Drag action, this is the destination. | | `--y` | number | `0` | Target Y position in screen pixels (origin: top-left). For Drag action, this is the destination. | -| `--from-x` | number | `0` | Start X position for Drag action. Drag starts here and moves to x,y. | -| `--from-y` | number | `0` | Start Y position for Drag action. Drag starts here and moves to x,y. | +| `--from-x` | number | `0` | Start X position for Drag action (origin: top-left). Drag starts here and moves to `--x`,`--y`. | +| `--from-y` | number | `0` | Start Y position for Drag action (origin: top-left). Drag starts here and moves to `--x`,`--y`. | | `--drag-speed` | number | `2000` | Drag speed in pixels per second (0 for instant). 2000 is fast (default), 200 is slow enough to watch. Applies to Drag, DragMove, and DragEnd actions. | | `--duration` | number | `0.5` | Hold duration in seconds for LongPress action. | | `--button` | enum | `Left` | Mouse button. `Click` and `LongPress` support `Left`, `Right`, and `Middle`. Drag actions support `Left` only; other buttons return an error. | diff --git a/.claude/skills/uloop-control-play-mode/SKILL.md b/.claude/skills/uloop-control-play-mode/SKILL.md index aa8a79b1b7..102b90f0e2 100644 --- a/.claude/skills/uloop-control-play-mode/SKILL.md +++ b/.claude/skills/uloop-control-play-mode/SKILL.md @@ -18,7 +18,7 @@ uloop control-play-mode [options] | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | string | `Play` | Action to perform: `Play`, `Stop`, `Pause`, `Step`, `Status`, `Resume` (alias of `Play`) | +| `--action` | string | `Play` | `Play` - start Play Mode, `Stop` - stop Play Mode, `Pause` - pause Play Mode, `Step` - advance one frame while paused, `Status` - report current state without changing anything, `Resume` - alias of Play in every state, including starting Play Mode when stopped | | `--timeout-seconds` | integer | `180` | Maximum seconds to wait for the requested play mode state | ## Output diff --git a/.claude/skills/uloop-record-input/SKILL.md b/.claude/skills/uloop-record-input/SKILL.md index 301586a6a4..40859135fa 100644 --- a/.claude/skills/uloop-record-input/SKILL.md +++ b/.claude/skills/uloop-record-input/SKILL.md @@ -28,9 +28,9 @@ uloop record-input --action Stop --output-path scripts/my-play.json | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | enum | `Start` | `Start` - begin recording, `Stop` - stop and save | -| `--output-path` | string | auto | Save path. Auto-generates under `.uloop/outputs/InputRecordings/` | -| `--keys` | string | `""` | Comma-separated key filter. Empty = all common game keys | +| `--action` | enum | `Start` | `Start` - begin recording input, `Stop` - stop recording and save to file | +| `--output-path` | string | auto | Save path for the recording JSON. When empty, auto-generates under `.uloop/outputs/InputRecordings/` | +| `--keys` | string | `""` | Comma-separated key filter (for example `W,A,S,D,Space`). Empty records all common game keys | | `--delay-seconds` | integer | `3` | Countdown delay in seconds before recording starts (0-10). Gives time to switch focus to Game View. | | `--no-show-overlay` | flag | - | Hide the recording countdown and REC indicator overlay | diff --git a/.claude/skills/uloop-replay-input/SKILL.md b/.claude/skills/uloop-replay-input/SKILL.md index 051b96759c..1ae5450c37 100644 --- a/.claude/skills/uloop-replay-input/SKILL.md +++ b/.claude/skills/uloop-replay-input/SKILL.md @@ -31,8 +31,8 @@ uloop replay-input --action Stop | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | enum | `Start` | `Start`, `Stop`, `Status` | -| `--input-path` | string | auto | JSON path. Auto-detects latest in `.uloop/outputs/InputRecordings/` | +| `--action` | enum | `Start` | `Start` - begin replaying, `Stop` - stop mid-way, `Status` - check progress | +| `--input-path` | string | auto | Path to the recording JSON. When empty, auto-detects the latest recording in `.uloop/outputs/InputRecordings/` | | `--no-show-overlay` | flag | - | Hide replay progress overlay | | `--loop` | flag | - | Loop continuously | diff --git a/.claude/skills/uloop-screenshot/SKILL.md b/.claude/skills/uloop-screenshot/SKILL.md index 942de5c6cf..435880c53b 100644 --- a/.claude/skills/uloop-screenshot/SKILL.md +++ b/.claude/skills/uloop-screenshot/SKILL.md @@ -18,12 +18,12 @@ uloop screenshot [--window-name ] [--resolution-scale ] [--match-mo | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--window-name` | string | `Game` | Window name to capture. Ignored when `--capture-mode rendering`. When the Game tab is Device Simulator and the title is `Simulator`, default `Game` falls back to `Simulator`. | +| `--window-name` | string | `Game` | Window name to capture (for example `Game`, `Scene`, `Console`, `Inspector`). Ignored when `--capture-mode rendering`. When the Game tab is Device Simulator and the title is Simulator, default Game falls back to Simulator. | | `--resolution-scale` | number | `1.0` | Resolution scale (0.1 to 1.0) | | `--match-mode` | enum | `exact` | Window name matching mode: `exact`, `prefix`, or `contains`. Ignored when `--capture-mode rendering`. | -| `--capture-mode` | enum | `window` | `window`=capture EditorWindow including toolbar, `rendering`=capture game rendering only (PlayMode required, coordinates match simulate-mouse) | +| `--capture-mode` | enum | `window` | `window` - capture EditorWindow including toolbar, `rendering` - capture game rendering only (PlayMode required). Rendering screenshots return `ScreenshotToInputFormula` for converting raw image pixels before calling simulate-mouse-input or raycast. | | `--output-directory` | string | `""` | Output directory path for saving screenshots. When empty, uses default path (.uloop/outputs/Screenshots/). Accepts absolute paths. | -| `--annotate-elements` | flag | - | Annotate interactive UI elements with index labels and interaction hints (A / CLICK, B / DRAG, ...). Only works with `--capture-mode rendering` in PlayMode. | +| `--annotate-elements` | flag | - | Annotate interactive UI elements with index labels and interaction hints (A / CLICK, B / DRAG, ...). The response includes an `AnnotatedElements` array with element metadata sorted by z-order. Only works with `--capture-mode rendering` in PlayMode. | | `--annotate-raycast-grid` | flag | - | Annotate clustered 3D physics collider candidates as `PhysicsCollider` entries in `AnnotatedElements`. Uses `Camera.main` visibility and the same top-left Game View coordinates as `simulate-mouse-input`. Only works with `--capture-mode rendering` in PlayMode. | | `--raycast-layer-mask` | string | `""` | Comma-separated physics layer names to narrow which layers `--annotate-raycast-grid` clusters. Hits are limited to layers also visible to `Camera.main.cullingMask`. When omitted, clusters against `Physics.DefaultRaycastLayers`. | | `--elements-only` | flag | - | Return only annotated element JSON without capturing a screenshot image. Requires `--annotate-elements` or `--annotate-raycast-grid`, and `--capture-mode rendering` in PlayMode. | diff --git a/.claude/skills/uloop-simulate-keyboard/SKILL.md b/.claude/skills/uloop-simulate-keyboard/SKILL.md index 7fc8612fb0..fd8b1887ab 100644 --- a/.claude/skills/uloop-simulate-keyboard/SKILL.md +++ b/.claude/skills/uloop-simulate-keyboard/SKILL.md @@ -27,7 +27,7 @@ uloop simulate-keyboard --action ReleaseAll | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | enum | `Press` | `Press`, `KeyDown`, `KeyUp`, `ReleaseAll` | +| `--action` | enum | `Press` | `Press` - one-shot key tap (Down then Up), `KeyDown` - hold key down, `KeyUp` - release held key, `ReleaseAll` - force-release every tracked and device-pressed key (allowed while PlayMode is paused; use after a pause-point interruption leaves key state inconsistent) | | `--key` | string | (required except `ReleaseAll`) | Key name matching Input System Key enum (e.g. `W`, `Space`, `LeftShift`, `A`, `Enter`). Case-insensitive. Digit keys use `Digit0`-`Digit9` or `Numpad0`-`Numpad9`, not bare `0`-`9`. Not used by `ReleaseAll`. | | `--duration` | number | `0` | Hold duration in seconds for Press action (0 = one-shot tap). Ignored by KeyDown/KeyUp/ReleaseAll. | diff --git a/.claude/skills/uloop-simulate-mouse-input/SKILL.md b/.claude/skills/uloop-simulate-mouse-input/SKILL.md index 605f7f3aaf..cc6a406c73 100644 --- a/.claude/skills/uloop-simulate-mouse-input/SKILL.md +++ b/.claude/skills/uloop-simulate-mouse-input/SKILL.md @@ -32,7 +32,7 @@ uloop simulate-mouse-input --action [options] | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | enum | `Click` | `Click`, `LongPress`, `MoveDelta`, `SmoothDelta`, `Scroll` | +| `--action` | enum | `Click` | `Click` - inject button press+release, `LongPress` - inject button hold for `--duration` seconds, `MoveDelta` - inject mouse delta (one-shot), `SmoothDelta` - inject mouse delta smoothly over `--duration` seconds, `Scroll` - inject scroll wheel | | `--x` | number | `0` | Target X position in Game View pixels (origin: top-left). Used by Click and LongPress. Use `AnnotatedElements[].SimX`, or raw image pixels converted with `ScreenshotToInputFormula`. | | `--y` | number | `0` | Target Y position in Game View pixels (origin: top-left). Used by Click and LongPress. Use `AnnotatedElements[].SimY`, or raw image pixels converted with `ScreenshotToInputFormula`. | | `--button` | enum | `Left` | Mouse button: `Left`, `Right`, `Middle`. Used by Click and LongPress. | @@ -40,7 +40,7 @@ uloop simulate-mouse-input --action [options] | `--delta-x` | number | `0` | Delta X in pixels for MoveDelta/SmoothDelta. Positive = right. | | `--delta-y` | number | `0` | Delta Y in pixels for MoveDelta/SmoothDelta. Positive = up. | | `--scroll-x` | number | `0` | Horizontal scroll delta for Scroll action. | -| `--scroll-y` | number | `0` | Vertical scroll delta for Scroll action. Typically 120 per notch. | +| `--scroll-y` | number | `0` | Vertical scroll delta for Scroll action. Positive = up, negative = down. Typically 120 per notch. | ### Actions diff --git a/.claude/skills/uloop-simulate-mouse-ui/SKILL.md b/.claude/skills/uloop-simulate-mouse-ui/SKILL.md index b317e85619..0a187f0c03 100644 --- a/.claude/skills/uloop-simulate-mouse-ui/SKILL.md +++ b/.claude/skills/uloop-simulate-mouse-ui/SKILL.md @@ -28,11 +28,11 @@ uloop simulate-mouse-ui --action --x --y [options] | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | enum | `Click` | `Click`, `Drag`, `DragStart`, `DragMove`, `DragEnd`, `LongPress` | +| `--action` | enum | `Click` | `Click` - click at position, `Drag` - one-shot drag, `DragStart` - begin drag and hold, `DragMove` - move while holding drag, `DragEnd` - release drag, `LongPress` - press and hold for `--duration` seconds | | `--x` | number | `0` | Target X position in screen pixels (origin: top-left). For Drag action, this is the destination. | | `--y` | number | `0` | Target Y position in screen pixels (origin: top-left). For Drag action, this is the destination. | -| `--from-x` | number | `0` | Start X position for Drag action. Drag starts here and moves to x,y. | -| `--from-y` | number | `0` | Start Y position for Drag action. Drag starts here and moves to x,y. | +| `--from-x` | number | `0` | Start X position for Drag action (origin: top-left). Drag starts here and moves to `--x`,`--y`. | +| `--from-y` | number | `0` | Start Y position for Drag action (origin: top-left). Drag starts here and moves to `--x`,`--y`. | | `--drag-speed` | number | `2000` | Drag speed in pixels per second (0 for instant). 2000 is fast (default), 200 is slow enough to watch. Applies to Drag, DragMove, and DragEnd actions. | | `--duration` | number | `0.5` | Hold duration in seconds for LongPress action. | | `--button` | enum | `Left` | Mouse button. `Click` and `LongPress` support `Left`, `Right`, and `Middle`. Drag actions support `Left` only; other buttons return an error. | diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/SKILL.md index aa8a79b1b7..102b90f0e2 100644 --- a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/Skill/SKILL.md @@ -18,7 +18,7 @@ uloop control-play-mode [options] | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | string | `Play` | Action to perform: `Play`, `Stop`, `Pause`, `Step`, `Status`, `Resume` (alias of `Play`) | +| `--action` | string | `Play` | `Play` - start Play Mode, `Stop` - stop Play Mode, `Pause` - pause Play Mode, `Step` - advance one frame while paused, `Status` - report current state without changing anything, `Resume` - alias of Play in every state, including starting Play Mode when stopped | | `--timeout-seconds` | integer | `180` | Maximum seconds to wait for the requested play mode state | ## Output diff --git a/Packages/src/Editor/FirstPartyTools/RecordInput/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/RecordInput/Skill/SKILL.md index 301586a6a4..40859135fa 100644 --- a/Packages/src/Editor/FirstPartyTools/RecordInput/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/RecordInput/Skill/SKILL.md @@ -28,9 +28,9 @@ uloop record-input --action Stop --output-path scripts/my-play.json | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | enum | `Start` | `Start` - begin recording, `Stop` - stop and save | -| `--output-path` | string | auto | Save path. Auto-generates under `.uloop/outputs/InputRecordings/` | -| `--keys` | string | `""` | Comma-separated key filter. Empty = all common game keys | +| `--action` | enum | `Start` | `Start` - begin recording input, `Stop` - stop recording and save to file | +| `--output-path` | string | auto | Save path for the recording JSON. When empty, auto-generates under `.uloop/outputs/InputRecordings/` | +| `--keys` | string | `""` | Comma-separated key filter (for example `W,A,S,D,Space`). Empty records all common game keys | | `--delay-seconds` | integer | `3` | Countdown delay in seconds before recording starts (0-10). Gives time to switch focus to Game View. | | `--no-show-overlay` | flag | - | Hide the recording countdown and REC indicator overlay | diff --git a/Packages/src/Editor/FirstPartyTools/ReplayInput/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/ReplayInput/Skill/SKILL.md index 051b96759c..1ae5450c37 100644 --- a/Packages/src/Editor/FirstPartyTools/ReplayInput/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/ReplayInput/Skill/SKILL.md @@ -31,8 +31,8 @@ uloop replay-input --action Stop | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | enum | `Start` | `Start`, `Stop`, `Status` | -| `--input-path` | string | auto | JSON path. Auto-detects latest in `.uloop/outputs/InputRecordings/` | +| `--action` | enum | `Start` | `Start` - begin replaying, `Stop` - stop mid-way, `Status` - check progress | +| `--input-path` | string | auto | Path to the recording JSON. When empty, auto-detects the latest recording in `.uloop/outputs/InputRecordings/` | | `--no-show-overlay` | flag | - | Hide replay progress overlay | | `--loop` | flag | - | Loop continuously | diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/SKILL.md index 942de5c6cf..435880c53b 100644 --- a/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/SKILL.md @@ -18,12 +18,12 @@ uloop screenshot [--window-name ] [--resolution-scale ] [--match-mo | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--window-name` | string | `Game` | Window name to capture. Ignored when `--capture-mode rendering`. When the Game tab is Device Simulator and the title is `Simulator`, default `Game` falls back to `Simulator`. | +| `--window-name` | string | `Game` | Window name to capture (for example `Game`, `Scene`, `Console`, `Inspector`). Ignored when `--capture-mode rendering`. When the Game tab is Device Simulator and the title is Simulator, default Game falls back to Simulator. | | `--resolution-scale` | number | `1.0` | Resolution scale (0.1 to 1.0) | | `--match-mode` | enum | `exact` | Window name matching mode: `exact`, `prefix`, or `contains`. Ignored when `--capture-mode rendering`. | -| `--capture-mode` | enum | `window` | `window`=capture EditorWindow including toolbar, `rendering`=capture game rendering only (PlayMode required, coordinates match simulate-mouse) | +| `--capture-mode` | enum | `window` | `window` - capture EditorWindow including toolbar, `rendering` - capture game rendering only (PlayMode required). Rendering screenshots return `ScreenshotToInputFormula` for converting raw image pixels before calling simulate-mouse-input or raycast. | | `--output-directory` | string | `""` | Output directory path for saving screenshots. When empty, uses default path (.uloop/outputs/Screenshots/). Accepts absolute paths. | -| `--annotate-elements` | flag | - | Annotate interactive UI elements with index labels and interaction hints (A / CLICK, B / DRAG, ...). Only works with `--capture-mode rendering` in PlayMode. | +| `--annotate-elements` | flag | - | Annotate interactive UI elements with index labels and interaction hints (A / CLICK, B / DRAG, ...). The response includes an `AnnotatedElements` array with element metadata sorted by z-order. Only works with `--capture-mode rendering` in PlayMode. | | `--annotate-raycast-grid` | flag | - | Annotate clustered 3D physics collider candidates as `PhysicsCollider` entries in `AnnotatedElements`. Uses `Camera.main` visibility and the same top-left Game View coordinates as `simulate-mouse-input`. Only works with `--capture-mode rendering` in PlayMode. | | `--raycast-layer-mask` | string | `""` | Comma-separated physics layer names to narrow which layers `--annotate-raycast-grid` clusters. Hits are limited to layers also visible to `Camera.main.cullingMask`. When omitted, clusters against `Physics.DefaultRaycastLayers`. | | `--elements-only` | flag | - | Return only annotated element JSON without capturing a screenshot image. Requires `--annotate-elements` or `--annotate-raycast-grid`, and `--capture-mode rendering` in PlayMode. | diff --git a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md index 7fc8612fb0..fd8b1887ab 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SimulateKeyboard/Skill/SKILL.md @@ -27,7 +27,7 @@ uloop simulate-keyboard --action ReleaseAll | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | enum | `Press` | `Press`, `KeyDown`, `KeyUp`, `ReleaseAll` | +| `--action` | enum | `Press` | `Press` - one-shot key tap (Down then Up), `KeyDown` - hold key down, `KeyUp` - release held key, `ReleaseAll` - force-release every tracked and device-pressed key (allowed while PlayMode is paused; use after a pause-point interruption leaves key state inconsistent) | | `--key` | string | (required except `ReleaseAll`) | Key name matching Input System Key enum (e.g. `W`, `Space`, `LeftShift`, `A`, `Enter`). Case-insensitive. Digit keys use `Digit0`-`Digit9` or `Numpad0`-`Numpad9`, not bare `0`-`9`. Not used by `ReleaseAll`. | | `--duration` | number | `0` | Hold duration in seconds for Press action (0 = one-shot tap). Ignored by KeyDown/KeyUp/ReleaseAll. | diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md index 605f7f3aaf..cc6a406c73 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseInput/Skill/SKILL.md @@ -32,7 +32,7 @@ uloop simulate-mouse-input --action [options] | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | enum | `Click` | `Click`, `LongPress`, `MoveDelta`, `SmoothDelta`, `Scroll` | +| `--action` | enum | `Click` | `Click` - inject button press+release, `LongPress` - inject button hold for `--duration` seconds, `MoveDelta` - inject mouse delta (one-shot), `SmoothDelta` - inject mouse delta smoothly over `--duration` seconds, `Scroll` - inject scroll wheel | | `--x` | number | `0` | Target X position in Game View pixels (origin: top-left). Used by Click and LongPress. Use `AnnotatedElements[].SimX`, or raw image pixels converted with `ScreenshotToInputFormula`. | | `--y` | number | `0` | Target Y position in Game View pixels (origin: top-left). Used by Click and LongPress. Use `AnnotatedElements[].SimY`, or raw image pixels converted with `ScreenshotToInputFormula`. | | `--button` | enum | `Left` | Mouse button: `Left`, `Right`, `Middle`. Used by Click and LongPress. | @@ -40,7 +40,7 @@ uloop simulate-mouse-input --action [options] | `--delta-x` | number | `0` | Delta X in pixels for MoveDelta/SmoothDelta. Positive = right. | | `--delta-y` | number | `0` | Delta Y in pixels for MoveDelta/SmoothDelta. Positive = up. | | `--scroll-x` | number | `0` | Horizontal scroll delta for Scroll action. | -| `--scroll-y` | number | `0` | Vertical scroll delta for Scroll action. Typically 120 per notch. | +| `--scroll-y` | number | `0` | Vertical scroll delta for Scroll action. Positive = up, negative = down. Typically 120 per notch. | ### Actions diff --git a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/Skill/SKILL.md index b317e85619..0a187f0c03 100644 --- a/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SimulateMouseUi/Skill/SKILL.md @@ -28,11 +28,11 @@ uloop simulate-mouse-ui --action --x --y [options] | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `--action` | enum | `Click` | `Click`, `Drag`, `DragStart`, `DragMove`, `DragEnd`, `LongPress` | +| `--action` | enum | `Click` | `Click` - click at position, `Drag` - one-shot drag, `DragStart` - begin drag and hold, `DragMove` - move while holding drag, `DragEnd` - release drag, `LongPress` - press and hold for `--duration` seconds | | `--x` | number | `0` | Target X position in screen pixels (origin: top-left). For Drag action, this is the destination. | | `--y` | number | `0` | Target Y position in screen pixels (origin: top-left). For Drag action, this is the destination. | -| `--from-x` | number | `0` | Start X position for Drag action. Drag starts here and moves to x,y. | -| `--from-y` | number | `0` | Start Y position for Drag action. Drag starts here and moves to x,y. | +| `--from-x` | number | `0` | Start X position for Drag action (origin: top-left). Drag starts here and moves to `--x`,`--y`. | +| `--from-y` | number | `0` | Start Y position for Drag action (origin: top-left). Drag starts here and moves to `--x`,`--y`. | | `--drag-speed` | number | `2000` | Drag speed in pixels per second (0 for instant). 2000 is fast (default), 200 is slow enough to watch. Applies to Drag, DragMove, and DragEnd actions. | | `--duration` | number | `0.5` | Hold duration in seconds for LongPress action. | | `--button` | enum | `Left` | Mouse button. `Click` and `LongPress` support `Left`, `Right`, and `Middle`. Drag actions support `Left` only; other buttons return an error. | diff --git a/cli/common/tools/default-tools.json b/cli/common/tools/default-tools.json index d0fceac137..6f3eb5e7e9 100644 --- a/cli/common/tools/default-tools.json +++ b/cli/common/tools/default-tools.json @@ -232,7 +232,7 @@ "properties": { "WindowName": { "type": "string", - "description": "Window name to capture. Ignored when --capture-mode rendering. When the Game tab is Device Simulator and the title is Simulator, default Game falls back to Simulator.", + "description": "Window name to capture (for example Game, Scene, Console, Inspector). Ignored when --capture-mode rendering. When the Game tab is Device Simulator and the title is Simulator, default Game falls back to Simulator.", "default": "Game" }, "ResolutionScale": { @@ -257,7 +257,7 @@ }, "CaptureMode": { "type": "string", - "description": "window=capture EditorWindow including toolbar, rendering=capture game rendering only (PlayMode required, coordinates match simulate-mouse)", + "description": "window - capture EditorWindow including toolbar, rendering - capture game rendering only (PlayMode required). Rendering screenshots return ScreenshotToInputFormula for converting raw image pixels before calling simulate-mouse-input or raycast.", "enum": [ "window", "rendering" @@ -266,7 +266,7 @@ }, "AnnotateElements": { "type": "boolean", - "description": "Annotate interactive UI elements with index labels and interaction hints (A / CLICK, B / DRAG, ...). Only works with --capture-mode rendering in PlayMode.", + "description": "Annotate interactive UI elements with index labels and interaction hints (A / CLICK, B / DRAG, ...). The response includes an AnnotatedElements array with element metadata sorted by z-order. Only works with --capture-mode rendering in PlayMode.", "default": false }, "ElementsOnly": { @@ -326,7 +326,7 @@ "properties": { "Action": { "type": "string", - "description": "Action to perform: Play, Stop, Pause, Step, Status, Resume (alias of Play)", + "description": "Play - start Play Mode, Stop - stop Play Mode, Pause - pause Play Mode, Step - advance one frame while paused, Status - report current state without changing anything, Resume - alias of Play in every state, including starting Play Mode when stopped", "enum": [ "Play", "Stop", @@ -468,7 +468,7 @@ "properties": { "Action": { "type": "string", - "description": "Click, Drag, DragStart, DragMove, DragEnd, LongPress", + "description": "Click - click at position, Drag - one-shot drag, DragStart - begin drag and hold, DragMove - move while holding drag, DragEnd - release drag, LongPress - press and hold for --duration seconds", "enum": [ "Click", "Drag", @@ -491,12 +491,12 @@ }, "FromX": { "type": "number", - "description": "Start X position for Drag action. Drag starts here and moves to x,y.", + "description": "Start X position for Drag action (origin: top-left). Drag starts here and moves to --x,--y.", "default": 0 }, "FromY": { "type": "number", - "description": "Start Y position for Drag action. Drag starts here and moves to x,y.", + "description": "Start Y position for Drag action (origin: top-left). Drag starts here and moves to --x,--y.", "default": 0 }, "DragSpeed": { @@ -545,7 +545,7 @@ "properties": { "Action": { "type": "string", - "description": "Click, LongPress, MoveDelta, SmoothDelta, Scroll", + "description": "Click - inject button press+release, LongPress - inject button hold for --duration seconds, MoveDelta - inject mouse delta (one-shot), SmoothDelta - inject mouse delta smoothly over --duration seconds, Scroll - inject scroll wheel", "enum": [ "Click", "LongPress", @@ -597,7 +597,7 @@ }, "ScrollY": { "type": "number", - "description": "Vertical scroll delta for Scroll action. Typically 120 per notch.", + "description": "Vertical scroll delta for Scroll action. Positive = up, negative = down. Typically 120 per notch.", "default": 0 } } @@ -611,7 +611,7 @@ "properties": { "Action": { "type": "string", - "description": "Press, KeyDown, KeyUp, ReleaseAll", + "description": "Press - one-shot key tap (Down then Up), KeyDown - hold key down, KeyUp - release held key, ReleaseAll - force-release every tracked and device-pressed key (allowed while PlayMode is paused; use after a pause-point interruption leaves key state inconsistent)", "enum": [ "Press", "KeyDown", @@ -644,17 +644,17 @@ "Start", "Stop" ], - "description": "Start - begin recording, Stop - stop and save", + "description": "Start - begin recording input, Stop - stop recording and save to file", "default": "Start" }, "OutputPath": { "type": "string", - "description": "Save path. Auto-generates under .uloop/outputs/InputRecordings/", + "description": "Save path for the recording JSON. When empty, auto-generates under .uloop/outputs/InputRecordings/", "default": "" }, "Keys": { "type": "string", - "description": "Comma-separated key filter. Empty = all common game keys", + "description": "Comma-separated key filter (for example W,A,S,D,Space). Empty records all common game keys", "default": "" }, "DelaySeconds": { @@ -683,12 +683,12 @@ "Stop", "Status" ], - "description": "Start, Stop, Status", + "description": "Start - begin replaying, Stop - stop mid-way, Status - check progress", "default": "Start" }, "InputPath": { "type": "string", - "description": "JSON path. Auto-detects latest in .uloop/outputs/InputRecordings/", + "description": "Path to the recording JSON. When empty, auto-detects the latest recording in .uloop/outputs/InputRecordings/", "default": "" }, "ShowOverlay": { From f31a62856d076335f55b6ec3b96672ae17af1af9 Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 27 Jul 2026 09:43:29 +0900 Subject: [PATCH 14/16] Stop the pre-commit hook from committing a catalog it did not generate Two ways the hook could produce a commit nobody wrote: Regenerating overwrites the catalog, so an unstaged hand edit to it was swept into the commit as though the generator had produced it. The hook now refuses to run in that state and says what to do, because that edit is lost either way - the next run overwrites it - and losing it silently inside someone else's commit is the worse of the two. The generator reads the working tree, so staging a skill file with `git add -p` produced a catalog describing text the commit does not contain. A partially staged skill file now stops the commit rather than being resolved by guesswork about which version was meant. The skill-file pattern also no longer names the two CLI-only skill directories that happen to exist today; the reader it feeds scans all of them. --- .husky/pre-commit | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index 8682bb71a9..4c71c770c9 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -22,9 +22,28 @@ if [ -n "$changed_cli_go" ]; then fi changed_skill_md="$(git diff --cached --name-only --diff-filter=ACMR \ - | grep -E '^Packages/src/Editor/(FirstPartyTools/[^/]+|CliOnlyTools~/(PausePoint|FocusWindow))/Skill/SKILL\.md$' || true)" + | grep -E '^Packages/src/Editor/(FirstPartyTools|CliOnlyTools~)/[^/]+/Skill/SKILL\.md$' || true)" if [ -n "$changed_skill_md" ]; then + # The generator reads the working tree, so a partially staged skill file would produce a catalog + # describing text this commit does not contain. Stop instead of committing that disagreement. + for skill_file in $changed_skill_md; do + if [ -n "$(git diff --name-only -- "$skill_file")" ]; then + echo "$skill_file is only partially staged." >&2 + echo "The tool catalog is generated from the working tree, so stage the file fully or unstage it." >&2 + exit 1 + fi + done + + # Regenerating overwrites the catalog, so an unstaged edit to it would be swept into this commit as + # if it had been generated. Hand edits to it are discarded by the next run either way; say so now. + if [ -n "$(git diff --name-only -- cli/common/tools/default-tools.json)" ]; then + echo "cli/common/tools/default-tools.json has unstaged changes." >&2 + echo "It is generated from the skill parameter tables: stage a regenerated catalog, or discard the" >&2 + echo "changes with git restore, then commit again." >&2 + exit 1 + fi + # The catalog is generated from these tables, so regenerate it here instead of letting CI reject the # push. A table that disagrees with its schema fails the generator, which blocks the commit. scripts/sync-tool-docs.sh || exit 1 From 59bc5931e8326510e119401a1895b80bf62333bc Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 27 Jul 2026 09:43:42 +0900 Subject: [PATCH 15/16] Cover both paths that mark a description as skill-sourced Deleting either assignment - the one in the skill reader or the one that carries the flag through the embedded fallback - left every test green, so nothing was holding the behaviour they exist for: a negated boolean printing its documented wording instead of a synthesized "Disable ". One test covers each path. The reader's test documents a property the embedded catalog does not know, so only the skill can describe it; without the assignment the help output falls back to the synthesized summary. The fallback's test starts from Unity's placeholder and checks the description it picks up survives the same way. --- cli/common/tooldocs/tool_option_help_test.go | 25 +++++++ .../command_help_skill_docs_test.go | 67 +++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/cli/common/tooldocs/tool_option_help_test.go b/cli/common/tooldocs/tool_option_help_test.go index 12108b5acf..100971c742 100644 --- a/cli/common/tooldocs/tool_option_help_test.go +++ b/cli/common/tooldocs/tool_option_help_test.go @@ -51,6 +51,31 @@ func TestOptionSummaryIgnoresTheWordingOfTheDescription(t *testing.T) { } } +// Verifies a description filled in from the embedded catalog is treated as skill-sourced too. The +// catalog is generated from the same parameter tables, so a cache carrying Unity's placeholder must end +// up with the table's wording rather than a synthesized summary. +func TestOptionSummaryKeepsANegatedBooleanDescriptionFilledFromTheEmbeddedCatalog(t *testing.T) { + catalog := tools.ApplyEmbeddedDescriptionFallback(tools.ToolCatalog{Tools: []tools.ToolDefinition{{ + Name: "get-hierarchy", + ParameterSchema: tools.ToolInputSchema{Properties: map[string]tools.ToolProperty{ + "IncludeComponents": {Type: "boolean", Default: true, Description: "Parameter: IncludeComponents"}, + }}, + }}}) + + property := catalog.Tools[0].EffectiveInputSchema().Properties["IncludeComponents"] + if property.Description == "" || property.Description == "Parameter: IncludeComponents" { + t.Fatalf("the embedded catalog did not supply a description: %q", property.Description) + } + summary := OptionSummary("get-hierarchy", "IncludeComponents", property) + + if summary != property.Description { + t.Errorf("the filled-in description was not printed as written: %q", summary) + } + if summary == "Disable include components" { + t.Errorf("the synthesized summary replaced the embedded description: %q", summary) + } +} + // Verifies a plain (non-negated) option is unaffected by provenance, since its description already // reads correctly against its own flag name. func TestOptionSummaryKeepsPlainOptionDescriptions(t *testing.T) { diff --git a/cli/dispatcher/internal/dispatcher/command_help_skill_docs_test.go b/cli/dispatcher/internal/dispatcher/command_help_skill_docs_test.go index c013c910b3..b50a66fcb2 100644 --- a/cli/dispatcher/internal/dispatcher/command_help_skill_docs_test.go +++ b/cli/dispatcher/internal/dispatcher/command_help_skill_docs_test.go @@ -10,6 +10,8 @@ import ( const skillDocsFixtureDurationDescription = "Hold duration written only in the fixture skill." +const skillDocsFixtureIncludeMaterialsDescription = "Leave material information out of the dump." + // Verifies option and tool help text come from the installed package's SKILL.md table rather than // from the descriptions compiled into this binary, which is the drift this reader removes. func TestCommandHelpReadsDescriptionsFromTheInstalledSkill(t *testing.T) { @@ -79,6 +81,71 @@ func TestCommandHelpKeepsEmbeddedDescriptionsWithoutAnInstalledSkill(t *testing. } } +// Verifies a negated boolean's help text comes from the skill table verbatim rather than the +// synthesized "Disable ". The synthesis is correct only for a description with no skill behind +// it, so the reader has to mark the descriptions it supplied as skill-sourced. +func TestCommandHelpPrintsASkillSourcedNegatedBooleanVerbatim(t *testing.T) { + projectRoot := createLaunchTestProject(t) + writeNegatedBooleanSkillFixture(t, projectRoot) + writeToolCache(t, projectRoot, `{ + "tools": [ + { + "name": "get-hierarchy", + "inputSchema": { + "type": "object", + "properties": { + "IncludeMaterials": {"type": "boolean", "default": true, "description": "Parameter: IncludeMaterials"} + } + } + } + ] +}`) + var stdout bytes.Buffer + var stderr bytes.Buffer + + handled, code := tryHandleCommandHelp("get-hierarchy", projectRoot, projectRoot, &stdout, &stderr) + + if !handled || code != 0 { + t.Fatalf("get-hierarchy help was not handled: handled=%v code=%d stderr=%s", handled, code, stderr.String()) + } + output := stdout.String() + if !strings.Contains(output, skillDocsFixtureIncludeMaterialsDescription) { + t.Errorf("the skill's flag wording was not printed:\n%s", output) + } + if strings.Contains(output, "Disable include materials") { + t.Errorf("the synthesized summary replaced the skill's wording:\n%s", output) + } +} + +// writeNegatedBooleanSkillFixture installs a package whose get-hierarchy skill documents the +// --no-include-materials flag from the flag's point of view. The property is deliberately one the +// embedded catalog does not know, so only the skill can supply its description. +func writeNegatedBooleanSkillFixture(t *testing.T, projectRoot string) { + t.Helper() + + packageRoot := filepath.Join(projectRoot, "Packages", "src") + skillDirectory := filepath.Join(packageRoot, "Editor", "FirstPartyTools", "GetHierarchy", "Skill") + if err := os.MkdirAll(skillDirectory, 0o755); err != nil { + t.Fatalf("failed to create the skill directory: %v", err) + } + manifest := []byte(`{"name":"io.github.hatayama.uloopmcp"}`) + if err := os.WriteFile(filepath.Join(packageRoot, "package.json"), manifest, 0o644); err != nil { + t.Fatalf("failed to write the package manifest: %v", err) + } + + skill := "---\n" + + "name: uloop-get-hierarchy\n" + + "toolName: get-hierarchy\n" + + "description: \"Dump the scene hierarchy from the fixture skill.\"\n" + + "---\n\n" + + "| Parameter | Type | Default | Description |\n" + + "|-----------|------|---------|-------------|\n" + + "| `--no-include-materials` | flag | - | " + skillDocsFixtureIncludeMaterialsDescription + " |\n" + if err := os.WriteFile(filepath.Join(skillDirectory, "SKILL.md"), []byte(skill), 0o644); err != nil { + t.Fatalf("failed to write the fixture skill: %v", err) + } +} + // writeSkillDocsFixturePackage installs a uloop package inside the project whose simulate-keyboard // skill documents --duration with the given text. func writeSkillDocsFixturePackage(t *testing.T, projectRoot string, durationDescription string) { From 83d5909567ed1cd8c6097bac99b792dfc8931d0c Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 27 Jul 2026 09:43:43 +0900 Subject: [PATCH 16/16] Read a skill's frontmatter from the same normalized lines as its table The parser normalized line endings and a BOM once for the table and then handed the raw file to the frontmatter reader, so a file containing lone CRs would have been understood two different ways by one function. Git never writes that, but "the normalization happens in one place" is this feature's own rule, and the frontmatter path was outside it. The C# guard's table splitter gains a note on why it does not resolve the "\|" escape: it reads only the first column. Reading a description there needs the same handling the Go splitter has. --- Assets/Tests/Editor/SkillParameterTableCoverageTests.cs | 2 ++ cli/common/skilldocs/parse.go | 8 +++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Assets/Tests/Editor/SkillParameterTableCoverageTests.cs b/Assets/Tests/Editor/SkillParameterTableCoverageTests.cs index 420e6e7922..49ca4e5f8e 100644 --- a/Assets/Tests/Editor/SkillParameterTableCoverageTests.cs +++ b/Assets/Tests/Editor/SkillParameterTableCoverageTests.cs @@ -429,6 +429,8 @@ private static bool IsStandardParameterTableHeader(string line) // The first column never contains an escaped pipe, so splitting the row structurally is enough // to read it; only descriptions carry "\|" and this guard never looks at them. + // Why not handle the escape: nothing here reads a description. Extending this to read one needs + // the same escape handling cli/common/skilldocs.splitTableRow does, or cells will be truncated. private static string[] SplitTableRow(string line) { string trimmed = line.Trim(); diff --git a/cli/common/skilldocs/parse.go b/cli/common/skilldocs/parse.go index 32b00acd2a..c155caa2ba 100644 --- a/cli/common/skilldocs/parse.go +++ b/cli/common/skilldocs/parse.go @@ -35,7 +35,7 @@ func ParseSkill(content string) map[string]ToolDocs { if ok && hasSubsectionHeading(sectionLines) { return parseMultiToolSkill(sectionLines) } - return parseSingleToolSkill(content, lines) + return parseSingleToolSkill(lines) } // normalizedLines makes the parser indifferent to how the checkout wrote the file. A Windows @@ -47,8 +47,10 @@ func normalizedLines(content string) []string { return strings.Split(content, "\n") } -func parseSingleToolSkill(content string, lines []string) map[string]ToolDocs { - frontmatter := skillscan.ParseSkillFrontmatter(strings.TrimPrefix(content, byteOrderMark)) +// parseSingleToolSkill reads the frontmatter from the same normalized lines the table is read from, so +// line endings and a BOM are handled in exactly one place rather than once per consumer. +func parseSingleToolSkill(lines []string) map[string]ToolDocs { + frontmatter := skillscan.ParseSkillFrontmatter(strings.Join(lines, "\n")) toolName := singleSkillToolName(frontmatter) if toolName == "" { return map[string]ToolDocs{}