godot-cli-control 0.2.12__tar.gz → 0.2.14__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 (62) hide show
  1. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/PKG-INFO +22 -2
  2. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/README.md +36 -3
  3. godot_cli_control-0.2.14/pyproject.toml +151 -0
  4. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/README.md +1 -1
  5. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/godot_cli_control/_version.py +2 -2
  6. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/godot_cli_control/bridge.py +5 -2
  7. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/godot_cli_control/cli.py +97 -15
  8. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/godot_cli_control/client.py +55 -10
  9. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/godot_cli_control/daemon.py +41 -0
  10. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/godot_cli_control/templates/skill/SKILL.md +14 -8
  11. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/tests/test_cli.py +19 -12
  12. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/tests/test_cli_helpers.py +0 -1
  13. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/tests/test_client.py +5 -5
  14. godot_cli_control-0.2.14/python/tests/test_e2e_client_direct.py +217 -0
  15. godot_cli_control-0.2.14/python/tests/test_e2e_example.py +123 -0
  16. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/tests/test_e2e_input.py +8 -5
  17. godot_cli_control-0.2.14/python/tests/test_e2e_record.py +198 -0
  18. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/tests/test_e2e_screenshot_gui.py +8 -4
  19. godot_cli_control-0.2.14/python/tests/test_issue_fixes.py +292 -0
  20. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/tests/test_pytest_plugin.py +15 -0
  21. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/tests/test_registry.py +8 -4
  22. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/tests/test_runner.py +0 -1
  23. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/tests/test_skills_install.py +0 -1
  24. godot_cli_control-0.2.12/pyproject.toml +0 -86
  25. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/.gitignore +0 -0
  26. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/LICENSE +0 -0
  27. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/CHANGELOG.md +0 -0
  28. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/LICENSE +0 -0
  29. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/bin/run_cli_control.ps1 +0 -0
  30. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/bin/run_cli_control.sh +0 -0
  31. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/bridge/error_codes.gd +0 -0
  32. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/bridge/game_bridge.gd +0 -0
  33. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/bridge/game_bridge.gd.uid +0 -0
  34. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/bridge/input_simulation_api.gd +0 -0
  35. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/bridge/input_simulation_api.gd.uid +0 -0
  36. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/bridge/low_level_api.gd +0 -0
  37. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/bridge/low_level_api.gd.uid +0 -0
  38. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/plugin.cfg +0 -0
  39. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/plugin.gd +0 -0
  40. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/plugin.gd.uid +0 -0
  41. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/tests/gut/test_game_bridge.gd +0 -0
  42. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/tests/gut/test_input_simulation_api.gd +0 -0
  43. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/tests/gut/test_low_level_api.gd +0 -0
  44. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/tests/run_gut.py +0 -0
  45. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/tests/run_gut.sh +0 -0
  46. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/tests/test_init_walkthrough.py +0 -0
  47. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/tests/test_walkthrough.sh +0 -0
  48. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/godot_cli_control/__init__.py +0 -0
  49. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/godot_cli_control/__main__.py +0 -0
  50. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/godot_cli_control/_duration.py +0 -0
  51. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/godot_cli_control/init_cmd.py +0 -0
  52. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/godot_cli_control/pytest_plugin.py +0 -0
  53. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/godot_cli_control/registry.py +0 -0
  54. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/godot_cli_control/runner.py +0 -0
  55. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/godot_cli_control/skills_install.py +0 -0
  56. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/godot_cli_control/templates/__init__.py +0 -0
  57. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/godot_cli_control/templates/skill/__init__.py +0 -0
  58. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/tests/__init__.py +0 -0
  59. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/tests/test_bridge.py +0 -0
  60. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/tests/test_daemon.py +0 -0
  61. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/tests/test_duration.py +0 -0
  62. {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/tests/test_init.py +0 -0
@@ -1,10 +1,29 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: godot-cli-control
3
- Version: 0.2.12
3
+ Version: 0.2.14
4
4
  Summary: WebSocket bridge for headless / scripted control of Godot scenes.
5
+ Project-URL: Homepage, https://github.com/ClaymanTwinkle/godot-cli-control
6
+ Project-URL: Repository, https://github.com/ClaymanTwinkle/godot-cli-control
7
+ Project-URL: Documentation, https://github.com/ClaymanTwinkle/godot-cli-control#readme
8
+ Project-URL: Changelog, https://github.com/ClaymanTwinkle/godot-cli-control/blob/main/addons/godot_cli_control/CHANGELOG.md
9
+ Project-URL: Issues, https://github.com/ClaymanTwinkle/godot-cli-control/issues
5
10
  Author: kesar
6
11
  License: MIT
7
12
  License-File: LICENSE
13
+ Keywords: ai-agent,automation,cli,game-testing,gdscript,godot,godot-plugin,godot4,headless,json-rpc,llm,pytest,screenshot,websocket
14
+ Classifier: Development Status :: 3 - Alpha
15
+ Classifier: Environment :: Console
16
+ Classifier: Framework :: Pytest
17
+ Classifier: Intended Audience :: Developers
18
+ Classifier: License :: OSI Approved :: MIT License
19
+ Classifier: Operating System :: OS Independent
20
+ Classifier: Programming Language :: Python :: 3
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Topic :: Games/Entertainment
26
+ Classifier: Topic :: Software Development :: Testing
8
27
  Requires-Python: >=3.10
9
28
  Requires-Dist: websockets<16,>=14
10
29
  Provides-Extra: pytest
@@ -13,6 +32,7 @@ Provides-Extra: test
13
32
  Requires-Dist: pytest-asyncio>=0.21; extra == 'test'
14
33
  Requires-Dist: pytest-cov>=4; extra == 'test'
15
34
  Requires-Dist: pytest>=7; extra == 'test'
35
+ Requires-Dist: ruff>=0.6; extra == 'test'
16
36
  Description-Content-Type: text/markdown
17
37
 
18
38
  # godot-cli-control
@@ -25,7 +45,7 @@ WebSocket bridge for headless / scripted control of Godot 4 scenes — Python cl
25
45
  pipx install godot-cli-control
26
46
 
27
47
  # or, for unreleased main:
28
- pipx install "git+https://github.com/ClaymanTwinkle/godot-cli-control.git#subdirectory=python"
48
+ pipx install "git+https://github.com/ClaymanTwinkle/godot-cli-control.git"
29
49
  ```
30
50
 
31
51
  The wheel ships the Godot plugin source so the `init` command can drop it into your project.
@@ -104,6 +104,12 @@ All methods callable via `godot-cli-control <method>` or `from godot_cli_control
104
104
  | `get_pressed()` | `await client.get_pressed()` |
105
105
  | `list_input_actions(include_builtin=False)` | `await client.list_input_actions()` |
106
106
 
107
+ > **CLI note**: as a shell subcommand, `screenshot` **requires an output path** — `godot-cli-control screenshot /tmp/shot.png` — and writes the PNG to that file. Returning base64 over stdout is intentionally unsupported, to keep large binary payloads out of an automating agent's context window.
108
+
109
+ > **`--wait`**: `tap` / `hold` / `combo` return as soon as the input is armed, *before* the motion finishes. Add `--wait` (e.g. `hold run 1.5 --wait`) to block until the action's duration elapses (game-time) so the next read sees the settled state — equivalent to following the command with `wait-time <duration>` on the same connection.
110
+
111
+ > **No-arg `GameClient()` / `GameBridge()`**: with no `port` argument they auto-discover the daemon's port from `.cli_control/port` (falling back to `9877`), so a single-connection script connects to a running daemon without hardcoding a port.
112
+
107
113
  ### Error codes
108
114
 
109
115
  Three numeric ranges share `error.code`; they never overlap, so a single field is unambiguous.
@@ -112,7 +118,7 @@ Three numeric ranges share `error.code`; they never overlap, so a single field i
112
118
  |---|---|---|
113
119
  | `1001` | server | Node not found at the given path |
114
120
  | `1002` | server | Property not found / shape mismatch |
115
- | `1003` | server | Method not found on the node (schema error, don't retry) |
121
+ | `1003` | server | Method not found on the node, **or** unknown InputMap action passed to `press`/`release`/`tap`/`hold`/`combo` (`"Unknown action: <name>"`). Schema error don't retry; run `actions` (or `actions --all`) to list valid actions |
116
122
  | `1004` | server | Combo already in progress (call `combo-cancel` to retry) |
117
123
  | `1005` | server | Scene tree too large (lower `depth` or pass `--max-nodes`) |
118
124
  | `1006` | server | Resource transiently unavailable (e.g. screenshot during scene transition). Rare under normal use — GameBridge waits for viewport first-frame before listening, and `screenshot` retries internally. Safe to retry if you do hit it. |
@@ -121,13 +127,40 @@ Three numeric ranges share `error.code`; they never overlap, so a single field i
121
127
  | `-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, or `hold` given `duration ≤ 0` — use `press` for an indefinite hold) |
122
128
  | `-1001` | client | Connection failure (daemon not running, port wrong, proxy hijacking localhost) |
123
129
  | `-1002` | client | Timeout waiting for response |
124
- | `-1003` | client | CLI usage error (combo missing steps, malformed `--steps-json`, …) |
130
+ | `-1003` | client | CLI usage error (combo missing steps, malformed `--steps-json`, a non-numeric `tap`/`wait-time` arg, a `set`/`call` value that fails JSON parsing, …). From an RPC subcommand this always exits **64** (#82); top-level `run`/`daemon` precondition failures also use `-1003` but exit 2. |
125
131
  | `-1004` | client | Local file IO error (e.g. screenshot can't write the destination) |
126
132
  | `-1005` | client | `run <script>` user script raised an uncaught exception — fix the script |
127
133
  | `-1099` | client | Internal CLI bug — please file an issue |
128
134
 
129
135
  For full retry guidance see the SKILL.md shipped by `godot-cli-control init` (`.claude/skills/godot-cli-control/SKILL.md` in the target project).
130
136
 
137
+ ## Daemon commands
138
+
139
+ The daemon is the long-lived Godot process the CLI talks to (one per project root). The CLI auto-starts it where needed; manage it explicitly with:
140
+
141
+ | Command | What it does |
142
+ |---|---|
143
+ | `daemon start [--headless\|--gui] [--record]` | Launch Godot with the bridge listening. 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). |
144
+ | `daemon stop` | Stop this project's daemon. Exit 2 if it stopped cleanly but the `.avi`→`.mp4` ffmpeg transcode failed (raw `.avi` kept; see `.cli_control/ffmpeg.log`). |
145
+ | `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[]`. |
146
+ | `daemon stop --project <path>` | Stop the daemon for one specific project root. |
147
+ | `daemon status` | Exit 0 = running, 1 = stopped. When stopped, the payload may carry `last_log` / `last_exit_code` to diagnose a crash. |
148
+ | `daemon ls` | List every registered daemon (project root, pid, port). |
149
+
150
+ **Project-level defaults**: drop a `.cli_control/config.json` with `{"idle_timeout": "30m"}` to give `daemon start` / `run` a default idle-timeout without passing `--idle-timeout` each time. It's consulted only when the flag is omitted (default `0`); an explicit `--idle-timeout` always wins. A long-running `wait-time` / `combo` / recording is bounded client-side by a 600s wall-time fail-safe — override it for legitimately long operations with `GODOT_CLI_LONG_OP_TIMEOUT=<seconds>`.
151
+
152
+ ## Exit codes
153
+
154
+ Semantic exit codes so `if godot-cli-control exists /root/Foo; then …` works in shell:
155
+
156
+ | Code | Meaning |
157
+ |---|---|
158
+ | 0 | Success (or boolean true for `exists` / `visible`, node found for `wait-node`) |
159
+ | 1 | RPC error; also `exists`/`visible`=false, `wait-node` timeout, `daemon status`=stopped |
160
+ | 2 | Connection / IO error, or `daemon stop` ffmpeg-transcode failure |
161
+ | 3 | `daemon stop --all` partial failure (≥1 daemon failed to stop) |
162
+ | 64 | Usage error on an RPC subcommand — bad argparse args, a pre-flight reject (`combo` with no steps / malformed `--steps-json`), a non-numeric `tap`/`wait-time` arg, or a `set`/`call` value that fails JSON parsing. These all carry client code `-1003` and now consistently exit 64 (#82; a few previously exited 2). |
163
+
131
164
  ## Activation Modes
132
165
 
133
166
  Activation paths (any one triggers the bridge):
@@ -151,7 +184,7 @@ If none triggered, the plugin prints `[Godot CLI Control] inactive — ...` to c
151
184
  ## Known Limitations
152
185
 
153
186
  1. **Headless + Movie Maker is broken** (Godot upstream): `--write-movie` produces empty MP4 in headless mode (no framebuffer). Use GUI mode for recording.
154
- 2. **Godot binary detection** falls back to `GODOT_BIN` env var. `init` writes `.cli_control/godot_bin` after autodetect; daemon reads that file before scanning. Detection covers macOS `/Applications/Godot*.app`, `PATH` (`godot`/`godot4`), and Windows `Program Files\Godot*`.
187
+ 2. **Godot binary detection** order: `GODOT_BIN` env var → macOS `/Applications/Godot*.app` → `PATH` (`godot4`/`godot`) → Windows `Program Files\Godot*`. `init` writes the detected path to `.cli_control/godot_bin`; the daemon reads that file before re-scanning. The pytest e2e suite uses this same detection, so a `godot` on `PATH` is enough to run it.
155
188
  3. **Python ≥ 3.10** required (websockets>=14 dependency).
156
189
  4. **Single-client mode**: only one WebSocket client at a time; second connection rejected.
157
190
  5. **Headless plugin auto-register**: in headless mode, `--editor --quit` may not trigger `plugin._enter_tree` to write the autoload. `init` handles this automatically by patching `project.godot` directly.
@@ -0,0 +1,151 @@
1
+ [project]
2
+ name = "godot-cli-control"
3
+ dynamic = ["version"]
4
+ description = "WebSocket bridge for headless / scripted control of Godot scenes."
5
+ readme = "python/README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "kesar" }]
9
+ # keywords / classifiers 仅供 PyPI 搜索与分类筛选;与 GitHub repo topics 对齐。
10
+ # 改这里时顺手对一眼 README 顶部徽章 + repo About 的 topics,别让三处漂移。
11
+ keywords = [
12
+ "godot",
13
+ "godot4",
14
+ "godot-plugin",
15
+ "gdscript",
16
+ "automation",
17
+ "game-testing",
18
+ "pytest",
19
+ "websocket",
20
+ "json-rpc",
21
+ "ai-agent",
22
+ "llm",
23
+ "screenshot",
24
+ "headless",
25
+ "cli",
26
+ ]
27
+ classifiers = [
28
+ # README 自称 Alpha;升 Beta/Stable 时记得同步这里与 README 的 Status 段。
29
+ "Development Status :: 3 - Alpha",
30
+ "Intended Audience :: Developers",
31
+ "License :: OSI Approved :: MIT License",
32
+ "Operating System :: OS Independent",
33
+ "Environment :: Console",
34
+ # Python 版本与 CI matrix(.github/workflows/ci.yml)保持一致:实测 3.10–3.13。
35
+ "Programming Language :: Python :: 3",
36
+ "Programming Language :: Python :: 3.10",
37
+ "Programming Language :: Python :: 3.11",
38
+ "Programming Language :: Python :: 3.12",
39
+ "Programming Language :: Python :: 3.13",
40
+ "Topic :: Software Development :: Testing",
41
+ "Topic :: Games/Entertainment",
42
+ # pytest11 entry-point 让它成为一个 pytest 插件 —— 这条 classifier 能把包
43
+ # 收进 PyPI 的 pytest-plugin 分类列表,是面向 pytest 用户的关键发现入口。
44
+ "Framework :: Pytest",
45
+ ]
46
+ dependencies = [
47
+ "websockets>=14,<16",
48
+ ]
49
+
50
+ [project.optional-dependencies]
51
+ pytest = ["pytest>=7"]
52
+ # 全套测试依赖 —— 仅 CI / 开发者本地测试用,包含 pytest-cov、pytest-asyncio 与
53
+ # ruff(lint 门禁,issue #83)。普通用户用 `[pytest]` 已够;不要把这些拖到默认安装路径。
54
+ test = ["pytest>=7", "pytest-asyncio>=0.21", "pytest-cov>=4", "ruff>=0.6"]
55
+
56
+ [project.scripts]
57
+ godot-cli-control = "godot_cli_control.cli:main"
58
+
59
+ [project.entry-points.pytest11]
60
+ godot-cli-control = "godot_cli_control.pytest_plugin"
61
+
62
+ # PyPI 项目页侧栏的链接全靠这张表(现在为空 = 侧栏只有一个 PyPI 自身链接)。
63
+ # Changelog 指向 addon 内的 CHANGELOG(README「Status」段也引这份),别改成根目录。
64
+ [project.urls]
65
+ Homepage = "https://github.com/ClaymanTwinkle/godot-cli-control"
66
+ Repository = "https://github.com/ClaymanTwinkle/godot-cli-control"
67
+ Documentation = "https://github.com/ClaymanTwinkle/godot-cli-control#readme"
68
+ Changelog = "https://github.com/ClaymanTwinkle/godot-cli-control/blob/main/addons/godot_cli_control/CHANGELOG.md"
69
+ Issues = "https://github.com/ClaymanTwinkle/godot-cli-control/issues"
70
+
71
+ [tool.pytest.ini_options]
72
+ # pytest-asyncio:显式钉死 async fixture 的 loop scope 为 function(= 未来版本默认值),
73
+ # 消除 "asyncio_default_fixture_loop_scope is unset" 的 deprecation warning。改这行前
74
+ # 先确认没有 module/session-scope 的 async fixture 依赖跨用例共享 loop。
75
+ asyncio_default_fixture_loop_scope = "function"
76
+ # gui:需要真实显示(开窗)才能跑的端到端测试。普通 unit / headless e2e 不带此
77
+ # marker。本地默认随 godot 检测 / GCC_GUI_E2E 未设而 skip;CI 专档(Linux 套
78
+ # xvfb 或 macOS runner)设 GCC_GUI_E2E=1 开启。注册在此避免 unknown-marker 警告,
79
+ # 也让 `pytest -m "not gui"` 能稳定剔除。
80
+ markers = [
81
+ "gui: 需要真实显示的 windowed e2e(screenshot frame_post_draw 路径,issue #64)",
82
+ ]
83
+
84
+ # ── lint(ruff)─────────────────────────────────────────────────────────
85
+ # issue #83:把 lint 从「开发者本机全局 ruff」收编成 CI 门禁 + dev 依赖,趁全仓
86
+ # 基线为 0 锁定收益。规则集刻意只钉 ruff 默认的 pyflakes(F) + pycodestyle 子集
87
+ # (E4/E7/E9)——这正是当前 0-error 基线对应的集合;扩到 E501/I/UP/B 会一次性炸出
88
+ # 几十条历史报错,与「锁定基线、零噪音引入门禁」的目标相悖。要加规则请单开 PR、
89
+ # 连同 autofix 一起 land,别让门禁第一天就红。line-length 仅供 `ruff format`
90
+ # 参考(E501 未纳入 select,不构成门禁)。
91
+ [tool.ruff]
92
+ target-version = "py310"
93
+ line-length = 100
94
+ extend-exclude = ["python/godot_cli_control/_version.py"] # hatch-vcs 生成物
95
+
96
+ [tool.ruff.lint]
97
+ select = ["E4", "E7", "E9", "F"]
98
+
99
+ [build-system]
100
+ requires = ["hatchling", "hatch-vcs"]
101
+ build-backend = "hatchling.build"
102
+
103
+ [tool.hatch.version]
104
+ source = "vcs"
105
+
106
+ [tool.hatch.build.hooks.vcs]
107
+ version-file = "python/godot_cli_control/_version.py"
108
+
109
+ [tool.hatch.build.targets.wheel]
110
+ packages = ["python/godot_cli_control"]
111
+
112
+ # 把 GDScript 插件嵌进 wheel,让 `godot-cli-control init` 能从安装包里把 plugin
113
+ # 复制到目标 Godot 项目。位置 _plugin/ 与 init_cmd.locate_plugin_source() 的查找逻辑约定。
114
+ [tool.hatch.build.targets.wheel.force-include]
115
+ "addons/godot_cli_control" = "godot_cli_control/_plugin"
116
+
117
+ # sdist 只装构建 wheel 必需的物料;docs/、仓库根 README 不进 sdist。
118
+ [tool.hatch.build.targets.sdist]
119
+ include = [
120
+ "/python/godot_cli_control",
121
+ "/python/tests",
122
+ "/python/README.md",
123
+ "/addons/godot_cli_control",
124
+ "/LICENSE",
125
+ ]
126
+
127
+ # ── 覆盖率 ──────────────────────────────────────────────────────────────
128
+ # 用 `coverage run -m pytest` 启动(不是 `pytest --cov`)—— pytest 启动时
129
+ # pytest11 entry-point 会自动加载 godot_cli_control.pytest_plugin,进而 import
130
+ # bridge/daemon/client;如果用 pytest-cov 那是先 pytest 后 cov,import-time 语句
131
+ # 全部 miss。`coverage run` 在 Python 启动时就开 trace,避开这个时序坑。
132
+ [tool.coverage.run]
133
+ source = ["godot_cli_control"]
134
+ branch = true
135
+ omit = [
136
+ "*/_version.py", # hatch-vcs 生成的版本字符串,无逻辑
137
+ "*/__main__.py", # `python -m godot_cli_control` 入口,靠 walkthrough 覆盖
138
+ "*/templates/*", # init 命令复制出去的模板,由 walkthrough 测
139
+ ]
140
+
141
+ [tool.coverage.report]
142
+ show_missing = true
143
+ skip_covered = false
144
+ exclude_lines = [
145
+ "pragma: no cover",
146
+ "if __name__ == .__main__.:",
147
+ "raise NotImplementedError",
148
+ ]
149
+ # 80% 是当前 Python 源码大致能稳定到的水准(cli.py 很大但既有测试良好)。
150
+ # 调高的话先在本地 `coverage report --fail-under=N` 验过再改。
151
+ fail_under = 80
@@ -8,7 +8,7 @@ WebSocket bridge for headless / scripted control of Godot 4 scenes — Python cl
8
8
  pipx install godot-cli-control
9
9
 
10
10
  # or, for unreleased main:
11
- pipx install "git+https://github.com/ClaymanTwinkle/godot-cli-control.git#subdirectory=python"
11
+ pipx install "git+https://github.com/ClaymanTwinkle/godot-cli-control.git"
12
12
  ```
13
13
 
14
14
  The wheel ships the Godot plugin source so the `init` command can drop it into your project.
@@ -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.2.12'
22
- __version_tuple__ = version_tuple = (0, 2, 12)
21
+ __version__ = version = '0.2.14'
22
+ __version_tuple__ = version_tuple = (0, 2, 14)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -15,13 +15,16 @@ import asyncio
15
15
  from pathlib import Path
16
16
  from typing import Any
17
17
 
18
- from .client import DEFAULT_PORT, GameClient
18
+ from .client import GameClient
19
19
 
20
20
 
21
21
  class GameBridge:
22
22
  """同步接口,内部通过事件循环调用异步 GameClient。"""
23
23
 
24
- def __init__(self, port: int = DEFAULT_PORT) -> None:
24
+ def __init__(self, port: int | None = None) -> None:
25
+ # port=None → GameClient 从 .cli_control/port auto-discover(issue #91)。
26
+ # daemon 默认 OS 自动分配端口,所以 ``GameBridge()`` 无参数也能连上正在
27
+ # 跑的 daemon,与 README 的单连接脚本示例一致。
25
28
  self._loop = asyncio.new_event_loop()
26
29
  self._client = GameClient(port=port)
27
30
  self._loop.run_until_complete(
@@ -243,6 +243,39 @@ def _read_combo_steps(ns: argparse.Namespace) -> list[dict]:
243
243
  return parsed
244
244
 
245
245
 
246
+ def _combo_total_duration(steps: list[dict]) -> float:
247
+ """combo 全部 step 的累计 game-time(给 ``--wait`` 算阻塞时长,issue #90)。
248
+
249
+ step schema 与服务端一致:``{"action": ..., "duration": <默认 0.1>}`` 串行
250
+ 按下/释放,``{"wait": <秒>}`` 纯等待。非数字 / 缺字段按默认 0.1 兜底,绝不
251
+ 抛错——``--wait`` 是体验增强,估时偏差远好过让命令崩掉。
252
+ """
253
+ total = 0.0
254
+ for step in steps:
255
+ if not isinstance(step, dict):
256
+ continue
257
+ raw = step.get("wait", step.get("duration", 0.1))
258
+ try:
259
+ total += float(raw)
260
+ except (TypeError, ValueError):
261
+ total += 0.1
262
+ return total
263
+
264
+
265
+ async def _maybe_block_for_duration(
266
+ client: GameClient, ns: argparse.Namespace, duration: float
267
+ ) -> None:
268
+ """``--wait``:输入命令发出后,阻塞 ``duration`` 的 game-time 再返回(issue #90)。
269
+
270
+ 把 SKILL.md 推荐的「读动作完成后状态前先 wait-time <时长>」折叠进同一条命令、
271
+ 复用同一连接:需要同步的 agent 一步到位拿到结算后状态。默认(不传 ``--wait``)
272
+ 保持异步立即返回,sticky + timer 模型不变。``wait_game_time`` 在 ≤0 时本就短路,
273
+ 这里也提前挡掉,省一次 RPC 往返。
274
+ """
275
+ if getattr(ns, "wait", False) and duration > 0:
276
+ await client.wait_game_time(duration)
277
+
278
+
246
279
  # ── 现有 RPC 命令 handler(迁移到 ns 签名 + 返回数据) ──
247
280
 
248
281
 
@@ -279,11 +312,16 @@ async def cmd_release(client: GameClient, ns: argparse.Namespace) -> dict:
279
312
 
280
313
  async def cmd_tap(client: GameClient, ns: argparse.Namespace) -> dict:
281
314
  duration = float(ns.duration) if ns.duration else 0.1
282
- return await client.action_tap(ns.action, duration)
315
+ result = await client.action_tap(ns.action, duration)
316
+ await _maybe_block_for_duration(client, ns, duration)
317
+ return result
283
318
 
284
319
 
285
320
  async def cmd_hold(client: GameClient, ns: argparse.Namespace) -> dict:
286
- return await client.hold(ns.action, float(ns.duration))
321
+ duration = float(ns.duration)
322
+ result = await client.hold(ns.action, duration)
323
+ await _maybe_block_for_duration(client, ns, duration)
324
+ return result
287
325
 
288
326
 
289
327
  async def cmd_combo(client: GameClient, ns: argparse.Namespace) -> dict:
@@ -291,7 +329,9 @@ async def cmd_combo(client: GameClient, ns: argparse.Namespace) -> dict:
291
329
  steps = getattr(ns, "_combo_steps", None)
292
330
  if steps is None:
293
331
  steps = _read_combo_steps(ns)
294
- return await client.combo(steps)
332
+ result = await client.combo(steps)
333
+ await _maybe_block_for_duration(client, ns, _combo_total_duration(steps))
334
+ return result
295
335
 
296
336
 
297
337
  async def cmd_release_all(client: GameClient, ns: argparse.Namespace) -> dict:
@@ -424,8 +464,18 @@ def _exit_from_wait_node(r: dict) -> int:
424
464
  # ── extra_args registrators ──
425
465
 
426
466
 
467
+ def _register_wait_flag(p: argparse.ArgumentParser) -> None:
468
+ """``--wait``:输入命令阻塞到动作时长(game-time)结束再返回(issue #90)。"""
469
+ p.add_argument(
470
+ "--wait",
471
+ action="store_true",
472
+ help="阻塞到动作时长(game-time)结束再返回,再读状态即结算后值;"
473
+ "默认异步立即返回。等价于命令后再跑一次 wait-time <时长>,但复用同一连接。",
474
+ )
475
+
476
+
427
477
  def _register_combo_args(p: argparse.ArgumentParser) -> None:
428
- """combo 的 args:``json_file`` 位置可选,加 ``--steps-json``。
478
+ """combo 的 args:``json_file`` 位置可选,加 ``--steps-json`` / ``--wait``。
429
479
  互斥校验在 ``_read_combo_steps`` 里做(argparse 里不能同时加 dest 冲突的
430
480
  位置参数和可选参数到 mutually_exclusive_group)。"""
431
481
  p.add_argument(
@@ -439,6 +489,7 @@ def _register_combo_args(p: argparse.ArgumentParser) -> None:
439
489
  default=None,
440
490
  help="直接传 JSON 字符串,不需要文件(与位置参数互斥)",
441
491
  )
492
+ _register_wait_flag(p)
442
493
 
443
494
 
444
495
  def _register_tree_args(p: argparse.ArgumentParser) -> None:
@@ -558,23 +609,25 @@ RPC_SPECS: tuple[RpcSpec, ...] = (
558
609
  RpcSpec(
559
610
  name="tap",
560
611
  handler=cmd_tap,
561
- description="短按动作(press → 等待 → release)。",
612
+ description="短按动作(press → 等待 → release)。默认异步立即返回;加 --wait 阻塞到时长结束。",
562
613
  positionals=(
563
614
  Positional("action", None, "InputMap 动作名"),
564
615
  Positional("duration", "?", "按下时长(秒),默认 0.1"),
565
616
  ),
566
617
  example="tap jump 0.2",
618
+ extra_args=_register_wait_flag,
567
619
  text_formatter=lambda r: f"tapped: {r}",
568
620
  ),
569
621
  RpcSpec(
570
622
  name="hold",
571
623
  handler=cmd_hold,
572
- description="按住动作指定时长(秒),到点自动释放。",
624
+ description="按住动作指定时长(秒),到点自动释放。默认命令立即返回(动作在游戏里持续该时长);要读动作完成后的状态请加 --wait(或命令后先 wait-time <时长>)。",
573
625
  positionals=(
574
626
  Positional("action", None, "InputMap 动作名"),
575
627
  Positional("duration", None, "按住时长(秒,必须 > 0)"),
576
628
  ),
577
629
  example="hold jump 1.5",
630
+ extra_args=_register_wait_flag,
578
631
  preflight=_preflight_hold,
579
632
  text_formatter=lambda r: f"holding: {r}",
580
633
  ),
@@ -762,12 +815,37 @@ RPC_BY_NAME: dict[str, RpcSpec] = {s.name: s for s in RPC_SPECS}
762
815
  # ── Daemon / run / init 子命令 ──
763
816
 
764
817
 
818
+ def _resolve_idle_timeout(ns: argparse.Namespace) -> int:
819
+ """解析 idle-timeout 秒数:显式 ``--idle-timeout`` > 项目级 config > 0(关闭)。
820
+
821
+ issue #44:默认值 ``"0"`` 时回退读 ``.cli_control/config.json`` 的 ``idle_timeout``,
822
+ 省得喜欢自动收尾的用户每次手敲 ``--idle-timeout``。config 也没设则仍是 0(关闭)。
823
+ 注意:显式 ``--idle-timeout 0`` 与默认 ``"0"`` 不可区分,两者都会回退 config——
824
+ config 设了又想单次强制关闭,临时 `unset` 配置即可(YAGNI,未做 opt-out flag)。
825
+ 非法 duration(CLI 或 config 任一)抛 ``ValueError``,由调用方转 EXIT_USAGE。
826
+ """
827
+ from ._duration import parse_duration
828
+
829
+ raw = getattr(ns, "idle_timeout", "0")
830
+ if raw == "0":
831
+ from .daemon import read_project_config
832
+
833
+ cfg_val = read_project_config().get("idle_timeout")
834
+ if cfg_val is not None:
835
+ try:
836
+ return parse_duration(str(cfg_val))
837
+ except ValueError as e:
838
+ raise ValueError(
839
+ f".cli_control/config.json 的 idle_timeout 非法:{e}"
840
+ ) from e
841
+ return parse_duration(raw)
842
+
843
+
765
844
  def cmd_daemon_start(ns: argparse.Namespace) -> int:
766
845
  from .daemon import Daemon, DaemonError
767
- from ._duration import parse_duration
768
846
 
769
847
  try:
770
- idle_seconds = parse_duration(getattr(ns, "idle_timeout", "0"))
848
+ idle_seconds = _resolve_idle_timeout(ns)
771
849
  except ValueError as e:
772
850
  _emit_top_error(ns, code=CLIENT_CODE_USAGE, message=str(e))
773
851
  return EXIT_USAGE
@@ -965,7 +1043,6 @@ def cmd_run(ns: argparse.Namespace) -> int:
965
1043
  64 argparse 用法错(idle_timeout 解析失败等"明显参数写错")
966
1044
  """
967
1045
  from .daemon import Daemon, DaemonError
968
- from ._duration import parse_duration
969
1046
 
970
1047
  fmt = _output_format(ns)
971
1048
 
@@ -985,7 +1062,7 @@ def cmd_run(ns: argparse.Namespace) -> int:
985
1062
  return EXIT_INFRA_ERROR
986
1063
 
987
1064
  try:
988
- idle_seconds = parse_duration(getattr(ns, "idle_timeout", "0"))
1065
+ idle_seconds = _resolve_idle_timeout(ns)
989
1066
  except ValueError as e:
990
1067
  if fmt == OUTPUT_JSON:
991
1068
  _emit_error_payload(CLIENT_CODE_USAGE, str(e))
@@ -1395,7 +1472,8 @@ def _add_daemon_flags(p: argparse.ArgumentParser) -> None:
1395
1472
  "--idle-timeout",
1396
1473
  type=str,
1397
1474
  default="0",
1398
- help="空闲超时(如 30m / 2h / 90s / 0=关闭,默认关)。开启后 Godot 端 Timer 自动 quit。",
1475
+ help="空闲超时(如 30m / 2h / 90s / 0=关闭,默认关)。开启后 Godot 端 Timer 自动 quit。"
1476
+ "不传时回退读 .cli_control/config.json 的 idle_timeout(issue #44),省得每次手敲。",
1399
1477
  )
1400
1478
 
1401
1479
 
@@ -1748,9 +1826,12 @@ async def _run_rpc(
1748
1826
  _emit_envelope_error(fmt, code, msg)
1749
1827
  return EXIT_INFRA_ERROR
1750
1828
  except (ValueError, json.JSONDecodeError) as e:
1751
- # 用法错误:combo 没文件没 inline、set value 解析失败……
1829
+ # 用法错误:set/call value JSON 解析失败等(这些命令无 preflight)。
1830
+ # 统一退 EXIT_USAGE(64),与 preflight 路径(combo/hold)一致 —— 同一客户端
1831
+ # 码 -1003 必须恒等于 exit 64,否则 agent 拿到 -1003 无法据此推断退出码
1832
+ # (issue #82,破坏「单 code 字段无歧义」契约)。
1752
1833
  _emit_envelope_error(fmt, CLIENT_CODE_USAGE, str(e))
1753
- return EXIT_INFRA_ERROR
1834
+ return EXIT_USAGE
1754
1835
  except Exception as e: # noqa: BLE001
1755
1836
  # 兜底:客户端内部 bug(AttributeError、KeyError、协议解析意外等)
1756
1837
  # 也要落进信封,否则 ``--json`` 契约对 AI agent 不再可信。把异常类
@@ -1805,9 +1886,10 @@ def main() -> None:
1805
1886
 
1806
1887
  port = ns.port
1807
1888
  if port is None:
1808
- from .daemon import Daemon
1889
+ # GameClient()/GameBridge() 共用同一发现入口(issue #91)。
1890
+ from .daemon import discover_port
1809
1891
 
1810
- port = Daemon(Path.cwd()).current_port() or DEFAULT_PORT
1892
+ port = discover_port() or DEFAULT_PORT
1811
1893
 
1812
1894
  rc = asyncio.run(_run_rpc(spec, ns, port, fmt))
1813
1895
  sys.exit(rc)
@@ -4,6 +4,7 @@ import asyncio
4
4
  import base64
5
5
  import json
6
6
  import logging
7
+ import os
7
8
  import uuid
8
9
  from typing import Any
9
10
 
@@ -23,7 +24,39 @@ DEFAULT_PORT: int = 9877
23
24
  # 改用固定大值后:正常完成时 server 自然回包;server 真死循环时由 600s 兜底
24
25
  # (而不是 55s 把还活着的录像炸掉);死连接由 websockets 库的 ping/pong 心跳
25
26
  # 在 ~40s 内独立检测,与本上限无关。
26
- LONG_OP_CLIENT_TIMEOUT: float = 600.0
27
+ LONG_OP_DEFAULT_TIMEOUT: float = 600.0
28
+
29
+
30
+ def _resolve_long_op_timeout() -> float:
31
+ """长操作生死线,可被 ``GODOT_CLI_LONG_OP_TIMEOUT`` env 覆盖(issue #69)。
32
+
33
+ 默认 ``LONG_OP_DEFAULT_TIMEOUT``(600s)。合法录像 > 10min 等少数长操作可
34
+ 设环境变量调高(如 ``GODOT_CLI_LONG_OP_TIMEOUT=1800``)。非正数 / 非数字一律
35
+ 忽略并回退默认——这是个兜底生死线,绝不能因 env 写错被设成 0 把正常操作秒杀。
36
+ """
37
+ raw = os.environ.get("GODOT_CLI_LONG_OP_TIMEOUT")
38
+ if not raw:
39
+ return LONG_OP_DEFAULT_TIMEOUT
40
+ try:
41
+ value = float(raw)
42
+ except ValueError:
43
+ logger.warning(
44
+ "GODOT_CLI_LONG_OP_TIMEOUT=%r 不是数字,回退默认 %.0fs",
45
+ raw, LONG_OP_DEFAULT_TIMEOUT,
46
+ )
47
+ return LONG_OP_DEFAULT_TIMEOUT
48
+ if value <= 0:
49
+ logger.warning(
50
+ "GODOT_CLI_LONG_OP_TIMEOUT=%r 必须 > 0,回退默认 %.0fs",
51
+ raw, LONG_OP_DEFAULT_TIMEOUT,
52
+ )
53
+ return LONG_OP_DEFAULT_TIMEOUT
54
+ return value
55
+
56
+
57
+ # 模块级解析一次:进程生命周期内 env 不变;测试需要时改 env 后调
58
+ # ``_resolve_long_op_timeout()`` 拿新值,或直接给 request() 传 timeout。
59
+ LONG_OP_CLIENT_TIMEOUT: float = _resolve_long_op_timeout()
27
60
 
28
61
 
29
62
  class RpcError(RuntimeError):
@@ -43,7 +76,17 @@ class RpcError(RuntimeError):
43
76
  class GameClient:
44
77
  """WebSocket client that connects to Godot's GameBridge service."""
45
78
 
46
- def __init__(self, port: int = DEFAULT_PORT) -> None:
79
+ def __init__(self, port: int | None = None) -> None:
80
+ # port=None(无显式端口)→ 从 .cli_control/port auto-discover,找不到再回退
81
+ # DEFAULT_PORT。daemon 默认 OS 自动分配端口,所以照 README 写
82
+ # ``GameClient()`` 单连接脚本的用户/agent 必须经由这条发现路径才能连上
83
+ # (issue #91)。发现逻辑收敛在 daemon.discover_port,CLI 走同一入口。
84
+ # 延迟 import 避免 client 在 package import 时拉起 daemon→registry 这条
85
+ # 较重的依赖链。
86
+ if port is None:
87
+ from .daemon import discover_port
88
+
89
+ port = discover_port() or DEFAULT_PORT
47
90
  self._port = port
48
91
  self._ws: ClientConnection | None = None
49
92
  self._pending: dict[str, asyncio.Future[dict]] = {}
@@ -279,17 +322,18 @@ class GameClient:
279
322
  async def wait_game_time(self, seconds: float) -> dict:
280
323
  """按 Godot game time 等待 N 秒。
281
324
 
282
- 客户端用固定 ``LONG_OP_CLIENT_TIMEOUT`` 生死线(issue #45):game-time
283
- wall-time 比值受录像模式 / 分辨率 / 盘速 / fps 影响不可预测,任何
284
- seconds-scaled 公式都会在某个组合下假超时。死连接由 ws ping/pong 兜底。
285
- seconds <= 0 时客户端短路返回,不发 RPC。
325
+ 客户端用固定的长操作生死线(issue #45):game-time 与 wall-time 比值受
326
+ 录像模式 / 分辨率 / 盘速 / fps 影响不可预测,任何 seconds-scaled 公式都会
327
+ 在某个组合下假超时。死连接由 ws ping/pong 兜底。生死线默认 600s,可被
328
+ ``GODOT_CLI_LONG_OP_TIMEOUT`` env 覆盖(issue #69)——每次调用现取,
329
+ 让 env 改动即时生效。seconds <= 0 时客户端短路返回,不发 RPC。
286
330
  """
287
331
  if seconds <= 0:
288
332
  return {"success": True}
289
333
  return await self.request(
290
334
  "wait_game_time",
291
335
  {"seconds": seconds},
292
- timeout=LONG_OP_CLIENT_TIMEOUT,
336
+ timeout=_resolve_long_op_timeout(),
293
337
  )
294
338
 
295
339
  # ---- Input simulation API ----
@@ -311,10 +355,11 @@ class GameClient:
311
355
  )
312
356
 
313
357
  async def combo(self, steps: list[dict]) -> dict:
314
- # 客户端用固定 LONG_OP_CLIENT_TIMEOUT 生死线(issue #45):见
315
- # wait_game_time 注释——同样的 game-time / wall-time 不对齐问题。
358
+ # 长操作生死线(issue #45):见 wait_game_time 注释——同样的 game-time /
359
+ # wall-time 不对齐问题。默认 600s,可被 GODOT_CLI_LONG_OP_TIMEOUT 覆盖
360
+ # (issue #69),每次调用现取。
316
361
  return await self.request(
317
- "input_combo", {"steps": steps}, timeout=LONG_OP_CLIENT_TIMEOUT
362
+ "input_combo", {"steps": steps}, timeout=_resolve_long_op_timeout()
318
363
  )
319
364
 
320
365
  async def combo_cancel(self) -> dict: