godot-cli-control 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,6 @@
1
+ """Godot CLI Control — WebSocket bridge for headless / scripted control of Godot scenes."""
2
+
3
+ from godot_cli_control.client import GameClient
4
+
5
+ __all__ = ["GameClient"]
6
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Allow running as: python3 -m godot_cli_control <command> [args...]"""
2
+
3
+ from .cli import main
4
+
5
+ main()
@@ -0,0 +1,129 @@
1
+ """同步 Bridge —— 封装 GameClient,让 Python 脚本只需写操作逻辑。
2
+
3
+ 用法:脚本只需定义 run(bridge) 函数即可。
4
+
5
+ def run(bridge):
6
+ bridge.click("/root/MainMenu/.../NewGameButton")
7
+ bridge.wait(2)
8
+ bridge.hold("move_right", 1.5)
9
+ bridge.press("attack_melee")
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from .client import GameClient
19
+
20
+
21
+ class GameBridge:
22
+ """同步接口,内部通过事件循环调用异步 GameClient。"""
23
+
24
+ def __init__(self, port: int = 9877) -> None:
25
+ self._loop = asyncio.new_event_loop()
26
+ self._client = GameClient(port=port)
27
+ self._loop.run_until_complete(
28
+ self._client.connect(retries=15, backoff=1.0)
29
+ )
30
+
31
+ def close(self) -> None:
32
+ self._loop.run_until_complete(self._client.disconnect())
33
+ self._loop.close()
34
+
35
+ def _run(self, coro: Any) -> Any:
36
+ return self._loop.run_until_complete(coro)
37
+
38
+ # ── 等待 ──
39
+
40
+ def wait(self, seconds: float) -> None:
41
+ """按 Godot game time 等待指定秒数。
42
+
43
+ 在 --write-movie 模式下,此方法等待的是 game time(录像帧数基准),
44
+ 而非 wall time,确保录像内容与脚本期望对齐。
45
+ """
46
+ self._run(self._client.wait_game_time(seconds))
47
+
48
+ # ── 场景树 ──
49
+
50
+ def tree(self, depth: int = 3) -> dict:
51
+ """获取场景树。"""
52
+ return self._run(self._client.get_scene_tree(depth=depth))
53
+
54
+ def node_exists(self, path: str) -> bool:
55
+ """检查节点是否存在。"""
56
+ return self._run(self._client.node_exists(path))
57
+
58
+ def wait_for_node(self, path: str, timeout: float = 5.0) -> bool:
59
+ """等待节点出现。"""
60
+ return self._run(self._client.wait_for_node(path, timeout=timeout))
61
+
62
+ # ── UI 交互 ──
63
+
64
+ def click(self, path: str) -> dict:
65
+ """点击 UI 节点。"""
66
+ return self._run(self._client.click(path))
67
+
68
+ # ── 输入模拟 ──
69
+
70
+ def press(self, action: str) -> None:
71
+ """按下并释放一个动作。"""
72
+ self._run(self._client.action_tap(action, 0.1))
73
+
74
+ def hold(self, action: str, duration: float) -> None:
75
+ """按住一个动作指定时长。"""
76
+ self._run(self._client.hold(action, duration))
77
+
78
+ def tap(self, action: str, duration: float = 0.1) -> None:
79
+ """快速点按。"""
80
+ self._run(self._client.action_tap(action, duration))
81
+
82
+ def move(self, x: float, y: float, duration: float) -> None:
83
+ """方向移动。"""
84
+ self._run(self._client.move(x, y, duration))
85
+
86
+ def action_press(self, action: str) -> None:
87
+ """按下动作(不释放,需手动 release)。"""
88
+ self._run(self._client.action_press(action))
89
+
90
+ def action_release(self, action: str) -> None:
91
+ """释放动作。"""
92
+ self._run(self._client.action_release(action))
93
+
94
+ def release_all(self) -> None:
95
+ """释放所有按住的动作。"""
96
+ self._run(self._client.release_all())
97
+
98
+ def combo(self, steps: list[dict]) -> dict:
99
+ """执行连续动作序列。"""
100
+ return self._run(self._client.combo(steps))
101
+
102
+ # ── 截图 ──
103
+
104
+ def screenshot(self, path: str | None = None) -> bytes:
105
+ """截图,返回 PNG 字节。可选保存到文件。"""
106
+ data = self._run(self._client.screenshot())
107
+ if path:
108
+ p = Path(path)
109
+ p.parent.mkdir(parents=True, exist_ok=True)
110
+ p.write_bytes(data)
111
+ return data
112
+
113
+ # ── 属性读写 ──
114
+
115
+ def get_property(self, path: str, prop: str) -> Any:
116
+ """获取节点属性。"""
117
+ return self._run(self._client.get_property(path, prop))
118
+
119
+ def set_property(self, path: str, prop: str, value: Any) -> None:
120
+ """设置节点属性。"""
121
+ self._run(self._client.set_property(path, prop, value))
122
+
123
+ def call_method(self, path: str, method: str, args: list | None = None) -> Any:
124
+ """调用节点方法。"""
125
+ return self._run(self._client.call_method(path, method, args))
126
+
127
+ def get_children(self, path: str, type_filter: str = "") -> list[dict]:
128
+ """获取子节点列表。"""
129
+ return self._run(self._client.get_children(path, type_filter=type_filter))
@@ -0,0 +1,158 @@
1
+ """CLI commands for one-off GameClient operations.
2
+
3
+ Usage:
4
+ python3 -m godot_cli_control click <node_path>
5
+ python3 -m godot_cli_control screenshot [output.png]
6
+ python3 -m godot_cli_control tree [depth]
7
+ python3 -m godot_cli_control press <action_name>
8
+ python3 -m godot_cli_control hold <action_name> <duration>
9
+ python3 -m godot_cli_control combo <sequence.json>
10
+
11
+ <action_name> 依赖目标工程的 InputMap;<node_path> 形如
12
+ /root/<Scene>/... 由 `tree` 查出。
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import base64
19
+ import json
20
+ import sys
21
+ from collections.abc import Callable, Coroutine
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from .client import GameClient
26
+
27
+ DEFAULT_PORT: int = 9877
28
+
29
+
30
+ async def cmd_click(client: GameClient, args: list[str]) -> None:
31
+ if not args:
32
+ print("Usage: click <node_path>", file=sys.stderr)
33
+ sys.exit(1)
34
+ result = await client.click(args[0])
35
+ print(f"clicked: {result}")
36
+
37
+
38
+ async def cmd_screenshot(client: GameClient, args: list[str]) -> None:
39
+ data = await client.screenshot()
40
+ if args:
41
+ output = Path(args[0])
42
+ output.parent.mkdir(parents=True, exist_ok=True)
43
+ output.write_bytes(data)
44
+ print(f"screenshot saved: {output} ({len(data)} bytes)")
45
+ else:
46
+ print(base64.b64encode(data).decode())
47
+
48
+
49
+ async def cmd_tree(client: GameClient, args: list[str]) -> None:
50
+ depth = int(args[0]) if args else 3
51
+ tree = await client.get_scene_tree(depth=depth)
52
+ print(json.dumps(tree, indent=2, ensure_ascii=False))
53
+
54
+
55
+ async def cmd_press(client: GameClient, args: list[str]) -> None:
56
+ if not args:
57
+ print("Usage: press <action>", file=sys.stderr)
58
+ sys.exit(1)
59
+ result = await client.action_press(args[0])
60
+ print(f"pressed: {result}")
61
+
62
+
63
+ async def cmd_release(client: GameClient, args: list[str]) -> None:
64
+ if not args:
65
+ print("Usage: release <action>", file=sys.stderr)
66
+ sys.exit(1)
67
+ result = await client.action_release(args[0])
68
+ print(f"released: {result}")
69
+
70
+
71
+ async def cmd_tap(client: GameClient, args: list[str]) -> None:
72
+ if not args:
73
+ print("Usage: tap <action> [duration]", file=sys.stderr)
74
+ sys.exit(1)
75
+ duration = float(args[1]) if len(args) > 1 else 0.1
76
+ result = await client.action_tap(args[0], duration)
77
+ print(f"tapped: {result}")
78
+
79
+
80
+ async def cmd_hold(client: GameClient, args: list[str]) -> None:
81
+ if len(args) < 2:
82
+ print("Usage: hold <action> <duration>", file=sys.stderr)
83
+ sys.exit(1)
84
+ result = await client.hold(args[0], float(args[1]))
85
+ print(f"holding: {result}")
86
+
87
+
88
+ async def cmd_move(client: GameClient, args: list[str]) -> None:
89
+ if len(args) < 3:
90
+ print("Usage: move <x> <y> <duration>", file=sys.stderr)
91
+ sys.exit(1)
92
+ result = await client.move(float(args[0]), float(args[1]), float(args[2]))
93
+ print(f"moving: {result}")
94
+
95
+
96
+ async def cmd_combo(client: GameClient, args: list[str]) -> None:
97
+ if not args:
98
+ print("Usage: combo <json_file>", file=sys.stderr)
99
+ sys.exit(1)
100
+ steps = json.loads(Path(args[0]).read_text())
101
+ if isinstance(steps, dict):
102
+ steps = steps.get("steps", [])
103
+ result = await client.combo(steps)
104
+ print(f"combo done: {result}")
105
+
106
+
107
+ async def cmd_release_all(client: GameClient, _args: list[str]) -> None:
108
+ result = await client.release_all()
109
+ print(f"released all: {result}")
110
+
111
+
112
+ COMMANDS: dict[
113
+ str, Callable[[GameClient, list[str]], Coroutine[Any, Any, None]]
114
+ ] = {
115
+ "click": cmd_click,
116
+ "screenshot": cmd_screenshot,
117
+ "tree": cmd_tree,
118
+ "press": cmd_press,
119
+ "release": cmd_release,
120
+ "tap": cmd_tap,
121
+ "hold": cmd_hold,
122
+ "move": cmd_move,
123
+ "combo": cmd_combo,
124
+ "release-all": cmd_release_all,
125
+ }
126
+
127
+
128
+ def main() -> None:
129
+ args: list[str] = sys.argv[1:]
130
+ port: int = DEFAULT_PORT
131
+
132
+ if "--port" in args:
133
+ idx = args.index("--port")
134
+ if idx + 1 >= len(args):
135
+ print("Error: --port requires a value", file=sys.stderr)
136
+ sys.exit(1)
137
+ port = int(args[idx + 1])
138
+ args = args[:idx] + args[idx + 2 :]
139
+
140
+ if not args or args[0] not in COMMANDS:
141
+ cmds = ", ".join(COMMANDS.keys())
142
+ print(
143
+ f"Usage: python3 -m godot_cli_control [--port N] <{cmds}> [args...]",
144
+ file=sys.stderr,
145
+ )
146
+ sys.exit(1)
147
+
148
+ cmd = args[0]
149
+
150
+ async def run() -> None:
151
+ async with GameClient(port=port) as client:
152
+ await COMMANDS[cmd](client, args[1:])
153
+
154
+ asyncio.run(run())
155
+
156
+
157
+ if __name__ == "__main__":
158
+ main()
@@ -0,0 +1,246 @@
1
+ """GameClient - WebSocket client for connecting to Godot GameBridge."""
2
+
3
+ import asyncio
4
+ import base64
5
+ import json
6
+ import logging
7
+ import uuid
8
+ from typing import Any
9
+
10
+ import websockets
11
+ from websockets.asyncio.client import ClientConnection
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class GameClient:
17
+ """WebSocket client that connects to Godot's GameBridge service."""
18
+
19
+ def __init__(self, port: int = 9877) -> None:
20
+ self._port = port
21
+ self._ws: ClientConnection | None = None
22
+ self._pending: dict[str, asyncio.Future[dict]] = {}
23
+ self._listen_task: asyncio.Task | None = None
24
+
25
+ async def __aenter__(self) -> "GameClient":
26
+ await self.connect()
27
+ return self
28
+
29
+ async def __aexit__(self, *_: object) -> None:
30
+ await self.disconnect()
31
+
32
+ async def connect(
33
+ self, retries: int = 10, backoff: float = 1.0, max_wait: float = 3.0
34
+ ) -> None:
35
+ """Connect to GameBridge with exponential backoff retry.
36
+
37
+ 显式 ``proxy=None``:GameBridge 永远跑在 localhost,而 websockets>=14
38
+ 会自动读取 ``all_proxy``/``http_proxy``/``https_proxy`` 环境变量并把
39
+ localhost 连接也塞进 SOCKS/HTTP 代理,导致 TCP 被代理立刻 EOF、
40
+ 抛 ``InvalidMessage: did not receive a valid HTTP response``。用户
41
+ shell 里的 ``all_proxy=socks5://127.0.0.1:7897`` 就是典型触发点;
42
+ 显式传 ``proxy=None`` 比依赖 ``no_proxy`` 顺序更稳(no_proxy 对
43
+ localhost 的匹配规则各库不一致)。
44
+ """
45
+ for attempt in range(retries):
46
+ try:
47
+ self._ws = await websockets.connect(
48
+ f"ws://localhost:{self._port}",
49
+ proxy=None,
50
+ )
51
+ self._listen_task = asyncio.create_task(self._listen())
52
+ logger.info("Connected to GameBridge on port %d", self._port)
53
+ return
54
+ except (
55
+ ConnectionRefusedError,
56
+ OSError,
57
+ websockets.exceptions.InvalidMessage,
58
+ ) as e:
59
+ if attempt < retries - 1:
60
+ wait = min(backoff * (2**attempt), max_wait)
61
+ logger.warning(
62
+ "Connection attempt %d failed: %s. Retrying in %.1fs...",
63
+ attempt + 1, e, wait,
64
+ )
65
+ await asyncio.sleep(wait)
66
+ else:
67
+ raise ConnectionError(
68
+ f"Failed to connect after {retries} attempts"
69
+ ) from e
70
+
71
+ async def disconnect(self) -> None:
72
+ """Disconnect from GameBridge."""
73
+ if self._listen_task:
74
+ self._listen_task.cancel()
75
+ try:
76
+ await self._listen_task
77
+ except asyncio.CancelledError:
78
+ pass
79
+ if self._ws:
80
+ await self._ws.close()
81
+ self._ws = None
82
+ logger.info("Disconnected from GameBridge")
83
+
84
+ async def _listen(self) -> None:
85
+ """Background task that listens for messages from GameBridge.
86
+
87
+ finally 里清空所有 pending future —— 涵盖 ConnectionClosed、
88
+ CancelledError 以及任何其它退出路径,防止调用方的 await 在
89
+ connection 消失后挂死到超时。
90
+ """
91
+ assert self._ws is not None
92
+ close_reason: str | None = None
93
+ try:
94
+ async for raw in self._ws:
95
+ msg = json.loads(raw)
96
+ if "id" in msg:
97
+ req_id = msg["id"]
98
+ if req_id in self._pending:
99
+ self._pending[req_id].set_result(msg)
100
+ del self._pending[req_id]
101
+ except websockets.ConnectionClosed:
102
+ close_reason = "Connection closed by server"
103
+ logger.info(close_reason)
104
+ finally:
105
+ reason = close_reason or "listen task stopped"
106
+ for future in self._pending.values():
107
+ if not future.done():
108
+ future.set_exception(ConnectionError(reason))
109
+ self._pending.clear()
110
+
111
+ async def request(
112
+ self, method: str, params: dict | None = None, timeout: float = 30.0
113
+ ) -> dict:
114
+ """Send a JSON-RPC request and wait for the response."""
115
+ assert self._ws is not None, "Not connected"
116
+ req_id = str(uuid.uuid4())[:8]
117
+ msg = {"id": req_id, "method": method, "params": params or {}}
118
+ future: asyncio.Future[dict] = asyncio.get_running_loop().create_future()
119
+ self._pending[req_id] = future
120
+ await self._ws.send(json.dumps(msg))
121
+ try:
122
+ response = await asyncio.wait_for(future, timeout=timeout)
123
+ except asyncio.TimeoutError:
124
+ self._pending.pop(req_id, None)
125
+ raise
126
+ if "error" in response:
127
+ raise RuntimeError(
128
+ f"GameBridge error: {response['error']['message']}"
129
+ )
130
+ return response.get("result", {})
131
+
132
+ # ---- Low-level API ----
133
+
134
+ async def click(self, path: str) -> dict:
135
+ return await self.request("click", {"path": path})
136
+
137
+ async def get_property(self, path: str, prop: str) -> Any:
138
+ result = await self.request(
139
+ "get_property", {"path": path, "property": prop}
140
+ )
141
+ return result.get("value")
142
+
143
+ async def set_property(self, path: str, prop: str, value: Any) -> dict:
144
+ return await self.request(
145
+ "set_property", {"path": path, "property": prop, "value": value}
146
+ )
147
+
148
+ async def call_method(
149
+ self, path: str, method: str, args: list | None = None
150
+ ) -> Any:
151
+ result = await self.request(
152
+ "call_method",
153
+ {"path": path, "method": method, "args": args or []},
154
+ )
155
+ return result.get("result")
156
+
157
+ async def get_text(self, path: str) -> str:
158
+ result = await self.request("get_text", {"path": path})
159
+ return result.get("text", "")
160
+
161
+ async def node_exists(self, path: str) -> bool:
162
+ result = await self.request("node_exists", {"path": path})
163
+ return result.get("exists", False)
164
+
165
+ async def is_visible(self, path: str) -> bool:
166
+ result = await self.request("is_visible", {"path": path})
167
+ return result.get("visible", False)
168
+
169
+ async def get_children(
170
+ self, path: str, type_filter: str = ""
171
+ ) -> list[dict]:
172
+ result = await self.request(
173
+ "get_children", {"path": path, "type_filter": type_filter}
174
+ )
175
+ return result.get("children", [])
176
+
177
+ async def screenshot(self) -> bytes:
178
+ """Take a screenshot and return PNG bytes."""
179
+ result = await self.request("screenshot")
180
+ return base64.b64decode(result.get("image", ""))
181
+
182
+ async def get_scene_tree(self, depth: int = 5) -> dict:
183
+ return await self.request("get_scene_tree", {"depth": depth})
184
+
185
+ async def wait_for_node(self, path: str, timeout: float = 5.0) -> bool:
186
+ result = await self.request(
187
+ "wait_for_node",
188
+ {"path": path, "timeout": timeout},
189
+ timeout=timeout + 5.0,
190
+ )
191
+ return result.get("found", False)
192
+
193
+ async def wait_game_time(self, seconds: float) -> dict:
194
+ """按 Godot game time 等待 N 秒。
195
+
196
+ Movie Maker (--write-movie) 模式下 wall time 比 game time 慢约 2-3×,
197
+ 客户端 timeout 用 seconds * 3 + 10 安全系数,与 combo() 一致。
198
+ seconds <= 0 时客户端短路返回,不发 RPC。
199
+ """
200
+ if seconds <= 0:
201
+ return {"success": True}
202
+ return await self.request(
203
+ "wait_game_time",
204
+ {"seconds": seconds},
205
+ timeout=seconds * 3.0 + 10.0,
206
+ )
207
+
208
+ # ---- Input simulation API ----
209
+
210
+ async def action_press(self, action: str) -> dict:
211
+ return await self.request("input_action_press", {"action": action})
212
+
213
+ async def action_release(self, action: str) -> dict:
214
+ return await self.request("input_action_release", {"action": action})
215
+
216
+ async def action_tap(self, action: str, duration: float = 0.1) -> dict:
217
+ return await self.request(
218
+ "input_action_tap", {"action": action, "duration": duration}
219
+ )
220
+
221
+ async def hold(self, action: str, duration: float) -> dict:
222
+ return await self.request(
223
+ "input_hold", {"action": action, "duration": duration}
224
+ )
225
+
226
+ async def move(self, x: float, y: float, duration: float) -> dict:
227
+ return await self.request(
228
+ "input_move", {"direction": {"x": x, "y": y}, "duration": duration}
229
+ )
230
+
231
+ async def combo(self, steps: list[dict]) -> dict:
232
+ total = sum(
233
+ s.get("duration", 0) or s.get("wait", 0) for s in steps
234
+ )
235
+ # movie maker (--write-movie) 模式下 Godot 渲染比实时慢(大分辨率 +
236
+ # MJPEG 编码开销),客户端墙钟等待需要 3× 安全系数 + 10s base,
237
+ # 避免 combo 还没在游戏时间跑完就被 TimeoutError 打断。
238
+ return await self.request(
239
+ "input_combo", {"steps": steps}, timeout=total * 3.0 + 10.0
240
+ )
241
+
242
+ async def combo_cancel(self) -> dict:
243
+ return await self.request("input_combo_cancel")
244
+
245
+ async def release_all(self) -> dict:
246
+ return await self.request("input_release_all")
@@ -0,0 +1,61 @@
1
+ """脚本运行器 —— 加载用户脚本并注入 GameBridge。
2
+
3
+ 用法: python3 -m godot_cli_control.runner <script.py> [args...]
4
+
5
+ 用户脚本只需定义 run(bridge) 函数:
6
+
7
+ def run(bridge):
8
+ bridge.click("/root/MainMenu/.../NewGameButton")
9
+ bridge.wait(2)
10
+ bridge.hold("move_right", 1.5)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import importlib.util
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ from .bridge import GameBridge
20
+
21
+
22
+ def main() -> None:
23
+ if len(sys.argv) < 2:
24
+ print("用法: python3 -m godot_cli_control.runner <script.py> [args...]", file=sys.stderr)
25
+ sys.exit(1)
26
+
27
+ script_path = Path(sys.argv[1])
28
+ if not script_path.exists():
29
+ print(f"错误:找不到脚本: {script_path}", file=sys.stderr)
30
+ sys.exit(1)
31
+
32
+ # 解析 --port 参数
33
+ port = 9877
34
+ args = sys.argv[2:]
35
+ if "--port" in args:
36
+ idx = args.index("--port")
37
+ port = int(args[idx + 1])
38
+ args = args[:idx] + args[idx + 2:]
39
+
40
+ # 动态加载用户脚本
41
+ spec = importlib.util.spec_from_file_location("user_script", script_path)
42
+ if spec is None or spec.loader is None:
43
+ print(f"错误:无法加载脚本: {script_path}", file=sys.stderr)
44
+ sys.exit(1)
45
+ module = importlib.util.module_from_spec(spec)
46
+ spec.loader.exec_module(module)
47
+
48
+ if not hasattr(module, "run"):
49
+ print(f"错误:脚本 {script_path} 中缺少 run(bridge) 函数", file=sys.stderr)
50
+ sys.exit(1)
51
+
52
+ # 创建 bridge 并执行
53
+ bridge = GameBridge(port=port)
54
+ try:
55
+ module.run(bridge)
56
+ finally:
57
+ bridge.close()
58
+
59
+
60
+ if __name__ == "__main__":
61
+ main()
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.4
2
+ Name: godot-cli-control
3
+ Version: 0.1.0
4
+ Summary: WebSocket bridge for headless / scripted control of Godot scenes.
5
+ Author: kesar
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: websockets<16,>=14
10
+ Description-Content-Type: text/markdown
11
+
12
+ # godot-cli-control
13
+
14
+ WebSocket bridge for headless / scripted control of Godot 4 scenes — Python client.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pip install godot-cli-control
20
+ ```
21
+
22
+ Requires Python ≥ 3.10. Companion Godot plugin must be installed and enabled in your Godot project (see [the plugin README](https://github.com/kesar/godot-2d-skeleton/blob/main/addons/godot_cli_control/README.md) for setup).
23
+
24
+ ## Usage
25
+
26
+ ```python
27
+ import asyncio
28
+ from godot_cli_control import GameClient
29
+
30
+ async def main():
31
+ async with GameClient(port=9877) as client:
32
+ # Inspect scene
33
+ tree = await client.get_scene_tree(depth=3)
34
+ print(tree)
35
+
36
+ # Interact
37
+ await client.click("/root/MyScene/Button")
38
+ await client.action_press("jump")
39
+ await client.wait_game_time(0.5)
40
+ await client.action_release("jump")
41
+
42
+ # Capture
43
+ png_bytes = await client.screenshot()
44
+ open("frame.png", "wb").write(png_bytes)
45
+
46
+ asyncio.run(main())
47
+ ```
48
+
49
+ ## CLI
50
+
51
+ ```bash
52
+ python -m godot_cli_control tree 3
53
+ python -m godot_cli_control click /root/MyScene/Button
54
+ python -m godot_cli_control screenshot /tmp/frame.png
55
+ ```
56
+
57
+ ## Documentation
58
+
59
+ See the [Godot plugin README](https://github.com/kesar/godot-2d-skeleton/blob/main/addons/godot_cli_control/README.md) for the full RPC reference, activation modes, security model, and known limitations.
@@ -0,0 +1,11 @@
1
+ godot_cli_control/__init__.py,sha256=AnccyQWirNpfA1mIOKKqSyIj9ZCHbePX-9OcstrN4JA,191
2
+ godot_cli_control/__main__.py,sha256=b4ZjDWfx_1HURqDT_NppUrSdsJHDnWrTmTqEm-fot7I,104
3
+ godot_cli_control/bridge.py,sha256=Ft6ccUI4bYqzvCAeBWLOh3wLYe6qvRimZYTkQ5awC3w,4372
4
+ godot_cli_control/cli.py,sha256=4ernAGcxQsHMPRrS5THB6r4D-2cvFacjR7ZrPNrIqhM,4576
5
+ godot_cli_control/client.py,sha256=gifAURi2u6SZNkjLjNcT3eG3f-MzP-q_mpIsjDO_in4,9360
6
+ godot_cli_control/runner.py,sha256=rPn9YBepJ_V4PCVO7JS-V55RuiZ1gQJjbU03vowuJMI,1660
7
+ godot_cli_control-0.1.0.dist-info/METADATA,sha256=fEapVMZN_deFZJC51sNr9PHKfaey2BPt4rjyjgekkK8,1639
8
+ godot_cli_control-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
9
+ godot_cli_control-0.1.0.dist-info/entry_points.txt,sha256=FE3vot-aNS_kea_ZtdBwM1jG7LGLfNTYe4Tr5ev-T_U,65
10
+ godot_cli_control-0.1.0.dist-info/licenses/LICENSE,sha256=EX5PjSSWg24GE8fMU2BhRYT-gGNthdfJ1oD20pzSYEI,1062
11
+ godot_cli_control-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ godot-cli-control = godot_cli_control.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kesar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.