godot-cli-control 0.2.10__tar.gz → 0.2.12__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 (57) hide show
  1. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/PKG-INFO +3 -2
  2. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/addons/godot_cli_control/README.md +2 -2
  3. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/python/README.md +2 -1
  4. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/python/godot_cli_control/_version.py +2 -2
  5. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/python/godot_cli_control/cli.py +21 -3
  6. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/python/godot_cli_control/daemon.py +11 -0
  7. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/python/godot_cli_control/init_cmd.py +88 -0
  8. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/python/godot_cli_control/templates/skill/SKILL.md +3 -2
  9. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/python/tests/test_cli.py +66 -0
  10. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/python/tests/test_daemon.py +20 -0
  11. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/python/tests/test_init.py +183 -0
  12. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/.gitignore +0 -0
  13. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/LICENSE +0 -0
  14. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/addons/godot_cli_control/CHANGELOG.md +0 -0
  15. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/addons/godot_cli_control/LICENSE +0 -0
  16. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/addons/godot_cli_control/bin/run_cli_control.ps1 +0 -0
  17. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/addons/godot_cli_control/bin/run_cli_control.sh +0 -0
  18. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/addons/godot_cli_control/bridge/error_codes.gd +0 -0
  19. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/addons/godot_cli_control/bridge/game_bridge.gd +0 -0
  20. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/addons/godot_cli_control/bridge/game_bridge.gd.uid +0 -0
  21. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/addons/godot_cli_control/bridge/input_simulation_api.gd +0 -0
  22. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/addons/godot_cli_control/bridge/input_simulation_api.gd.uid +0 -0
  23. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/addons/godot_cli_control/bridge/low_level_api.gd +0 -0
  24. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/addons/godot_cli_control/bridge/low_level_api.gd.uid +0 -0
  25. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/addons/godot_cli_control/plugin.cfg +0 -0
  26. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/addons/godot_cli_control/plugin.gd +0 -0
  27. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/addons/godot_cli_control/plugin.gd.uid +0 -0
  28. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/addons/godot_cli_control/tests/gut/test_game_bridge.gd +0 -0
  29. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/addons/godot_cli_control/tests/gut/test_input_simulation_api.gd +0 -0
  30. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/addons/godot_cli_control/tests/gut/test_low_level_api.gd +0 -0
  31. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/addons/godot_cli_control/tests/run_gut.py +0 -0
  32. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/addons/godot_cli_control/tests/run_gut.sh +0 -0
  33. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/addons/godot_cli_control/tests/test_init_walkthrough.py +0 -0
  34. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/addons/godot_cli_control/tests/test_walkthrough.sh +0 -0
  35. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/pyproject.toml +0 -0
  36. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/python/godot_cli_control/__init__.py +0 -0
  37. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/python/godot_cli_control/__main__.py +0 -0
  38. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/python/godot_cli_control/_duration.py +0 -0
  39. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/python/godot_cli_control/bridge.py +0 -0
  40. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/python/godot_cli_control/client.py +0 -0
  41. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/python/godot_cli_control/pytest_plugin.py +0 -0
  42. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/python/godot_cli_control/registry.py +0 -0
  43. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/python/godot_cli_control/runner.py +0 -0
  44. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/python/godot_cli_control/skills_install.py +0 -0
  45. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/python/godot_cli_control/templates/__init__.py +0 -0
  46. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/python/godot_cli_control/templates/skill/__init__.py +0 -0
  47. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/python/tests/__init__.py +0 -0
  48. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/python/tests/test_bridge.py +0 -0
  49. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/python/tests/test_cli_helpers.py +0 -0
  50. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/python/tests/test_client.py +0 -0
  51. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/python/tests/test_duration.py +0 -0
  52. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/python/tests/test_e2e_input.py +0 -0
  53. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/python/tests/test_e2e_screenshot_gui.py +0 -0
  54. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/python/tests/test_pytest_plugin.py +0 -0
  55. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/python/tests/test_registry.py +0 -0
  56. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/python/tests/test_runner.py +0 -0
  57. {godot_cli_control-0.2.10 → godot_cli_control-0.2.12}/python/tests/test_skills_install.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: godot-cli-control
3
- Version: 0.2.10
3
+ Version: 0.2.12
4
4
  Summary: WebSocket bridge for headless / scripted control of Godot scenes.
5
5
  Author: kesar
6
6
  License: MIT
@@ -114,7 +114,8 @@ The CLI is the canonical surface — every `GameClient` method has a one-line eq
114
114
  ```bash
115
115
  # Lifecycle
116
116
  godot-cli-control init [--path DIR] [--force]
117
- godot-cli-control daemon start [--headless --record --movie-path X --fps N --port N --idle-timeout 30m]
117
+ godot-cli-control daemon start [--headless | --gui] [--port N --idle-timeout 30m]
118
+ godot-cli-control daemon start --record --movie-path X [--fps N] # 录制需真实渲染器,不能与 --headless 同用
118
119
  godot-cli-control daemon stop [--all | --project PATH]
119
120
  godot-cli-control daemon status
120
121
  godot-cli-control daemon ls # list running daemons across all projects
@@ -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
 
@@ -97,7 +97,8 @@ The CLI is the canonical surface — every `GameClient` method has a one-line eq
97
97
  ```bash
98
98
  # Lifecycle
99
99
  godot-cli-control init [--path DIR] [--force]
100
- godot-cli-control daemon start [--headless --record --movie-path X --fps N --port N --idle-timeout 30m]
100
+ godot-cli-control daemon start [--headless | --gui] [--port N --idle-timeout 30m]
101
+ godot-cli-control daemon start --record --movie-path X [--fps N] # 录制需真实渲染器,不能与 --headless 同用
101
102
  godot-cli-control daemon stop [--all | --project PATH]
102
103
  godot-cli-control daemon status
103
104
  godot-cli-control daemon ls # list running daemons across all projects
@@ -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.10'
22
- __version_tuple__ = version_tuple = (0, 2, 10)
21
+ __version__ = version = '0.2.12'
22
+ __version_tuple__ = version_tuple = (0, 2, 12)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -73,11 +73,18 @@ def _resolve_headless(
73
73
  True,让没显式指定 ``--headless`` / ``--gui`` 的非 TTY 场景(subagent / pipe)
74
74
  自动改走 GUI —— headless 下 dummy renderer 拿不到 viewport texture,screenshot
75
75
  永远 1006 fail(issue #65)。
76
+
77
+ ``--record`` 同理且更狠:Movie Maker 的 ``add_frame()`` 读的也是 viewport
78
+ texture,headless 下拿到 null 直接 SIGSEGV(CultivationWorld #180)。所以没显式
79
+ ``--headless`` 时一律翻成 GUI;显式 ``--headless`` 仍返回 True,由
80
+ ``daemon.start`` 的 preflight 拒绝(明确用法错,不静默改写用户意图)。
76
81
  """
77
82
  if getattr(ns, "headless", False):
78
83
  return True
79
84
  if getattr(ns, "gui", False):
80
85
  return False
86
+ if getattr(ns, "record", False):
87
+ return False
81
88
  if force_gui_hint:
82
89
  return False
83
90
  try:
@@ -1257,6 +1264,7 @@ def cmd_init(ns: argparse.Namespace) -> int:
1257
1264
  write_skills=not ns.no_skills,
1258
1265
  skills_only=ns.skills_only,
1259
1266
  clobber_skills=not ns.skills_no_clobber,
1267
+ write_gitignore=not ns.no_gitignore,
1260
1268
  output_format=fmt,
1261
1269
  result=result,
1262
1270
  )
@@ -1356,7 +1364,8 @@ def _add_daemon_flags(p: argparse.ArgumentParser) -> None:
1356
1364
  p.add_argument(
1357
1365
  "--record",
1358
1366
  action="store_true",
1359
- help="启动后录制 demo(写到 .cli_control/movie_path",
1367
+ help="启动后录制 demo(写到 .cli_control/movie_path)。需真实渲染器,"
1368
+ "不能与 --headless 同用;没指定时会自动开窗(即使非 TTY)。",
1360
1369
  )
1361
1370
  p.add_argument(
1362
1371
  "--movie-path",
@@ -1367,7 +1376,8 @@ def _add_daemon_flags(p: argparse.ArgumentParser) -> None:
1367
1376
  headless_grp.add_argument(
1368
1377
  "--headless",
1369
1378
  action="store_true",
1370
- help="无窗口模式。默认值:stdout 非 TTY 时自动 headless(CI / pipe / agent)。",
1379
+ help="无窗口模式。默认值:stdout 非 TTY 时自动 headless(CI / pipe / agent)。"
1380
+ "与 --record 互斥(录制需真实渲染器)。",
1371
1381
  )
1372
1382
  headless_grp.add_argument(
1373
1383
  "--gui",
@@ -1560,7 +1570,7 @@ def build_parser() -> argparse.ArgumentParser:
1560
1570
  help="在 Godot 项目根一键接入插件",
1561
1571
  description=(
1562
1572
  "复制 addons/godot_cli_control 到目标项目、patch project.godot 启用插件、"
1563
- "校验 GODOT_BIN"
1573
+ "校验 GODOT_BIN、在 .gitignore 忽略 .cli_control/。"
1564
1574
  ),
1565
1575
  epilog=(
1566
1576
  "GODOT_BIN 查找顺序:\n"
@@ -1592,6 +1602,14 @@ def build_parser() -> argparse.ArgumentParser:
1592
1602
  "保证版本与 CLI 帮助同步)。与 --no-skills / --skills-only 都兼容。"
1593
1603
  ),
1594
1604
  )
1605
+ init_p.add_argument(
1606
+ "--no-gitignore",
1607
+ action="store_true",
1608
+ help=(
1609
+ "跳过往项目根 .gitignore 追加 .cli_control/(默认会追加,"
1610
+ "忽略 daemon 的机器本地状态目录)。--skills-only 模式下本就跳过。"
1611
+ ),
1612
+ )
1595
1613
  skills_group = init_p.add_mutually_exclusive_group()
1596
1614
  skills_group.add_argument(
1597
1615
  "--no-skills",
@@ -93,6 +93,17 @@ class Daemon:
93
93
  )
94
94
  if record and not movie_path:
95
95
  raise DaemonError("--record requires --movie-path")
96
+ # 录制无法在 headless 下跑:Godot Movie Maker 的 add_frame() 读 viewport
97
+ # texture,headless dummy renderer 拿到 null 直接 SIGSEGV(首帧崩,
98
+ # CultivationWorld #180)。CLI 侧 _resolve_headless 已把「没显式 --headless
99
+ # 的 record」翻成 GUI,所以走到这里还 headless=True 的只可能是用户显式
100
+ # --headless —— 这是用法矛盾,spawn 前拒绝,别让 Godot 段错误。
101
+ if record and headless:
102
+ raise DaemonError(
103
+ "录制与 --headless 冲突:Godot Movie Maker 需要真实渲染器,"
104
+ "headless dummy renderer 拿不到 viewport texture,add_frame() 会"
105
+ " SIGSEGV。去掉 --headless(默认/非 TTY 会自动开窗)或显式 --gui。"
106
+ )
96
107
 
97
108
  bin_path = (
98
109
  godot_bin
@@ -8,6 +8,8 @@
8
8
  绕过 Godot Editor GUI 启用步骤
9
9
  4. 自动检测 Godot 二进制,写入 ``.cli_control/godot_bin``,daemon 启动时
10
10
  会优先读取此文件
11
+ 5. 在项目根 ``.gitignore`` 追加 ``.cli_control/`` —— daemon 的机器本地状态
12
+ 目录(godot_bin 绝对路径 / pid / port / log / 录制中间产物),不应提交
11
13
 
12
14
  完成后用户只需 ``godot-cli-control daemon start`` 即可。
13
15
  """
@@ -26,6 +28,12 @@ from .daemon import find_godot_binary, reimport_project
26
28
  PLUGIN_DIR_NAME = "godot_cli_control"
27
29
  ADDONS_DIRNAME = "addons"
28
30
 
31
+ GITIGNORE_NAME = ".gitignore"
32
+ # 需要 init 写进目标项目 .gitignore 的条目。用元组而非单值,给将来"增量加
33
+ # 忽略项"留口子(与 method_blacklist_extra 同样的增量哲学);目前只有 daemon
34
+ # 的机器本地状态目录 .cli_control/。
35
+ GITIGNORE_ENTRIES: tuple[str, ...] = (".cli_control/",)
36
+
29
37
  AUTOLOAD_KEY = "GameBridgeNode"
30
38
  AUTOLOAD_VALUE = '"*res://addons/godot_cli_control/bridge/game_bridge.gd"'
31
39
  PLUGIN_CFG_PATH = "res://addons/godot_cli_control/plugin.cfg"
@@ -43,6 +51,7 @@ def run_init(
43
51
  write_skills: bool = True,
44
52
  skills_only: bool = False,
45
53
  clobber_skills: bool = True,
54
+ write_gitignore: bool = True,
46
55
  *,
47
56
  output_format: str = "text",
48
57
  result: dict[str, Any] | None = None,
@@ -56,6 +65,9 @@ def run_init(
56
65
  ``clobber_skills=False``:写 skill 时遇到已存在文件就跳过(用户改过
57
66
  SKILL.md、希望保留本地版又允许 init 把缺失那条补上时用)。与
58
67
  ``write_skills=False`` / ``skills_only=True`` 都兼容。
68
+ ``write_gitignore=False``:跳过往项目根 ``.gitignore`` 追加 ``.cli_control/``
69
+ (不想让 init 碰 ``.gitignore`` 的用户逃生口)。该步骤属于"项目接入"阶段,
70
+ ``skills_only=True`` 时本就跳过。
59
71
 
60
72
  ``output_format='json'``:抑制全部人类可读 print;调用方(cli.cmd_init)
61
73
  传入 ``result`` 字典,本函数会回填结构化字段,由 cli 侧封 JSON envelope。
@@ -93,6 +105,7 @@ def run_init(
93
105
  project_godot_changes=[],
94
106
  godot_bin=None,
95
107
  skills_written=[],
108
+ gitignore_added=[],
96
109
  )
97
110
 
98
111
  if not (project_root / "project.godot").is_file():
@@ -157,6 +170,15 @@ def run_init(
157
170
  "(跳过资源重新导入;首次 daemon start 会兜底重建 cache)"
158
171
  )
159
172
 
173
+ # .gitignore:无条件确保(不依赖 godot_bin 是否检测到——daemon 早晚
174
+ # 会建 .cli_control/,提前忽略最稳)。属于"项目接入"阶段,故在
175
+ # not skills_only 块内。
176
+ if write_gitignore:
177
+ added = _ensure_gitignore_entries(project_root)
178
+ if added:
179
+ _say(f"修改 .gitignore:忽略 {', '.join(added)}")
180
+ _record(gitignore_added=added)
181
+
160
182
  if write_skills:
161
183
  # lazy import:保持与 cli.cmd_init 那侧 `from .init_cmd import run_init`
162
184
  # 对称,两边都不在模块顶层 import,避免任一方将来改成顶层 import
@@ -319,3 +341,69 @@ def _ensure_in_packed_array(
319
341
  body.rstrip("\n") + "\n" + f'{key}=PackedStringArray("{value}")\n'
320
342
  )
321
343
  return text[:start] + new_body + text[end:], True
344
+
345
+
346
+ # ── .gitignore 维护 ──
347
+
348
+
349
+ def _gitignore_has_entry(text: str, entry: str) -> bool:
350
+ """``.gitignore`` 文本里是否已忽略 ``entry``。
351
+
352
+ 宽松匹配:忽略前导 ``/`` 与尾随 ``/``、行首尾空白,所以 ``.cli_control``、
353
+ ``.cli_control/``、``/.cli_control/`` 视为同一条,不重复加。注释行(``#`` 开头)
354
+ 不算数 —— 用户把它注释掉就是想让它失效,真条目仍要补。
355
+ """
356
+ target = entry.strip().strip("/")
357
+ for line in text.splitlines():
358
+ stripped = line.strip()
359
+ if not stripped or stripped.startswith("#"):
360
+ continue
361
+ if stripped.strip("/") == target:
362
+ return True
363
+ return False
364
+
365
+
366
+ def _add_gitignore_entries(
367
+ text: str, entries: tuple[str, ...] | list[str]
368
+ ) -> tuple[str, list[str]]:
369
+ """纯函数:把缺失的 ``entries`` 追加到 ``.gitignore`` 文本末尾。
370
+
371
+ 返回 ``(新文本, 实际新增的条目列表)``。已存在的条目跳过。追加前确保末尾
372
+ 有换行,避免把新条目黏到原末行后面;空文本不留前导空行。行尾统一用
373
+ ``\\n``,CRLF 回写交给 IO 包装层(与 ``_patch_project_godot`` 同构)。
374
+ """
375
+ added: list[str] = []
376
+ out = text
377
+ for entry in entries:
378
+ if _gitignore_has_entry(out, entry):
379
+ continue
380
+ if out and not out.endswith("\n"):
381
+ out += "\n"
382
+ out += entry + "\n"
383
+ added.append(entry)
384
+ return out, added
385
+
386
+
387
+ def _ensure_gitignore_entries(
388
+ project_root: Path, entries: tuple[str, ...] = GITIGNORE_ENTRIES
389
+ ) -> list[str]:
390
+ """确保项目根 ``.gitignore`` 忽略 ``entries``;返回实际新增的条目。
391
+
392
+ 文件不存在则创建(LF)。已存在则沿用 ``_patch_project_godot`` 的 bytes+CRLF
393
+ 思路:读 bytes、探测原文是否 CRLF、解码时折成 LF 便于匹配,仅在确有新增时
394
+ 回写并把行尾翻回原样 —— 保 Windows 上 git diff 不被整文件行尾翻转污染。
395
+ """
396
+ path = project_root / GITIGNORE_NAME
397
+ if path.exists():
398
+ raw = path.read_bytes()
399
+ is_crlf = b"\r\n" in raw
400
+ text = raw.decode("utf-8").replace("\r\n", "\n")
401
+ else:
402
+ is_crlf = False
403
+ text = ""
404
+
405
+ new_text, added = _add_gitignore_entries(text, entries)
406
+ if added:
407
+ out = new_text.replace("\n", "\r\n") if is_crlf else new_text
408
+ path.write_bytes(out.encode("utf-8"))
409
+ return added
@@ -85,7 +85,7 @@ $ godot-cli-control --text exists /root/Main
85
85
  true
86
86
 
87
87
  $ godot-cli-control init
88
- {"ok": true, "result": {"project_root": "/path/to/proj", "plugin_copied": true, "plugin_overwritten": false, "project_godot_changes": ["autoload/GameBridgeNode", "editor_plugins/enabled"], "godot_bin": "/usr/bin/godot", "skills_written": [".../SKILL.md", ".../SKILL.md"], "skills_only": false, "write_skills": true}}
88
+ {"ok": true, "result": {"project_root": "/path/to/proj", "plugin_copied": true, "plugin_overwritten": false, "project_godot_changes": ["autoload/GameBridgeNode", "editor_plugins/enabled"], "godot_bin": "/usr/bin/godot", "skills_written": [".../SKILL.md", ".../SKILL.md"], "gitignore_added": [".cli_control/"], "skills_only": false, "write_skills": true}}
89
89
 
90
90
  $ godot-cli-control run my_script.py
91
91
  {"ok": true, "result": {"exit_code": 0, "script": "my_script.py"}}
@@ -274,7 +274,7 @@ Or `{"steps": [...]}` wrapper form is also accepted. Steps run strictly serially
274
274
  The daemon can drive Godot's [Movie Maker](https://docs.godotengine.org/en/stable/tutorials/animation/creating_movies.html) (`--write-movie`) so input you script through this skill is captured to disk. Pipeline: Godot writes raw `.avi`, `daemon stop` shells out to `ffmpeg` to transcode that into `.mp4` next to the original.
275
275
 
276
276
  ```bash
277
- godot-cli-control daemon start --record --movie-path out.avi --headless --fps 60
277
+ godot-cli-control daemon start --record --movie-path out.avi --fps 60
278
278
  godot-cli-control click /root/Main/StartButton
279
279
  godot-cli-control tap jump 0.3
280
280
  godot-cli-control daemon stop # → produces out.mp4
@@ -283,6 +283,7 @@ godot-cli-control daemon stop # → produces out.mp4
283
283
  Key constraints:
284
284
 
285
285
  - `--record` **requires** `--movie-path` (daemon refuses to start otherwise).
286
+ - `--record` needs a **real renderer**, so it cannot run with `--headless`: Godot Movie Maker's `add_frame()` reads the viewport texture, which the headless dummy renderer leaves null → SIGSEGV on the first frame. The daemon therefore **rejects `--record --headless` with exit code 2** before launching Godot. You don't need to pass `--gui`: when `--record` is set the daemon auto-opens a window even in a non-TTY (subagent / pipe / CI) shell that would otherwise default to headless.
286
287
  - The `.mp4` is produced **only when `daemon stop` runs**; `kill -9` leaves the raw `.avi` behind.
287
288
  - `ffmpeg` must be on `PATH` for transcoding. If transcoding fails, the raw `.avi` is kept and `daemon stop` exits with code `2` (transcode log at `.cli_control/ffmpeg.log`).
288
289
  - `--fps` controls the **fixed simulation framerate** Godot runs at while recording — set it to your target video framerate.
@@ -723,6 +723,7 @@ def test_cmd_init_catches_unexpected_exception_into_envelope(
723
723
  no_skills=False,
724
724
  skills_only=False,
725
725
  skills_no_clobber=False,
726
+ no_gitignore=False,
726
727
  output_format=OUTPUT_JSON,
727
728
  )
728
729
  rc = cmd_init(ns)
@@ -1258,6 +1259,47 @@ def test_daemon_start_text_mode_keeps_silent_success(
1258
1259
  assert capsys.readouterr().out == ""
1259
1260
 
1260
1261
 
1262
+ def test_daemon_start_rejects_record_with_explicit_headless(
1263
+ tmp_path: Path,
1264
+ monkeypatch: pytest.MonkeyPatch,
1265
+ capsys: pytest.CaptureFixture[str],
1266
+ ) -> None:
1267
+ """``daemon start --record --movie-path x --headless`` → ok:false 信封 +
1268
+ EXIT_INFRA_ERROR(2),且 message 点名 headless(CultivationWorld #180)。
1269
+
1270
+ 显式 --headless 经 _resolve_headless 仍是 True,被 daemon.start 的 preflight
1271
+ 拒绝。find_godot_binary mock 成 None 确保 RED 时不会真去 spawn Godot。"""
1272
+ import json as _json
1273
+
1274
+ (tmp_path / "project.godot").write_text("", encoding="utf-8")
1275
+ monkeypatch.chdir(tmp_path)
1276
+ monkeypatch.setattr(
1277
+ "godot_cli_control.daemon.find_godot_binary", lambda: None
1278
+ )
1279
+
1280
+ from godot_cli_control.cli import (
1281
+ EXIT_INFRA_ERROR,
1282
+ OUTPUT_JSON,
1283
+ cmd_daemon_start,
1284
+ )
1285
+
1286
+ ns = __import__("argparse").Namespace(
1287
+ record=True,
1288
+ movie_path=str(tmp_path / "demo.avi"),
1289
+ headless=True,
1290
+ gui=False,
1291
+ fps=30,
1292
+ port=0,
1293
+ idle_timeout="0",
1294
+ output_format=OUTPUT_JSON,
1295
+ )
1296
+ rc = cmd_daemon_start(ns)
1297
+ assert rc == EXIT_INFRA_ERROR
1298
+ payload = _json.loads(capsys.readouterr().out.strip())
1299
+ assert payload["ok"] is False
1300
+ assert "headless" in payload["error"]["message"]
1301
+
1302
+
1261
1303
  def test_daemon_stop_emits_json_envelope(
1262
1304
  monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
1263
1305
  ) -> None:
@@ -1796,6 +1838,30 @@ class TestDaemonHeadlessAutodetect:
1796
1838
  ns = type("NS", (), {"headless": True, "gui": False})()
1797
1839
  assert _resolve_headless(ns, force_gui_hint=True) is True
1798
1840
 
1841
+ def test_record_flips_pipe_to_gui(
1842
+ self, monkeypatch: pytest.MonkeyPatch
1843
+ ) -> None:
1844
+ """--record 没显式 --headless 时强制开窗 —— Movie Maker 需真实渲染器,
1845
+ headless dummy renderer 拿不到 viewport texture 会 SIGSEGV
1846
+ (CultivationWorld #180,同 screenshot/#65)。非 TTY 默认 headless
1847
+ 的 subagent/pipe/CI 场景下,--record 也应自动翻成 GUI。"""
1848
+ from godot_cli_control.cli import _resolve_headless
1849
+
1850
+ monkeypatch.setattr("sys.stdout.isatty", lambda: False)
1851
+ ns = type("NS", (), {"headless": False, "gui": False, "record": True})()
1852
+ assert _resolve_headless(ns) is False
1853
+
1854
+ def test_explicit_headless_wins_over_record_flip(
1855
+ self, monkeypatch: pytest.MonkeyPatch
1856
+ ) -> None:
1857
+ """显式 --headless + --record 仍返回 True —— 不静默改写用户意图,
1858
+ 而是把矛盾交给 daemon.start 的 preflight 拒绝(给明确用法错)。"""
1859
+ from godot_cli_control.cli import _resolve_headless
1860
+
1861
+ monkeypatch.setattr("sys.stdout.isatty", lambda: True)
1862
+ ns = type("NS", (), {"headless": True, "gui": False, "record": True})()
1863
+ assert _resolve_headless(ns) is True
1864
+
1799
1865
 
1800
1866
  class TestScriptLikelyUsesScreenshot:
1801
1867
  def test_detects_method_call(self, tmp_path: Path) -> None:
@@ -80,6 +80,26 @@ def test_start_rejects_record_without_movie_path(tmp_path: Path) -> None:
80
80
  daemon.start(record=True, movie_path=None)
81
81
 
82
82
 
83
+ def test_start_rejects_record_with_headless(
84
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
85
+ ) -> None:
86
+ """record + headless 必须在 spawn 前拒绝。
87
+
88
+ Godot Movie Maker 在 headless dummy renderer 下拿不到 viewport texture,
89
+ add_frame() 收到 null 纹理直接 SIGSEGV(CultivationWorld #180,同 #65 截图)。
90
+ 这是用法矛盾 —— preflight 报错,别让 Godot 段错误吓人。检查必须在 Godot
91
+ binary 查找/spawn 之前,所以即使 find_godot_binary 被 mock 成 None 也应先命中
92
+ headless 报错。"""
93
+ _touch_godot_project(tmp_path)
94
+ monkeypatch.setattr(
95
+ "godot_cli_control.daemon.find_godot_binary", lambda: None
96
+ )
97
+ daemon = Daemon(tmp_path)
98
+ movie = tmp_path / "rec.avi"
99
+ with pytest.raises(DaemonError, match="headless"):
100
+ daemon.start(record=True, movie_path=str(movie), headless=True)
101
+
102
+
83
103
  def test_start_rejects_when_godot_bin_missing(
84
104
  tmp_path: Path, monkeypatch: pytest.MonkeyPatch
85
105
  ) -> None:
@@ -428,6 +428,7 @@ def test_cmd_init_emits_success_envelope_in_json_mode(
428
428
  no_skills=False,
429
429
  skills_only=False,
430
430
  skills_no_clobber=False,
431
+ no_gitignore=False,
431
432
  output_format=OUTPUT_JSON,
432
433
  )
433
434
  rc = cmd_init(ns)
@@ -459,6 +460,7 @@ def test_cmd_init_emits_error_envelope_on_non_godot_dir(
459
460
  no_skills=False,
460
461
  skills_only=False,
461
462
  skills_no_clobber=False,
463
+ no_gitignore=False,
462
464
  output_format=OUTPUT_JSON,
463
465
  )
464
466
  rc = cmd_init(ns)
@@ -469,3 +471,184 @@ def test_cmd_init_emits_error_envelope_on_non_godot_dir(
469
471
  assert payload["ok"] is False
470
472
  assert payload["error"]["code"] == -1003 # CLIENT_CODE_USAGE
471
473
  assert "project.godot" in payload["error"]["message"]
474
+
475
+
476
+ # ── .gitignore 维护(确保 .cli_control/ 不被提交)──
477
+
478
+
479
+ def test_gitignore_adds_entry_to_empty_text() -> None:
480
+ """纯函数:空文本追加条目。"""
481
+ from godot_cli_control.init_cmd import _add_gitignore_entries
482
+
483
+ out, added = _add_gitignore_entries("", [".cli_control/"])
484
+ assert added == [".cli_control/"]
485
+ assert out == ".cli_control/\n"
486
+ # 不允许出现前导空行
487
+ assert not out.startswith("\n")
488
+
489
+
490
+ def test_gitignore_appends_preserving_existing_entries() -> None:
491
+ """已有别的忽略项时只追加、不动原内容。"""
492
+ from godot_cli_control.init_cmd import _add_gitignore_entries
493
+
494
+ text = "*.tmp\nbuild/\n"
495
+ out, added = _add_gitignore_entries(text, [".cli_control/"])
496
+ assert added == [".cli_control/"]
497
+ assert "*.tmp" in out
498
+ assert "build/" in out
499
+ assert out.endswith(".cli_control/\n")
500
+
501
+
502
+ def test_gitignore_idempotent_when_entry_present() -> None:
503
+ """已含完全相同条目 → 不重复加。"""
504
+ from godot_cli_control.init_cmd import _add_gitignore_entries
505
+
506
+ text = "build/\n.cli_control/\n"
507
+ out, added = _add_gitignore_entries(text, [".cli_control/"])
508
+ assert added == []
509
+ assert out == text
510
+
511
+
512
+ @pytest.mark.parametrize(
513
+ "existing",
514
+ [".cli_control\n", "/.cli_control/\n", "/.cli_control\n", ".cli_control/ \n"],
515
+ )
516
+ def test_gitignore_idempotent_on_equivalent_variants(existing: str) -> None:
517
+ """带/不带斜杠、带前导 / 、带尾随空白都视为已忽略,不重复加。"""
518
+ from godot_cli_control.init_cmd import _add_gitignore_entries
519
+
520
+ out, added = _add_gitignore_entries(existing, [".cli_control/"])
521
+ assert added == []
522
+ assert out == existing
523
+
524
+
525
+ def test_gitignore_commented_line_does_not_count_as_present() -> None:
526
+ """被注释掉的同名行不算忽略,真条目仍要补上。"""
527
+ from godot_cli_control.init_cmd import _add_gitignore_entries
528
+
529
+ text = "# .cli_control/\n"
530
+ out, added = _add_gitignore_entries(text, [".cli_control/"])
531
+ assert added == [".cli_control/"]
532
+ assert "# .cli_control/" in out # 注释保留
533
+ assert out.endswith(".cli_control/\n")
534
+
535
+
536
+ def test_gitignore_appends_newline_when_file_lacks_trailing_one() -> None:
537
+ """原文件末行无换行符时,新条目必须独立成行、不黏在末行后面。"""
538
+ from godot_cli_control.init_cmd import _add_gitignore_entries
539
+
540
+ out, added = _add_gitignore_entries("build/", [".cli_control/"])
541
+ assert added == [".cli_control/"]
542
+ assert out == "build/\n.cli_control/\n"
543
+
544
+
545
+ def test_ensure_gitignore_creates_file_when_missing(tmp_path: Path) -> None:
546
+ """项目根没有 .gitignore → 创建并写入 .cli_control/。"""
547
+ from godot_cli_control.init_cmd import _ensure_gitignore_entries
548
+
549
+ added = _ensure_gitignore_entries(tmp_path)
550
+ gi = tmp_path / ".gitignore"
551
+ assert gi.is_file()
552
+ assert ".cli_control/" in gi.read_text(encoding="utf-8")
553
+ assert added == [".cli_control/"]
554
+
555
+
556
+ def test_ensure_gitignore_idempotent_across_runs(tmp_path: Path) -> None:
557
+ """连跑两次只写一条,不重复。"""
558
+ from godot_cli_control.init_cmd import _ensure_gitignore_entries
559
+
560
+ _ensure_gitignore_entries(tmp_path)
561
+ added2 = _ensure_gitignore_entries(tmp_path)
562
+ assert added2 == []
563
+ content = (tmp_path / ".gitignore").read_text(encoding="utf-8")
564
+ assert content.count(".cli_control/") == 1
565
+
566
+
567
+ def test_ensure_gitignore_preserves_crlf(tmp_path: Path) -> None:
568
+ """已有 CRLF 的 .gitignore 在追加后必须仍是 CRLF,不混入裸 LF。"""
569
+ from godot_cli_control.init_cmd import _ensure_gitignore_entries
570
+
571
+ gi = tmp_path / ".gitignore"
572
+ gi.write_bytes(b"*.tmp\r\nbuild/\r\n")
573
+ _ensure_gitignore_entries(tmp_path)
574
+ after = gi.read_bytes()
575
+ assert b".cli_control/\r\n" in after
576
+ bare_lf = after.count(b"\n") - after.count(b"\r\n")
577
+ assert bare_lf == 0, f"出现裸 LF:{bare_lf} 处"
578
+
579
+
580
+ def test_run_init_adds_cli_control_to_gitignore(
581
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
582
+ ) -> None:
583
+ """端到端:run_init 默认在项目根 .gitignore 忽略 .cli_control/。"""
584
+ monkeypatch.delenv("GODOT_BIN", raising=False)
585
+ monkeypatch.setattr(
586
+ "godot_cli_control.init_cmd.find_godot_binary", lambda: None
587
+ )
588
+ proj = _minimal_project(tmp_path)
589
+ assert run_init(proj) == 0
590
+ gi = (proj / ".gitignore").read_text(encoding="utf-8")
591
+ assert ".cli_control/" in gi
592
+
593
+
594
+ def test_run_init_no_gitignore_skips_gitignore(
595
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
596
+ ) -> None:
597
+ """write_gitignore=False → 不创建 / 不改 .gitignore。"""
598
+ monkeypatch.delenv("GODOT_BIN", raising=False)
599
+ monkeypatch.setattr(
600
+ "godot_cli_control.init_cmd.find_godot_binary", lambda: None
601
+ )
602
+ proj = _minimal_project(tmp_path)
603
+ assert run_init(proj, write_gitignore=False) == 0
604
+ assert not (proj / ".gitignore").exists()
605
+
606
+
607
+ def test_run_init_skills_only_skips_gitignore(tmp_path: Path) -> None:
608
+ """skills_only 属于纯刷 SKILL.md,不应碰 .gitignore。"""
609
+ proj = _make_min_godot_project(tmp_path)
610
+ assert run_init(proj, skills_only=True) == 0
611
+ assert not (proj / ".gitignore").exists()
612
+
613
+
614
+ def test_run_init_json_records_gitignore_added(
615
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
616
+ ) -> None:
617
+ """json 模式 result 必须回填 gitignore_added 字段。"""
618
+ monkeypatch.delenv("GODOT_BIN", raising=False)
619
+ monkeypatch.setattr(
620
+ "godot_cli_control.init_cmd.find_godot_binary", lambda: None
621
+ )
622
+ proj = _make_min_godot_project(tmp_path)
623
+ result: dict[str, object] = {}
624
+ assert run_init(proj, output_format="json", result=result) == 0
625
+ assert result["gitignore_added"] == [".cli_control/"]
626
+
627
+
628
+ def test_cmd_init_no_gitignore_flag_skips(
629
+ tmp_path: Path,
630
+ monkeypatch: pytest.MonkeyPatch,
631
+ capsys: pytest.CaptureFixture[str],
632
+ ) -> None:
633
+ """CLI 层:--no-gitignore(ns.no_gitignore=True)不写 .gitignore。"""
634
+ import argparse
635
+
636
+ from godot_cli_control.cli import OUTPUT_JSON, cmd_init
637
+
638
+ monkeypatch.delenv("GODOT_BIN", raising=False)
639
+ monkeypatch.setattr(
640
+ "godot_cli_control.init_cmd.find_godot_binary", lambda: None
641
+ )
642
+ proj = _make_min_godot_project(tmp_path)
643
+ ns = argparse.Namespace(
644
+ path=str(proj),
645
+ force=False,
646
+ no_skills=False,
647
+ skills_only=False,
648
+ skills_no_clobber=False,
649
+ no_gitignore=True,
650
+ output_format=OUTPUT_JSON,
651
+ )
652
+ assert cmd_init(ns) == 0
653
+ capsys.readouterr()
654
+ assert not (proj / ".gitignore").exists()