godot-cli-control 0.4.2__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/PKG-INFO +3 -3
  2. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/addons/godot_cli_control/CHANGELOG.md +10 -1
  3. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/addons/godot_cli_control/README.md +5 -1
  4. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/addons/godot_cli_control/bridge/error_codes.gd +87 -0
  5. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/addons/godot_cli_control/bridge/game_bridge.gd +7 -1
  6. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/addons/godot_cli_control/bridge/low_level_api.gd +86 -10
  7. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/addons/godot_cli_control/plugin.cfg +1 -1
  8. godot_cli_control-0.5.0/addons/godot_cli_control/tests/gut/test_error_codes.gd +38 -0
  9. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/addons/godot_cli_control/tests/gut/test_game_bridge.gd +17 -0
  10. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/addons/godot_cli_control/tests/gut/test_low_level_api.gd +66 -0
  11. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/python/README.md +2 -2
  12. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/python/godot_cli_control/_version.py +2 -2
  13. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/python/godot_cli_control/bridge.py +22 -3
  14. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/python/godot_cli_control/cli.py +294 -75
  15. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/python/godot_cli_control/client.py +39 -3
  16. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/python/godot_cli_control/init_cmd.py +4 -8
  17. godot_cli_control-0.5.0/python/godot_cli_control/skills_install.py +98 -0
  18. godot_cli_control-0.5.0/python/godot_cli_control/templates/skill/SKILL.md +159 -0
  19. godot_cli_control-0.5.0/python/godot_cli_control/templates/skill/references/commands.md +221 -0
  20. godot_cli_control-0.5.0/python/godot_cli_control/templates/skill/references/daemon-multi-instance.md +111 -0
  21. godot_cli_control-0.5.0/python/godot_cli_control/templates/skill/references/error-codes.md +47 -0
  22. godot_cli_control-0.5.0/python/godot_cli_control/templates/skill/references/pitfalls.md +78 -0
  23. godot_cli_control-0.5.0/python/godot_cli_control/templates/skill/references/python-and-pytest.md +121 -0
  24. godot_cli_control-0.5.0/python/godot_cli_control/templates/skill/references/recording.md +48 -0
  25. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/python/tests/test_bridge.py +37 -3
  26. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/python/tests/test_broadcast.py +6 -3
  27. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/python/tests/test_cli.py +234 -0
  28. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/python/tests/test_cli_helpers.py +4 -1
  29. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/python/tests/test_client.py +56 -0
  30. godot_cli_control-0.5.0/python/tests/test_error_hints.py +148 -0
  31. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/python/tests/test_init.py +4 -1
  32. godot_cli_control-0.5.0/python/tests/test_skills_install.py +250 -0
  33. godot_cli_control-0.4.2/python/godot_cli_control/skills_install.py +0 -51
  34. godot_cli_control-0.4.2/python/godot_cli_control/templates/skill/SKILL.md +0 -664
  35. godot_cli_control-0.4.2/python/tests/test_skills_install.py +0 -198
  36. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/.gitignore +0 -0
  37. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/LICENSE +0 -0
  38. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/addons/godot_cli_control/LICENSE +0 -0
  39. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/addons/godot_cli_control/bin/run_cli_control.ps1 +0 -0
  40. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/addons/godot_cli_control/bin/run_cli_control.sh +0 -0
  41. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/addons/godot_cli_control/bridge/capture_logger.gd +0 -0
  42. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/addons/godot_cli_control/bridge/diagnostics_api.gd +0 -0
  43. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/addons/godot_cli_control/bridge/game_bridge.gd.uid +0 -0
  44. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/addons/godot_cli_control/bridge/input_simulation_api.gd +0 -0
  45. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/addons/godot_cli_control/bridge/input_simulation_api.gd.uid +0 -0
  46. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/addons/godot_cli_control/bridge/low_level_api.gd.uid +0 -0
  47. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/addons/godot_cli_control/bridge/render_api.gd +0 -0
  48. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/addons/godot_cli_control/bridge/scene_api.gd +0 -0
  49. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/addons/godot_cli_control/bridge/time_api.gd +0 -0
  50. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/addons/godot_cli_control/bridge/variant_codec.gd +0 -0
  51. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/addons/godot_cli_control/bridge/wait_api.gd +0 -0
  52. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/addons/godot_cli_control/plugin.gd +0 -0
  53. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/addons/godot_cli_control/plugin.gd.uid +0 -0
  54. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/addons/godot_cli_control/tests/gut/test_diagnostics_api.gd +0 -0
  55. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/addons/godot_cli_control/tests/gut/test_input_simulation_api.gd +0 -0
  56. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/addons/godot_cli_control/tests/gut/test_render_api.gd +0 -0
  57. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/addons/godot_cli_control/tests/gut/test_scene_api.gd +0 -0
  58. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/addons/godot_cli_control/tests/gut/test_time_api.gd +0 -0
  59. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/addons/godot_cli_control/tests/gut/test_variant_codec.gd +0 -0
  60. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/addons/godot_cli_control/tests/gut/test_wait_api.gd +0 -0
  61. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/addons/godot_cli_control/tests/run_gut.py +0 -0
  62. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/addons/godot_cli_control/tests/run_gut.sh +0 -0
  63. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/addons/godot_cli_control/tests/test_init_walkthrough.py +0 -0
  64. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/addons/godot_cli_control/tests/test_walkthrough.sh +0 -0
  65. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/pyproject.toml +0 -0
  66. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/python/godot_cli_control/__init__.py +0 -0
  67. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/python/godot_cli_control/__main__.py +0 -0
  68. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/python/godot_cli_control/_duration.py +0 -0
  69. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/python/godot_cli_control/daemon.py +0 -0
  70. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/python/godot_cli_control/pytest_plugin.py +0 -0
  71. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/python/godot_cli_control/registry.py +0 -0
  72. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/python/godot_cli_control/runner.py +0 -0
  73. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/python/godot_cli_control/templates/__init__.py +0 -0
  74. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/python/godot_cli_control/templates/skill/__init__.py +0 -0
  75. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/python/tests/__init__.py +0 -0
  76. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/python/tests/conftest.py +0 -0
  77. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/python/tests/test_daemon.py +0 -0
  78. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/python/tests/test_duration.py +0 -0
  79. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/python/tests/test_e2e_client_direct.py +0 -0
  80. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/python/tests/test_e2e_diag.py +0 -0
  81. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/python/tests/test_e2e_example.py +0 -0
  82. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/python/tests/test_e2e_input.py +0 -0
  83. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/python/tests/test_e2e_multi_instance.py +0 -0
  84. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/python/tests/test_e2e_record.py +0 -0
  85. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/python/tests/test_e2e_render.py +0 -0
  86. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/python/tests/test_e2e_scene.py +0 -0
  87. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/python/tests/test_e2e_screenshot_gui.py +0 -0
  88. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/python/tests/test_e2e_time.py +0 -0
  89. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/python/tests/test_issue_fixes.py +0 -0
  90. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/python/tests/test_pytest_plugin.py +0 -0
  91. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/python/tests/test_registry.py +0 -0
  92. {godot_cli_control-0.4.2 → godot_cli_control-0.5.0}/python/tests/test_runner.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: godot-cli-control
3
- Version: 0.4.2
3
+ Version: 0.5.0
4
4
  Summary: WebSocket bridge for headless / scripted control of Godot scenes.
5
5
  Project-URL: Homepage, https://github.com/ClaymanTwinkle/godot-cli-control
6
6
  Project-URL: Repository, https://github.com/ClaymanTwinkle/godot-cli-control
@@ -62,9 +62,9 @@ godot-cli-control tree 3
62
62
  godot-cli-control daemon stop
63
63
  ```
64
64
 
65
- Re-running `init` refreshes both `addons/godot_cli_control/` and the SKILL.md files to match the installed CLI version (the plugin directory is wiped and re-copied; `project.godot` patching stays idempotent). Pass `--keep-addon` to keep an existing `addons/godot_cli_control/` untouched.
65
+ Re-running `init` refreshes both `addons/godot_cli_control/` and the skill files to match the installed CLI version (the plugin directory is wiped and re-copied; `project.godot` patching stays idempotent). Pass `--keep-addon` to keep an existing `addons/godot_cli_control/` untouched.
66
66
 
67
- `init` also writes `.claude/skills/godot-cli-control/SKILL.md` and `.codex/skills/godot-cli-control/SKILL.md` so AI agents working in your Godot project can pick up this CLI surface automatically. Use `--no-skills` to skip, or `--skills-only` to refresh just those files after a CLI upgrade. See the [top-level README](../README.md#agent-integration) for details.
67
+ `init` also writes `.claude/skills/godot-cli-control/` and `.codex/skills/godot-cli-control/` (a lean `SKILL.md` core + `references/*.md` detail files) so AI agents working in your Godot project can pick up this CLI surface automatically without flooding their context. Use `--no-skills` to skip, or `--skills-only` to refresh just those files after a CLI upgrade. See the [top-level README](../README.md#agent-integration) for details.
68
68
 
69
69
  ## Async API
70
70
 
@@ -6,7 +6,16 @@
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
- ## [0.4.2] - 2026-06-14
9
+ ## [0.5.0] - 2026-07-08
10
+
11
+ ### Added
12
+ - **`daemon restart`——改启动 flags 一步到位**:容忍未运行的 stop + 以本次给出的 flags 启动(与 `daemon start` 同一套参数,共享注册函数防漂移;**不记忆**上次 start 的 flags)。选靶走 stop 的自动语义(0 个在跑 → default;1 个 → 它;≥2 → 须 `--name`)。stop 硬失败(进程在但停不掉)中止不起新实例(exit 2);仅 ffmpeg 转码失败(AVI 保留)不阻断重启、最终 exit 4 透出(对齐 `run` 语义)。信封 `{"restarted", "was_running", "stop_rc", "instance", "port", "pid"}`。
13
+
14
+ ### Changed
15
+ - **顶层 `--help` 换成分组命令总览,从 130 行降到 ~87 行**:40+ 子命令原先平铺全量描述;现按 Daemon/读/写/输入/等待/场景时间/渲染诊断 7 组展示、每命令一句话摘要(取自各命令描述首句,自动生成)。旧的手维护清单曾静默漂移掉 `find`/`wait-prop`/`scene-*`/`drag` 等十余条——新版由 `RPC_SPECS` 生成并有测试锁「每个命令必须归入恰好一组」,不再漂移。各命令完整参数照旧 `<cmd> -h`。
16
+ - **`click` 支持 find 同款过滤器定位——「点击文案为 X 的按钮」一条命令原子完成**:`click --contains 开始 [--type BaseButton] [--exact/--name-pattern/--from]`(Python:`client.click(text_contains=...)` / `bridge.click(...)` 同参)。服务端同帧内 find+click,替代 `find` → 解析 `matches[0].path` → `click` 三步两次 RPC,并消灭两步之间的 stale-path 竞态。过滤器必须恰好命中 1 个节点:0 个报 `1001`;≥2 个报**新错误码 `1017 AMBIGUOUS_MATCH`**(message 列出候选路径,收窄过滤器再试——BFS 序不可预期,宁可 fail-loud 也不静默点第一个)。成功信封带 `path`(实际点到的节点)。path 与过滤器互斥(CLI preflight `-1003`/64,服务端 `-32602` 兜裸 RPC)。需要新 addon 行为——老项目跑一次 `init` 同步。
17
+ - **错误信封新增可选 `error.hint` 字段——错误发生点当场给「下一步怎么办」**:此前「拿到 1009 该先 `pause`」「拿到 1004 该 `combo-cancel`」这类指引只存在于 SKILL.md 错误码表里,没读过表的 agent 只能瞎重试。现服务端业务码(`1xxx` 全部 16 个 + `-32601`)由 addon 侧 `CliControlErrorCodes.hint_for` 随响应下发(GUT 锁「新增业务码必须登记 hint」),客户端码(`-1001`/`-1002`/`-1004`/`-1006`/`-1099`)由 CLI 在发射点补齐;服务端下发的 hint 优先于客户端映射。message 已足够具体的码(`-32602`/`-1003`/`-1005`)不带 hint,无 hint 的错误不带空字段。`--text` 模式在 stderr 尾部追加「(提示:...)」;`--instance all` 广播的 per-entry error 同样带 hint;`RpcError` 新增 `.hint` 属性供 Python 调用方使用。服务端 hint 需要新 addon——老项目跑一次 `init` 同步(未同步只是没有 hint,无兼容问题)。
18
+ - **SKILL.md 拆成多文件渐进披露结构,触发时上下文占用从 ~1850 行降到 ~160 行**:旧版单文件 SKILL.md 渲染后 1854 行 / ~142KB(其中 1195 行是 `{{cli_help}}` argparse 全量帮助注入),agent 每次触发 skill 都整体灌进上下文——违背本项目「不让大 payload 撑爆 agent 上下文」的 AI 友好契约。现拆为:核心 `SKILL.md`(quickstart / 退出码 / 单行命令目录 / 高频 pitfalls / 路由表,≤400 行有测试锁)+ `references/*.md` 六个主题文件(commands / error-codes / daemon-multi-instance / recording / python-and-pytest / pitfalls,agent 按需 Read)。`{{cli_help}}` 注入删除——agent 现场跑 `godot-cli-control <cmd> -h` 拿永远最新的帮助,顺带消灭「重渲染必须 COLUMNS=80 + Python 3.12」的 argparse 折行漂移坑(CI skill-render-drift 改整目录 diff,新增 `python -m godot_cli_control.skills_install` 一键重渲染入口)。`init` / `init --skills-only` 现在写整个 skill 目录;`--skills-no-clobber` 逐文件跳过已存在的(老单文件安装升级时缺失的 references/ 仍会补上)。旧项目跑一次 `godot-cli-control init --skills-only` 即升级。API 变更:`skills_install.render_skill(version)` 不再收 `cli_help` 参数,新增 `skills_install.skill_files(version)`。
10
19
 
11
20
  ### Fixed
12
21
  - **`call` 对错类型「标量」实参不再假成功(#167 的标量侧补集)**:#167 只关了 `call` 的 **JSON Array → 复合 Variant** 侧假成功;错类型的标量实参(`set_name 42` 数字→StringName、`set_z_index '"abc"'` String→int 等)此前仍直接喂 `callv`,引擎喷 `Cannot convert argument` 返回 `null`,RPC 却假 `ok:true`/`result:null`——调用方以为方法生效实则没跑(与 #164/#167 同级数据风险,且 README/SKILL 已宣称「typed 方法假成功不再可能」,文档与实现脱节)。现连 daemon 前按方法声明签名对非 Array 实参也做类型守卫:镜像 Godot `Variant::can_convert_strict`,命中安全互转组(数值组 `bool`/`int`/`float`、字符串组 `String`/`StringName`/`NodePath`)透传,跨组(必 `Cannot convert`)`-32602` fail-loud。数值宽化(`float`→`int`)、`bool`→`int`、`String`→`String` 等合法调用零回归;`null` 实参保守透传(不在本 issue 新增拦截)。纯 addon 服务端行为改动,老项目跑一次 `init` 同步。
@@ -82,7 +82,7 @@ All methods callable via `godot-cli-control <method>` or `from godot_cli_control
82
82
 
83
83
  | Method | Example |
84
84
  |---|---|
85
- | `click(path)` | `await client.click("/root/MyScene/Button")` |
85
+ | `click(path=None, node_type=None, text=None, text_contains=None, name_pattern=None, from_path=None)` | `await client.click("/root/MyScene/Button")`, or locate-and-click atomically with find-style filters: `await client.click(text_contains="开始", node_type="BaseButton")` — must match exactly one node (0 → `1001`, ≥ 2 → `1017` listing candidates); success envelope carries the clicked `path`. `path` and filters are mutually exclusive (`-32602`). CLI: `click <path>` \| `click --contains <substr> [--exact/--type/--name-pattern/--from]` |
86
86
  | `click_at(x, y, node=None, button="left", double=False)` | `await client.click_at(320, 240)` or `client.click_at(node="/root/Slot", button="right")` — coordinate-level mouse click via the real event pipeline (viewport physical px); CLI: `click-at <x> <y> \| --node <path> [--button] [--double]` |
87
87
  | `mouse_move(x, y, node=None)` | `await client.mouse_move(400, 300)` — inject one mouse-motion event (with `relative`); CLI: `mouse-move <x> <y> \| --node <path>` |
88
88
  | `drag(x1, y1, x2, y2, from_node=None, to_node=None, button="left", duration=0.3, steps=10)` | `await client.drag(100, 100, 300, 200)` — press→interpolated move→release (slider/drag-and-drop/swipe); each end may use `from_node`/`to_node` for a node center; game-time `duration`, one drag at a time (`1014` otherwise); CLI: `drag <x1> <y1> <x2> <y2> \| --from-node/--to-node <path> [--button] [--duration] [--steps]` |
@@ -137,6 +137,8 @@ All methods callable via `godot-cli-control <method>` or `from godot_cli_control
137
137
 
138
138
  Three numeric ranges share `error.code`; they never overlap, so a single field is unambiguous.
139
139
 
140
+ Most errors also carry an optional **`error.hint`** field — a one-line "what to do next" pointer (e.g. `1009` hints "call `pause` first, then `step-frames`"). Server-side hints (`1xxx` / `-32601`) come from the addon (`CliControlErrorCodes.hint_for`) and require a synced addon (re-run `init` on old projects); client-side hints (`-1xxx`) are added by the CLI itself. Codes whose `message` is already case-specific (`-32602`, `-1003`, `-1005`) carry no hint. Follow the hint before consulting the table below.
141
+
140
142
  | Code | Source | Meaning |
141
143
  |---|---|---|
142
144
  | `1001` | server | Node not found at the given path |
@@ -155,6 +157,7 @@ Three numeric ranges share `error.code`; they never overlap, so a single field i
155
157
  | `1014` | server | DRAG_IN_PROGRESS: a `drag` was issued while another is still interpolating. One mouse drag in flight at a time — wait for it (or `release-all` to cancel) before the next. State-class (like `1004`). |
156
158
  | `1015` | server | EMIT_SIGNAL_DISABLED: `emit-signal` called but daemon was not started with `--allow-emit-signal`. Restart the daemon with that flag (debug-build + localhost + explicit opt-in). `emit_signal` remains in the method blacklist — `call <node> emit_signal` is always rejected regardless of this flag. |
157
159
  | `1016` | server | RESPONSE_TOO_LARGE: a single response exceeded the outbound WebSocket buffer (default 10 MB, `godot_cli_control/outbound_buffer_mb`) and was replaced with this error instead of hanging the call to a `-1002` timeout. Usually a bytes-API `screenshot` of a hiDPI frame — pass a path (daemon writes to disk) or raise the buffer. |
160
+ | `1017` | server | AMBIGUOUS_MATCH: `click` with finder filters matched ≥ 2 nodes (message lists candidates). Narrow the filters (`--exact`/`--type`/`--from`/`--name-pattern`) until exactly one matches, or `find` and click the path directly. |
158
161
  | `-32600` | server | Malformed JSON-RPC request |
159
162
  | `-32601` | server | Unknown method name |
160
163
  | `-32602` | server | Invalid params (incl. blocked methods/properties from the security blacklist, `set` value-type mismatch — e.g. `Vector2` property given an array of wrong length / non-numeric elements; `call` typed-arg coercion failure / argument-count mismatch — wrong array length, non-numeric element, a JSON array given to a scalar/Object parameter, a scalar of the wrong type (a string for an `int` param, a number for a `String`/`StringName`), or wrong arg count (#167/#183); or `hold` given `duration ≤ 0` — use `press` for an indefinite hold; also out-of-range values like a scene `timeout` outside `(0, 3600]` sent directly via `GameClient`, and `find_nodes` called with no filter at all or with both `text` and `text_contains`) |
@@ -175,6 +178,7 @@ The daemon is the long-lived Godot process the CLI talks to (one per project roo
175
178
  | Command | What it does |
176
179
  |---|---|
177
180
  | `daemon start [--headless\|--gui] [--record] [--time-scale N] [--name <inst>] [--allow-emit-signal]` | Launch Godot with the bridge listening. `--name` sets the instance name (default `default`); use different names to run multiple Godot daemons for the same project in parallel. Headless vs GUI is auto-detected from the TTY; `--record` forces GUI (Movie Maker needs a real renderer) and is **rejected with `--headless`** (exit 2). `--time-scale N` sets `Engine.time_scale` from frame 0 (range `(0, 100]`). `--allow-emit-signal` unlocks the `emit-signal` subcommand (debug-build + localhost + explicit opt-in three gates); without it any `emit-signal` call returns `1015`. |
181
+ | `daemon restart [same flags as start]` | Tolerant stop (a stopped daemon is fine) + start with the flags you pass **now** — it does not remember the previous start's flags. The one-step way to change daemon flags (e.g. add `--allow-emit-signal`). Hard stop failure aborts before starting (exit 2); a transcode-only stop failure still restarts and exits `4`. |
178
182
  | `daemon stop [--name <inst>]` | Stop this project's daemon. With `--name`, stop the named instance. Exit 2 if it stopped cleanly but the `.avi`→`.mp4` ffmpeg transcode failed (raw `.avi` kept; see `.cli_control/ffmpeg.log`). |
179
183
  | `daemon stop --all` | Stop every registered daemon across all projects. **Exit 3** if at least one failed; per-record `rc` (and an `error` field on failures) is in `result.stopped[]`. |
180
184
  | `daemon stop --all --project <path>` | Stop all instances of one specific project. |
@@ -62,7 +62,94 @@ const EMIT_SIGNAL_DISABLED: int = 1015
62
62
  # 容量/资源类永久错——同一响应重试必再超;agent 应改用 path 落盘(screenshot)
63
63
  # 或调大 buffer。daemon 用它替换发不出去的大响应,避免 client 干等到 -1002 假超时。
64
64
  const RESPONSE_TOO_LARGE: int = 1016
65
+ # click 过滤器定位命中多于一个节点(find+click 原子化):BFS 序对 agent 不可
66
+ # 预期,静默点第一个可能点错按钮(比报错贵得多)——宁可 fail-loud 列出候选
67
+ # 让 agent 收窄。状态类错(与 1004/1014 同族):过滤器本身合法,是场景里
68
+ # 恰好有多个匹配。
69
+ const AMBIGUOUS_MATCH: int = 1017
65
70
 
66
71
  const INVALID_PARAMS: int = -32602
67
72
  const INVALID_REQUEST: int = -32600
68
73
  const METHOD_UNKNOWN: int = -32601
74
+
75
+
76
+ ## 错误码 → 「下一步怎么办」提示。空串 = 无提示(该码的 message 已足够具体,
77
+ ## 如 -32602 每个 case 有专属文案)。唯一注入点是 GameBridge._send_error:
78
+ ## 响应 error 对象带上可选 "hint" 字段,CLI 信封原样透传给 agent——让 agent
79
+ ## 在错误发生点当场拿到指引,不依赖它读过 SKILL.md 错误码表。
80
+ ## 客户端 -1xxx 的提示在 Python 侧 cli.py 的 _CLIENT_HINTS,两段各管各的
81
+ ## (与错误码三段制同构)。hint 只补「下一步动作」,不复述错误本身。
82
+ static func hint_for(code: int) -> String:
83
+ match code:
84
+ NODE_NOT_FOUND:
85
+ return (
86
+ "path must start with /root; locate by text/type with `find`,"
87
+ + " or `wait-node <path>` if it may not be loaded yet"
88
+ )
89
+ PROPERTY_NOT_FOUND:
90
+ return "inspect the node's properties with `tree <path> 1`"
91
+ METHOD_NOT_FOUND:
92
+ return (
93
+ "inspect node methods with `tree`; for input actions run"
94
+ + " `actions` (or `actions --all`)"
95
+ )
96
+ COMBO_IN_PROGRESS:
97
+ return "run `combo-cancel` (or `release-all`), then retry"
98
+ SCENE_TREE_TOO_LARGE:
99
+ return (
100
+ "pass --max-nodes N, or query a subtree:"
101
+ + " `tree <path>` / `children <path>`"
102
+ )
103
+ RESOURCE_UNAVAILABLE:
104
+ return (
105
+ "usually transient - retry after `wait-time 0.05`; on a"
106
+ + " headless daemon screenshots never work (restart with --gui)"
107
+ )
108
+ SIGNAL_NOT_FOUND:
109
+ return "list the node's signals with `tree`; check the spelling"
110
+ SCENE_UNAVAILABLE:
111
+ return (
112
+ "check the res:// path; if loading hangs, inspect"
113
+ + " `daemon logs --tail 50`"
114
+ )
115
+ NOT_PAUSED:
116
+ return "call `pause` first, then `step-frames`"
117
+ UNSUPPORTED_NODE_TYPE:
118
+ return (
119
+ "aim at a drawable node (often the child sprite),"
120
+ + " or use another command"
121
+ )
122
+ NODE_NOT_ON_SCREEN:
123
+ return "move the camera/node into view (or wait for it), then retry"
124
+ FEATURE_UNAVAILABLE:
125
+ return (
126
+ "requires a newer Godot (errors capture needs 4.5+);"
127
+ + " upgrade the engine or drop this call"
128
+ )
129
+ WRITE_FAILED:
130
+ return "fix the destination path/permissions (daemon-side), then retry"
131
+ DRAG_IN_PROGRESS:
132
+ return (
133
+ "wait for the in-flight drag to finish,"
134
+ + " or `release-all` to cancel it"
135
+ )
136
+ EMIT_SIGNAL_DISABLED:
137
+ return "restart the daemon with --allow-emit-signal"
138
+ RESPONSE_TOO_LARGE:
139
+ return (
140
+ "pass a file path so the daemon writes to disk,"
141
+ + " or raise godot_cli_control/outbound_buffer_mb"
142
+ )
143
+ AMBIGUOUS_MATCH:
144
+ return (
145
+ "narrow the filters (--exact / --type / --from / --name-pattern)"
146
+ + " until exactly one node matches, or run `find` and click"
147
+ + " the path directly"
148
+ )
149
+ METHOD_UNKNOWN:
150
+ return (
151
+ "client and addon versions drifted - re-run"
152
+ + " `godot-cli-control init` to sync the addon"
153
+ )
154
+ _:
155
+ return ""
@@ -388,7 +388,13 @@ func _send_response(id: String, result: Dictionary) -> void:
388
388
 
389
389
 
390
390
  func _send_error(id: String, code: int, message: String) -> void:
391
- var response: Dictionary = {"id": id, "error": {"code": code, "message": message}}
391
+ # 所有服务端错误响应的唯一出口:按码附加可选 "hint"(下一步指引),
392
+ # 让 agent 在错误发生点当场拿到动作建议(hint 表在 CliControlErrorCodes)。
393
+ var err: Dictionary = {"code": code, "message": message}
394
+ var hint: String = CliControlErrorCodes.hint_for(code)
395
+ if not hint.is_empty():
396
+ err["hint"] = hint
397
+ var response: Dictionary = {"id": id, "error": err}
392
398
  _send_json(response)
393
399
 
394
400
 
@@ -146,9 +146,57 @@ func _merge_extra(base: PackedStringArray, setting_key: String) -> PackedStringA
146
146
 
147
147
 
148
148
  func handle_click(params: Dictionary) -> Dictionary:
149
+ # 两种定位方式:绝对 path(原始形态),或 find 同款过滤器(type / text /
150
+ # text_contains / name_pattern / from)——「点击文案为 X 的按钮」此前要
151
+ # find → 解析 matches[0].path → click 三步两次 RPC,中间还有 stale-path
152
+ # 竞态窗口;现在服务端同帧内 find+click 原子完成。
153
+ if _has_find_filters(params):
154
+ var path_given: String = params.get("path", "") as String
155
+ if not path_given.is_empty():
156
+ return _err(
157
+ CliControlErrorCodes.INVALID_PARAMS,
158
+ "click: give either a node path or finder filters, not both",
159
+ )
160
+ return _click_by_filters(params)
149
161
  var node: Node = _get_node_or_error(params)
150
162
  if node == null:
151
163
  return _node_not_found(params.get("path", "") as String)
164
+ return _click_node(node)
165
+
166
+
167
+ ## 过滤器定位 + 点击。恰好 1 个匹配才点:0 个 → 1001;≥2 个 → 1017 列出
168
+ ## 匹配路径让 agent 收窄(宁可 fail-loud 也不静默点第一个——BFS 序对 agent
169
+ ## 不可预期,点错按钮比报错贵得多)。成功信封带 "path"(实际点到的节点)。
170
+ func _click_by_filters(params: Dictionary) -> Dictionary:
171
+ # 取 1 个足以点击、多取 4 个用于歧义报错时展示候选(够收窄用)
172
+ var found: Dictionary = _collect_find_matches(params, 5)
173
+ if found.has("error"):
174
+ return found
175
+ var nodes: Array = found["nodes"] as Array
176
+ if nodes.is_empty():
177
+ return _err(
178
+ CliControlErrorCodes.NODE_NOT_FOUND,
179
+ "click: no node matches the given filters",
180
+ )
181
+ if nodes.size() > 1:
182
+ var paths: PackedStringArray = PackedStringArray()
183
+ for entry: Dictionary in found["matches"] as Array:
184
+ paths.append(str(entry["path"]))
185
+ var more: String = ", ..." if (found.get("truncated", false) as bool) else ""
186
+ return _err(
187
+ CliControlErrorCodes.AMBIGUOUS_MATCH,
188
+ "click: %d nodes match the filters: %s%s"
189
+ % [nodes.size(), ", ".join(paths), more],
190
+ )
191
+ var result: Dictionary = _click_node(nodes[0] as Node)
192
+ if not result.has("error"):
193
+ var entry: Dictionary = (found["matches"] as Array)[0] as Dictionary
194
+ result["path"] = str(entry["path"])
195
+ return result
196
+
197
+
198
+ ## click 的实际派发(path 定位与过滤器定位共用)。
199
+ func _click_node(node: Node) -> Dictionary:
152
200
  if node is BaseButton:
153
201
  (node as BaseButton).emit_signal("pressed")
154
202
  return {"success": true}
@@ -694,9 +742,39 @@ func handle_find_nodes(params: Dictionary) -> Dictionary:
694
742
  # 服务端节点搜索(issue #153):程序化匿名 UI(@Button@12,编号跨运行不稳定)
695
743
  # 只能按文本/类型定位,客户端 children+get_text 逐层 BFS 在录制模式下
696
744
  # 每个 RPC 等帧渲染(50-150ms/次),一次全树遍历曾拖出 57s 死时间——
697
- # 这里把几十次往返折成一次。不用 Node.find_children:它先建全量匹配数组
698
- # 没法 limit 短路,且手写 BFS 才有「浅层优先」的稳定排序(UI 搜索友好)。
699
- # 空字符串 = 过滤器未启用(与 get_children type_filter 约定一致)。
745
+ # 这里把几十次往返折成一次。遍历本体抽在 _collect_find_matches,与
746
+ # click 的过滤器定位(原子 find+click)共用。
747
+ var limit: int = params.get("limit", 20) as int
748
+ if limit <= 0:
749
+ limit = 20
750
+ limit = mini(limit, _FIND_MAX_MATCHES)
751
+ var found: Dictionary = _collect_find_matches(params, limit)
752
+ if found.has("error"):
753
+ return found
754
+ var response: Dictionary = {"matches": found["matches"]}
755
+ if found.get("truncated", false) as bool:
756
+ response["truncated"] = true
757
+ return response
758
+
759
+
760
+ ## params 里是否带任一 find 过滤器(click 用它区分 path 定位 / 过滤器定位)。
761
+ ## 空字符串 = 过滤器未启用(与 get_children 的 type_filter 约定一致)。
762
+ func _has_find_filters(params: Dictionary) -> bool:
763
+ return (
764
+ not (params.get("type", "") as String).is_empty()
765
+ or not (params.get("name_pattern", "") as String).is_empty()
766
+ or not (params.get("text", "") as String).is_empty()
767
+ or not (params.get("text_contains", "") as String).is_empty()
768
+ or not (params.get("from", "") as String).is_empty()
769
+ )
770
+
771
+
772
+ ## find 遍历本体(handle_find_nodes 与 click 过滤器定位共用):校验过滤器 →
773
+ ## 解析 from 子树 → 迭代 BFS。返回 {"matches": Array[Dictionary](序列化 entry),
774
+ ## "nodes": Array[Node](与 matches 一一对应,供 click 直接派发), "truncated"?}
775
+ ## 或 {"error": ...}。不用 Node.find_children:它先建全量匹配数组没法 limit
776
+ ## 短路,且手写 BFS 才有「浅层优先」的稳定排序(UI 搜索友好)。
777
+ func _collect_find_matches(params: Dictionary, limit: int) -> Dictionary:
700
778
  var type_filter: String = params.get("type", "") as String
701
779
  var name_pattern: String = params.get("name_pattern", "") as String
702
780
  var text_exact: String = params.get("text", "") as String
@@ -714,10 +792,6 @@ func handle_find_nodes(params: Dictionary) -> Dictionary:
714
792
  CliControlErrorCodes.INVALID_PARAMS,
715
793
  "find_nodes: 'text' (exact) and 'text_contains' (substring) are mutually exclusive",
716
794
  )
717
- var limit: int = params.get("limit", 20) as int
718
- if limit <= 0:
719
- limit = 20
720
- limit = mini(limit, _FIND_MAX_MATCHES)
721
795
  # 默认搜全树(root 而非 current_scene):弹窗 / autoload 常直接挂 root,
722
796
  # 「按文本找按钮」必须能命中它们。
723
797
  var start: Node = get_tree().root
@@ -729,6 +803,7 @@ func handle_find_nodes(params: Dictionary) -> Dictionary:
729
803
  # 迭代 BFS(数组 + 头指针):浅层匹配排前;不递归 → 深树不爆栈;
730
804
  # 与 find_children(owned=true) 不同,代码创建的无 owner 节点照常命中。
731
805
  var matches: Array[Dictionary] = []
806
+ var match_nodes: Array[Node] = []
732
807
  var truncated: bool = false
733
808
  var queue: Array[Node] = []
734
809
  for child: Node in start.get_children():
@@ -743,12 +818,13 @@ func handle_find_nodes(params: Dictionary) -> Dictionary:
743
818
  truncated = true
744
819
  break
745
820
  matches.append(_node_entry(node))
821
+ match_nodes.append(node)
746
822
  for child: Node in node.get_children():
747
823
  queue.append(child)
748
- var response: Dictionary = {"matches": matches}
824
+ var result: Dictionary = {"matches": matches, "nodes": match_nodes}
749
825
  if truncated:
750
- response["truncated"] = true
751
- return response
826
+ result["truncated"] = true
827
+ return result
752
828
 
753
829
 
754
830
  ## find_nodes 的过滤判定(AND 语义)。text 两档共享前置:节点必须有 text 属性。
@@ -3,5 +3,5 @@
3
3
  name="Godot CLI Control"
4
4
  description="WebSocket bridge for headless / scripted control of Godot scenes (click / property / input simulation / screenshot / record)."
5
5
  author="kesar"
6
- version="0.4.2"
6
+ version="0.5.0"
7
7
  script="plugin.gd"
@@ -0,0 +1,38 @@
1
+ ## GUT 单元测试:CliControlErrorCodes.hint_for —— 错误码 → 下一步提示。
2
+ ##
3
+ ## 契约:每个 1xxx 业务码都必须登记 hint(agent 在错误发生点当场拿到指引,
4
+ ## 相当于把 SKILL.md 错误码表的「下一步」列下沉到运行时)。新增业务码忘
5
+ ## 登记 → test_every_business_code_has_hint 红,与 error_codes.gd 头注释的
6
+ ## 「新加业务码必须在这里登记」同一守护思路。
7
+ extends GutTest
8
+
9
+ const ErrorCodes := preload("res://addons/godot_cli_control/bridge/error_codes.gd")
10
+
11
+
12
+ func test_every_business_code_has_hint() -> void:
13
+ # get_script_constant_map 是 Script 的实例方法:经 preload 常量(类命名空间)
14
+ # 直呼会被 4.7 解析器判「对类调非静态函数」拒载,先落到 Script 类型变量再调。
15
+ var script_res: Script = ErrorCodes
16
+ var consts: Dictionary = script_res.get_script_constant_map()
17
+ for key: String in consts:
18
+ var value: Variant = consts[key]
19
+ if value is int and int(value) >= 1000 and int(value) < 2000:
20
+ assert_ne(
21
+ ErrorCodes.hint_for(int(value)),
22
+ "",
23
+ "业务码 %s(%d) 未登记 hint —— 新码必须同时登记下一步提示" % [key, value]
24
+ )
25
+
26
+
27
+ func test_unknown_code_returns_empty() -> void:
28
+ assert_eq(ErrorCodes.hint_for(4242), "")
29
+
30
+
31
+ func test_method_unknown_hints_init_resync() -> void:
32
+ # -32601 = client/addon 版本漂移,指引重跑 init 同步
33
+ assert_string_contains(ErrorCodes.hint_for(-32601), "init")
34
+
35
+
36
+ func test_invalid_params_has_no_hint() -> void:
37
+ # -32602 每个 case 的 message 已带专属文案,不加统一 hint(避免复述)
38
+ assert_eq(ErrorCodes.hint_for(-32602), "")
@@ -338,6 +338,23 @@ func test_sync_handler_error_dict_emits_error_frame() -> void:
338
338
  assert_eq(str(f.error.message), "Node not found")
339
339
 
340
340
 
341
+ func test_error_frame_carries_hint_for_registered_code() -> void:
342
+ # _send_error 是所有错误响应的唯一出口:已登记的码必须带 "hint"(下一步指引)
343
+ _low.click_return = {"error": {"code": 1001, "message": "Node not found"}}
344
+ _send('{"id": "x", "method": "click", "params": {"path": "/missing"}}')
345
+ var f: Dictionary = _last_frame()
346
+ assert_has(f.error, "hint")
347
+ assert_string_contains(str(f.error.hint), "find")
348
+
349
+
350
+ func test_error_frame_omits_hint_for_unregistered_code() -> void:
351
+ # -32600 未登记 hint —— 不带空字段占位(信封字段有值才出现)
352
+ _send('not json at all')
353
+ var f: Dictionary = _last_frame()
354
+ assert_eq(int(f.error.code), -32600)
355
+ assert_does_not_have(f.error, "hint")
356
+
357
+
341
358
  # ── async 路径 ────────────────────────────────────────────────────
342
359
 
343
360
  func test_async_handler_success_emits_result_frame() -> void:
@@ -1395,6 +1395,72 @@ func test_find_nodes_from_scopes_search_to_subtree() -> void:
1395
1395
  assert_string_contains(str((matches[0] as Dictionary).get("name")), "@Button@")
1396
1396
 
1397
1397
 
1398
+ # ── handle_click 过滤器定位(原子 find+click) ────────────────────
1399
+
1400
+
1401
+ func _find_fixture_button(text: String) -> Button:
1402
+ for child: Node in (_target.get_node("Panel") as Node).get_children():
1403
+ if child is Button and (child as Button).text == text:
1404
+ return child as Button
1405
+ return null
1406
+
1407
+
1408
+ func test_click_by_text_clicks_unique_match() -> void:
1409
+ _build_find_fixture()
1410
+ var btn: Button = _find_fixture_button("开始游戏")
1411
+ assert_not_null(btn)
1412
+ watch_signals(btn)
1413
+ var result: Dictionary = _api.handle_click({
1414
+ "from": str(_target.get_path()), "text": "开始游戏",
1415
+ })
1416
+ assert_does_not_have(result, "error")
1417
+ assert_eq(result.get("success"), true)
1418
+ assert_eq(
1419
+ str(result.get("path")), str(btn.get_path()),
1420
+ "信封应带实际点到的节点路径(agent 可缓存复用)"
1421
+ )
1422
+ assert_signal_emitted(btn, "pressed")
1423
+
1424
+
1425
+ func test_click_by_filters_zero_matches_is_1001() -> void:
1426
+ _build_find_fixture()
1427
+ var result: Dictionary = _api.handle_click({
1428
+ "from": str(_target.get_path()), "text": "不存在的按钮文案",
1429
+ })
1430
+ assert_has(result, "error")
1431
+ assert_eq(int(result.error.code), 1001)
1432
+
1433
+
1434
+ func test_click_by_filters_multiple_matches_is_1017_listing_paths() -> void:
1435
+ _build_find_fixture()
1436
+ # type=BaseButton 命中两个按钮 → 歧义必须 fail-loud(BFS 序不可预期,
1437
+ # 静默点第一个可能点错),message 列出候选路径供 agent 收窄
1438
+ var result: Dictionary = _api.handle_click({
1439
+ "from": str(_target.get_path()), "type": "BaseButton",
1440
+ })
1441
+ assert_has(result, "error")
1442
+ assert_eq(int(result.error.code), 1017)
1443
+ assert_string_contains(str(result.error.message), "@Button@")
1444
+
1445
+
1446
+ func test_click_path_plus_filters_is_invalid_params() -> void:
1447
+ _build_find_fixture()
1448
+ var result: Dictionary = _api.handle_click({
1449
+ "path": str(_target.get_path()), "text": "开始游戏",
1450
+ })
1451
+ assert_has(result, "error")
1452
+ assert_eq(int(result.error.code), -32602)
1453
+
1454
+
1455
+ func test_click_by_filters_invalid_combo_propagates_find_validation() -> void:
1456
+ # 过滤器校验复用 find 侧:text 与 text_contains 互斥(-32602)
1457
+ var result: Dictionary = _api.handle_click({
1458
+ "text": "a", "text_contains": "b",
1459
+ })
1460
+ assert_has(result, "error")
1461
+ assert_eq(int(result.error.code), -32602)
1462
+
1463
+
1398
1464
  # ── _mark_screenshot_stale_check (#156 子问题 B / B3) ────────────────
1399
1465
 
1400
1466
  func test_stale_check_first_call_not_stale() -> void:
@@ -25,9 +25,9 @@ godot-cli-control tree 3
25
25
  godot-cli-control daemon stop
26
26
  ```
27
27
 
28
- Re-running `init` refreshes both `addons/godot_cli_control/` and the SKILL.md files to match the installed CLI version (the plugin directory is wiped and re-copied; `project.godot` patching stays idempotent). Pass `--keep-addon` to keep an existing `addons/godot_cli_control/` untouched.
28
+ Re-running `init` refreshes both `addons/godot_cli_control/` and the skill files to match the installed CLI version (the plugin directory is wiped and re-copied; `project.godot` patching stays idempotent). Pass `--keep-addon` to keep an existing `addons/godot_cli_control/` untouched.
29
29
 
30
- `init` also writes `.claude/skills/godot-cli-control/SKILL.md` and `.codex/skills/godot-cli-control/SKILL.md` so AI agents working in your Godot project can pick up this CLI surface automatically. Use `--no-skills` to skip, or `--skills-only` to refresh just those files after a CLI upgrade. See the [top-level README](../README.md#agent-integration) for details.
30
+ `init` also writes `.claude/skills/godot-cli-control/` and `.codex/skills/godot-cli-control/` (a lean `SKILL.md` core + `references/*.md` detail files) so AI agents working in your Godot project can pick up this CLI surface automatically without flooding their context. Use `--no-skills` to skip, or `--skills-only` to refresh just those files after a CLI upgrade. See the [top-level README](../README.md#agent-integration) for details.
31
31
 
32
32
  ## Async API
33
33
 
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.4.2'
22
- __version_tuple__ = version_tuple = (0, 4, 2)
21
+ __version__ = version = '0.5.0'
22
+ __version_tuple__ = version_tuple = (0, 5, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -139,9 +139,28 @@ class GameBridge:
139
139
 
140
140
  # ── UI 交互 ──
141
141
 
142
- def click(self, path: str) -> dict:
143
- """点击 UI 节点。"""
144
- return self._run(self._client.click(path))
142
+ def click(
143
+ self,
144
+ path: str | None = None,
145
+ *,
146
+ node_type: str | None = None,
147
+ text: str | None = None,
148
+ text_contains: str | None = None,
149
+ name_pattern: str | None = None,
150
+ from_path: str | None = None,
151
+ ) -> dict:
152
+ """点击 UI 节点:给 ``path`` 直接定位,或给 find 同款过滤器由服务端
153
+ 同帧原子 find+click(恰好 1 个匹配才点:0 个 → 1001,≥2 个 → 1017)。"""
154
+ return self._run(
155
+ self._client.click(
156
+ path,
157
+ node_type=node_type,
158
+ text=text,
159
+ text_contains=text_contains,
160
+ name_pattern=name_pattern,
161
+ from_path=from_path,
162
+ )
163
+ )
145
164
 
146
165
  def click_at(
147
166
  self,