godot-cli-control 0.2.11__tar.gz → 0.2.13__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 (61) hide show
  1. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/PKG-INFO +2 -1
  2. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/addons/godot_cli_control/README.md +38 -5
  3. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/pyproject.toml +23 -4
  4. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/python/godot_cli_control/_version.py +2 -2
  5. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/python/godot_cli_control/bridge.py +5 -2
  6. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/python/godot_cli_control/cli.py +107 -16
  7. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/python/godot_cli_control/client.py +55 -10
  8. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/python/godot_cli_control/daemon.py +41 -0
  9. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/python/godot_cli_control/init_cmd.py +88 -0
  10. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/python/godot_cli_control/templates/skill/SKILL.md +15 -9
  11. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/python/tests/test_cli.py +20 -12
  12. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/python/tests/test_cli_helpers.py +0 -1
  13. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/python/tests/test_client.py +5 -5
  14. godot_cli_control-0.2.13/python/tests/test_e2e_client_direct.py +217 -0
  15. godot_cli_control-0.2.13/python/tests/test_e2e_example.py +123 -0
  16. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/python/tests/test_e2e_input.py +8 -5
  17. godot_cli_control-0.2.13/python/tests/test_e2e_record.py +198 -0
  18. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/python/tests/test_e2e_screenshot_gui.py +8 -4
  19. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/python/tests/test_init.py +183 -0
  20. godot_cli_control-0.2.13/python/tests/test_issue_fixes.py +292 -0
  21. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/python/tests/test_pytest_plugin.py +15 -0
  22. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/python/tests/test_registry.py +8 -4
  23. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/python/tests/test_runner.py +0 -1
  24. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/python/tests/test_skills_install.py +0 -1
  25. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/.gitignore +0 -0
  26. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/LICENSE +0 -0
  27. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/addons/godot_cli_control/CHANGELOG.md +0 -0
  28. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/addons/godot_cli_control/LICENSE +0 -0
  29. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/addons/godot_cli_control/bin/run_cli_control.ps1 +0 -0
  30. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/addons/godot_cli_control/bin/run_cli_control.sh +0 -0
  31. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/addons/godot_cli_control/bridge/error_codes.gd +0 -0
  32. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/addons/godot_cli_control/bridge/game_bridge.gd +0 -0
  33. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/addons/godot_cli_control/bridge/game_bridge.gd.uid +0 -0
  34. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/addons/godot_cli_control/bridge/input_simulation_api.gd +0 -0
  35. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/addons/godot_cli_control/bridge/input_simulation_api.gd.uid +0 -0
  36. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/addons/godot_cli_control/bridge/low_level_api.gd +0 -0
  37. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/addons/godot_cli_control/bridge/low_level_api.gd.uid +0 -0
  38. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/addons/godot_cli_control/plugin.cfg +0 -0
  39. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/addons/godot_cli_control/plugin.gd +0 -0
  40. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/addons/godot_cli_control/plugin.gd.uid +0 -0
  41. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/addons/godot_cli_control/tests/gut/test_game_bridge.gd +0 -0
  42. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/addons/godot_cli_control/tests/gut/test_input_simulation_api.gd +0 -0
  43. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/addons/godot_cli_control/tests/gut/test_low_level_api.gd +0 -0
  44. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/addons/godot_cli_control/tests/run_gut.py +0 -0
  45. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/addons/godot_cli_control/tests/run_gut.sh +0 -0
  46. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/addons/godot_cli_control/tests/test_init_walkthrough.py +0 -0
  47. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/addons/godot_cli_control/tests/test_walkthrough.sh +0 -0
  48. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/python/README.md +0 -0
  49. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/python/godot_cli_control/__init__.py +0 -0
  50. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/python/godot_cli_control/__main__.py +0 -0
  51. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/python/godot_cli_control/_duration.py +0 -0
  52. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/python/godot_cli_control/pytest_plugin.py +0 -0
  53. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/python/godot_cli_control/registry.py +0 -0
  54. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/python/godot_cli_control/runner.py +0 -0
  55. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/python/godot_cli_control/skills_install.py +0 -0
  56. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/python/godot_cli_control/templates/__init__.py +0 -0
  57. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/python/godot_cli_control/templates/skill/__init__.py +0 -0
  58. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/python/tests/__init__.py +0 -0
  59. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/python/tests/test_bridge.py +0 -0
  60. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/python/tests/test_daemon.py +0 -0
  61. {godot_cli_control-0.2.11 → godot_cli_control-0.2.13}/python/tests/test_duration.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: godot-cli-control
3
- Version: 0.2.11
3
+ Version: 0.2.13
4
4
  Summary: WebSocket bridge for headless / scripted control of Godot scenes.
5
5
  Author: kesar
6
6
  License: MIT
@@ -13,6 +13,7 @@ Provides-Extra: test
13
13
  Requires-Dist: pytest-asyncio>=0.21; extra == 'test'
14
14
  Requires-Dist: pytest-cov>=4; extra == 'test'
15
15
  Requires-Dist: pytest>=7; extra == 'test'
16
+ Requires-Dist: ruff>=0.6; extra == 'test'
16
17
  Description-Content-Type: text/markdown
17
18
 
18
19
  # godot-cli-control
@@ -9,13 +9,13 @@ If you have the Python CLI installed (`pipx install godot-cli-control`), the ent
9
9
 
10
10
  ```bash
11
11
  cd your_godot_project
12
- godot-cli-control init # copy plugin, patch project.godot, detect Godot
12
+ godot-cli-control init # copy plugin, patch project.godot, detect Godot, gitignore .cli_control/
13
13
  godot-cli-control daemon start
14
14
  godot-cli-control tree 3
15
15
  godot-cli-control daemon stop
16
16
  ```
17
17
 
18
- `init` automates everything described in the manual setup below — copying the addon, editing `project.godot`, and detecting your Godot binary.
18
+ `init` automates everything described in the manual setup below — copying the addon, editing `project.godot`, and detecting your Godot binary. It also appends `.cli_control/` to the project's root `.gitignore` (the daemon's machine-local state dir: detected binary path, pid, port, log, recording intermediates), so that state never gets committed. Pass `--no-gitignore` to skip that step.
19
19
 
20
20
  ## Manual Setup (if you prefer)
21
21
 
@@ -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.
@@ -12,9 +12,9 @@ dependencies = [
12
12
 
13
13
  [project.optional-dependencies]
14
14
  pytest = ["pytest>=7"]
15
- # 全套测试依赖 —— 仅 CI / 开发者本地测试用,包含 pytest-covpytest-asyncio
16
- # 普通用户用 `[pytest]` 已够;不要把 cov 拖到默认安装路径。
17
- test = ["pytest>=7", "pytest-asyncio>=0.21", "pytest-cov>=4"]
15
+ # 全套测试依赖 —— 仅 CI / 开发者本地测试用,包含 pytest-covpytest-asyncio
16
+ # ruff(lint 门禁,issue #83)。普通用户用 `[pytest]` 已够;不要把这些拖到默认安装路径。
17
+ test = ["pytest>=7", "pytest-asyncio>=0.21", "pytest-cov>=4", "ruff>=0.6"]
18
18
 
19
19
  [project.scripts]
20
20
  godot-cli-control = "godot_cli_control.cli:main"
@@ -23,14 +23,33 @@ godot-cli-control = "godot_cli_control.cli:main"
23
23
  godot-cli-control = "godot_cli_control.pytest_plugin"
24
24
 
25
25
  [tool.pytest.ini_options]
26
+ # pytest-asyncio:显式钉死 async fixture 的 loop scope 为 function(= 未来版本默认值),
27
+ # 消除 "asyncio_default_fixture_loop_scope is unset" 的 deprecation warning。改这行前
28
+ # 先确认没有 module/session-scope 的 async fixture 依赖跨用例共享 loop。
29
+ asyncio_default_fixture_loop_scope = "function"
26
30
  # gui:需要真实显示(开窗)才能跑的端到端测试。普通 unit / headless e2e 不带此
27
- # marker。本地默认随 GODOT_BIN / GCC_GUI_E2E 未设而 skip;CI 专档(Linux 套
31
+ # marker。本地默认随 godot 检测 / GCC_GUI_E2E 未设而 skip;CI 专档(Linux 套
28
32
  # xvfb 或 macOS runner)设 GCC_GUI_E2E=1 开启。注册在此避免 unknown-marker 警告,
29
33
  # 也让 `pytest -m "not gui"` 能稳定剔除。
30
34
  markers = [
31
35
  "gui: 需要真实显示的 windowed e2e(screenshot frame_post_draw 路径,issue #64)",
32
36
  ]
33
37
 
38
+ # ── lint(ruff)─────────────────────────────────────────────────────────
39
+ # issue #83:把 lint 从「开发者本机全局 ruff」收编成 CI 门禁 + dev 依赖,趁全仓
40
+ # 基线为 0 锁定收益。规则集刻意只钉 ruff 默认的 pyflakes(F) + pycodestyle 子集
41
+ # (E4/E7/E9)——这正是当前 0-error 基线对应的集合;扩到 E501/I/UP/B 会一次性炸出
42
+ # 几十条历史报错,与「锁定基线、零噪音引入门禁」的目标相悖。要加规则请单开 PR、
43
+ # 连同 autofix 一起 land,别让门禁第一天就红。line-length 仅供 `ruff format`
44
+ # 参考(E501 未纳入 select,不构成门禁)。
45
+ [tool.ruff]
46
+ target-version = "py310"
47
+ line-length = 100
48
+ extend-exclude = ["python/godot_cli_control/_version.py"] # hatch-vcs 生成物
49
+
50
+ [tool.ruff.lint]
51
+ select = ["E4", "E7", "E9", "F"]
52
+
34
53
  [build-system]
35
54
  requires = ["hatchling", "hatch-vcs"]
36
55
  build-backend = "hatchling.build"
@@ -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.11'
22
- __version_tuple__ = version_tuple = (0, 2, 11)
21
+ __version__ = version = '0.2.13'
22
+ __version_tuple__ = version_tuple = (0, 2, 13)
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))
@@ -1264,6 +1341,7 @@ def cmd_init(ns: argparse.Namespace) -> int:
1264
1341
  write_skills=not ns.no_skills,
1265
1342
  skills_only=ns.skills_only,
1266
1343
  clobber_skills=not ns.skills_no_clobber,
1344
+ write_gitignore=not ns.no_gitignore,
1267
1345
  output_format=fmt,
1268
1346
  result=result,
1269
1347
  )
@@ -1394,7 +1472,8 @@ def _add_daemon_flags(p: argparse.ArgumentParser) -> None:
1394
1472
  "--idle-timeout",
1395
1473
  type=str,
1396
1474
  default="0",
1397
- 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),省得每次手敲。",
1398
1477
  )
1399
1478
 
1400
1479
 
@@ -1569,7 +1648,7 @@ def build_parser() -> argparse.ArgumentParser:
1569
1648
  help="在 Godot 项目根一键接入插件",
1570
1649
  description=(
1571
1650
  "复制 addons/godot_cli_control 到目标项目、patch project.godot 启用插件、"
1572
- "校验 GODOT_BIN"
1651
+ "校验 GODOT_BIN、在 .gitignore 忽略 .cli_control/。"
1573
1652
  ),
1574
1653
  epilog=(
1575
1654
  "GODOT_BIN 查找顺序:\n"
@@ -1601,6 +1680,14 @@ def build_parser() -> argparse.ArgumentParser:
1601
1680
  "保证版本与 CLI 帮助同步)。与 --no-skills / --skills-only 都兼容。"
1602
1681
  ),
1603
1682
  )
1683
+ init_p.add_argument(
1684
+ "--no-gitignore",
1685
+ action="store_true",
1686
+ help=(
1687
+ "跳过往项目根 .gitignore 追加 .cli_control/(默认会追加,"
1688
+ "忽略 daemon 的机器本地状态目录)。--skills-only 模式下本就跳过。"
1689
+ ),
1690
+ )
1604
1691
  skills_group = init_p.add_mutually_exclusive_group()
1605
1692
  skills_group.add_argument(
1606
1693
  "--no-skills",
@@ -1739,9 +1826,12 @@ async def _run_rpc(
1739
1826
  _emit_envelope_error(fmt, code, msg)
1740
1827
  return EXIT_INFRA_ERROR
1741
1828
  except (ValueError, json.JSONDecodeError) as e:
1742
- # 用法错误: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 字段无歧义」契约)。
1743
1833
  _emit_envelope_error(fmt, CLIENT_CODE_USAGE, str(e))
1744
- return EXIT_INFRA_ERROR
1834
+ return EXIT_USAGE
1745
1835
  except Exception as e: # noqa: BLE001
1746
1836
  # 兜底:客户端内部 bug(AttributeError、KeyError、协议解析意外等)
1747
1837
  # 也要落进信封,否则 ``--json`` 契约对 AI agent 不再可信。把异常类
@@ -1796,9 +1886,10 @@ def main() -> None:
1796
1886
 
1797
1887
  port = ns.port
1798
1888
  if port is None:
1799
- from .daemon import Daemon
1889
+ # GameClient()/GameBridge() 共用同一发现入口(issue #91)。
1890
+ from .daemon import discover_port
1800
1891
 
1801
- port = Daemon(Path.cwd()).current_port() or DEFAULT_PORT
1892
+ port = discover_port() or DEFAULT_PORT
1802
1893
 
1803
1894
  rc = asyncio.run(_run_rpc(spec, ns, port, fmt))
1804
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:
@@ -5,6 +5,7 @@
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
+ import json
8
9
  import os
9
10
  import shutil
10
11
  import signal
@@ -363,6 +364,46 @@ class Daemon:
363
364
  _reap_if_dead(pid) # 回收被 SIGKILL 的 zombie 子进程(若它是本进程的孩子)
364
365
 
365
366
 
367
+ # ── 项目级配置 ──
368
+
369
+
370
+ def read_project_config(project_root: Path | None = None) -> dict:
371
+ """读取项目级 ``<project_root>/.cli_control/config.json``(issue #44)。
372
+
373
+ 存放无需每次手敲的默认值,目前支持 ``idle_timeout``(如 ``{"idle_timeout":"30m"}``)。
374
+ ``project_root`` 默认 ``Path.cwd()``。缺文件 / 非法 JSON / 顶层非对象一律返回
375
+ ``{}``——配置坏掉绝不能让 daemon 起不来,缺省即回到「无配置」行为。
376
+ """
377
+ root = Path(project_root) if project_root is not None else Path.cwd()
378
+ path = root / ".cli_control" / "config.json"
379
+ if not path.exists():
380
+ return {}
381
+ try:
382
+ data = json.loads(path.read_text(encoding="utf-8"))
383
+ except (ValueError, OSError):
384
+ return {}
385
+ return data if isinstance(data, dict) else {}
386
+
387
+
388
+ # ── 端口发现 ──
389
+
390
+
391
+ def discover_port(project_root: Path | None = None) -> int | None:
392
+ """从 ``<project_root>/.cli_control/port`` 读取 daemon 当前监听端口。
393
+
394
+ daemon 默认以 OS 自动分配端口启动(写入 ``.cli_control/port``),所以"无显式
395
+ 端口"的连接方都得先来这里发现实际端口,再回退 ``DEFAULT_PORT``。这是 CLI RPC
396
+ 子命令、``GameClient()`` 与 ``GameBridge()`` 三处无显式 port 时的**唯一共用**
397
+ 发现入口(issue #91)——与 README 承诺的 "auto-discover from ``.cli_control/port``"
398
+ 对齐。
399
+
400
+ ``project_root`` 默认 ``Path.cwd()``(与 CLI 的工作目录约定一致)。无 port 文件
401
+ 或解析失败时返回 ``None``,由调用方决定回退值。
402
+ """
403
+ root = Path(project_root) if project_root is not None else Path.cwd()
404
+ return Daemon(root).current_port()
405
+
406
+
366
407
  # ── Godot 二进制发现 ──
367
408
 
368
409