Initial Void Bastion 0.20 prototype

This commit is contained in:
Emporex committed 2026-10-03 21:28:56 +02:00
commit 7362ca0f02
251 files changed
+29688

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Blake Bukowsky
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+43
View File
@@ -0,0 +1,43 @@
# Agent Tools
Godot editor plugin that exposes **43 tools across 12 namespaces** (scene edits, signal wiring, resource creation, reference validation, animation, headless test runs, etc.) over MCP so coding agents can operate through the editor's real APIs instead of hand-editing `.tscn` / `.tres` / `project.godot` as text.
Works with any MCP-capable client — Claude Code, Claude Desktop, Cursor, Cline, Windsurf, Continue, Zed.
## Quick start
1. Enable this plugin: *Project → Project Settings → Plugins* → tick **Agent Tools**.
2. Confirm the Output panel shows `[agent_tools] listening on 127.0.0.1:9920`.
3. Configure your MCP client to run the bridge — see the [full setup guide](https://github.com/BlakeBukowsky/GodotTools#configure-your-agent) for per-client config paths.
Simplest user-scoped config (for Claude Code, Cursor, Windsurf, etc. — different config file per client):
```json
{
"mcpServers": {
"godot-agent-tools": {
"command": "npx",
"args": ["-y", "godot-agent-tools-mcp"]
}
}
}
```
Full documentation, troubleshooting, and tool catalog: **https://github.com/BlakeBukowsky/GodotTools**
## Raw TCP protocol (if you want to skip MCP)
Line-delimited JSON-RPC on `127.0.0.1:9920`:
```json
{"id": 1, "method": "scene.current", "params": {}}
```
Response:
```json
{"id": 1, "result": {"open": false}}
```
## License
MIT — see [LICENSE](LICENSE).
+187
View File
@@ -0,0 +1,187 @@
# Auto-run driver for run.scene_headless (input simulation, multi-screenshot,
# state dump, deterministic seed). Reads a per-call config from
# res://.godot/agent_tools/headless/config.json, instances the target scene,
# injects scripted input per frame, captures screenshots at specified frames,
# optionally writes a final-state JSON dump, then quits.
@tool
extends Node
const CONFIG_PATH := "res://.godot/agent_tools/headless/config.json"
const Coerce := preload("res://addons/agent_tools/tools/_coerce.gd")
var _input_script: Array = []
var _screenshots: Array = [] # [{frame, path}, ...]
var _state_dump: bool = false
var _state_dump_path: String = ""
var _quit_frame: int = 120
var _frame: int = 0
var _target_inst: Node = null
func _ready() -> void:
var f := FileAccess.open(CONFIG_PATH, FileAccess.READ)
if f == null:
push_error("[headless_driver] config not found at %s" % CONFIG_PATH)
get_tree().quit(2)
return
var parsed = JSON.parse_string(f.get_as_text())
f.close()
if typeof(parsed) != TYPE_DICTIONARY:
push_error("[headless_driver] malformed config")
get_tree().quit(2)
return
# Seed the global RNG before instancing so the target scene's randomness
# (randi/randf in _ready, etc.) is deterministic per-seed.
if parsed.has("seed"):
seed(int(parsed.seed))
_input_script = parsed.get("input_script", [])
_screenshots = parsed.get("screenshots", [])
_state_dump = bool(parsed.get("state_dump", false))
_state_dump_path = str(parsed.get("state_dump_path", ""))
_quit_frame = int(parsed.get("quit_frame", 120))
var target_scene_path: String = parsed.get("target_scene", "")
if target_scene_path == "":
push_error("[headless_driver] config missing 'target_scene'")
get_tree().quit(2)
return
var packed = load(target_scene_path)
if packed == null:
push_error("[headless_driver] failed to load target %s" % target_scene_path)
get_tree().quit(2)
return
_target_inst = packed.instantiate()
add_child(_target_inst)
print("[headless_driver] driving %s for %d frames, %d events, %d screenshots%s" %
[target_scene_path, _quit_frame, _input_script.size(), _screenshots.size(),
", state_dump=on" if _state_dump else ""])
func _process(_delta: float) -> void:
# 1. Inject input events scheduled for this frame.
for spec in _input_script:
if int(spec.get("frame", -1)) == _frame:
_inject(spec)
# 2. Capture screenshots scheduled for this frame.
for shot in _screenshots:
if int(shot.get("frame", -1)) == _frame:
_capture(str(shot.get("path", "")))
if _frame >= _quit_frame:
if _state_dump and _state_dump_path != "":
_write_state_dump()
print("[headless_driver] done at frame %d" % _frame)
get_tree().quit(0)
return
_frame += 1
func _inject(spec: Dictionary) -> void:
match spec.get("type", ""):
"action_press":
Input.action_press(spec.get("action", ""), float(spec.get("strength", 1.0)))
"action_release":
Input.action_release(spec.get("action", ""))
"action_tap":
Input.action_press(spec.get("action", ""), float(spec.get("strength", 1.0)))
Input.action_release(spec.get("action", ""))
"key":
var ek := InputEventKey.new()
var kc = spec.get("keycode", 0)
if kc is String:
ek.physical_keycode = OS.find_keycode_from_string(kc)
else:
ek.physical_keycode = int(kc)
ek.pressed = bool(spec.get("pressed", true))
Input.parse_input_event(ek)
"mouse_click":
var pos = spec.get("position", [0, 0])
var v := Vector2(pos[0], pos[1])
var button := int(spec.get("button", 1))
var down := InputEventMouseButton.new()
down.button_index = button
down.pressed = true
down.position = v
down.global_position = v
Input.parse_input_event(down)
var up := InputEventMouseButton.new()
up.button_index = button
up.pressed = false
up.position = v
up.global_position = v
Input.parse_input_event(up)
"mouse_motion":
var pos = spec.get("position", [0, 0])
var em := InputEventMouseMotion.new()
em.position = Vector2(pos[0], pos[1])
em.global_position = em.position
Input.parse_input_event(em)
_:
push_warning("[headless_driver] unknown event type: %s" % spec.get("type", "<missing>"))
func _capture(path: String) -> void:
if path == "":
return
var vp := get_tree().root.get_viewport()
if vp == null:
push_warning("[headless_driver] no root viewport")
return
var tex := vp.get_texture()
if tex == null:
push_warning("[headless_driver] viewport has no texture (running under dummy renderer?)")
return
var img := tex.get_image()
if img == null or img.is_empty():
push_warning("[headless_driver] failed to capture image at frame %d" % _frame)
return
var dir := path.get_base_dir()
if not DirAccess.dir_exists_absolute(dir):
DirAccess.make_dir_recursive_absolute(dir)
var err := img.save_png(path)
if err != OK:
push_warning("[headless_driver] save_png failed: %d" % err)
else:
print("[headless_driver] frame %d -> %s" % [_frame, path])
func _write_state_dump() -> void:
if _target_inst == null:
return
var snapshot := _node_snapshot(_target_inst, _target_inst)
var f := FileAccess.open(_state_dump_path, FileAccess.WRITE)
if f == null:
push_warning("[headless_driver] failed to open state dump path: %s" % _state_dump_path)
return
f.store_string(JSON.stringify(snapshot, " "))
f.close()
print("[headless_driver] state dump -> %s" % _state_dump_path)
# Mirrors scene.inspect's shape, plus a small selection of commonly-useful
# properties. Kept narrow on purpose — full get_property_list() dumps balloon.
static func _node_snapshot(node: Node, scene_root: Node) -> Dictionary:
var d := {
"name": String(node.name),
"class": node.get_class(),
"node_path": "." if node == scene_root else String(scene_root.get_path_to(node)),
"script": "",
"properties": {},
"children": [],
}
var scr := node.get_script() as Script
if scr:
d.script = scr.resource_path
# Common props worth verifying, if present on this node type.
for prop_name in ["visible", "position", "global_position", "size", "text",
"value", "modulate", "color", "rotation", "scale", "disabled", "pressed"]:
if prop_name in node:
d.properties[prop_name] = Coerce.to_json(node.get(prop_name))
for child in node.get_children():
if child.owner == scene_root or child == scene_root:
d.children.append(_node_snapshot(child, scene_root))
return d
@@ -0,0 +1 @@
uid://iko41fhoo3mm
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://addons/agent_tools/headless/driver.gd" id="1"]
[node name="HeadlessDriver" type="Node"]
script = ExtResource("1")
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://d2wklut1fj5ed"
path="res://.godot/imported/icon.png-381bc5920e04d060db359f1234d8c8b0.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/agent_tools/icon.png"
dest_files=["res://.godot/imported/icon.png-381bc5920e04d060db359f1234d8c8b0.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
+9
View File
@@ -0,0 +1,9 @@
[plugin]
name="Agent Tools"
description="70 tools + 6 MCP Resources for coding agents to edit scenes, wire signals, create resources, and more through Godot's real APIs instead of hand-editing .tscn/.tres files. If you haven't already, configure your MCP client (Claude Code, Cursor, Cline, etc.) per the README: https://github.com/BlakeBukowsky/GodotTools"
author="Blake Bukowsky"
version="0.3.1"
license="MIT"
script="plugin.gd"
icon="icon.png"
+132
View File
@@ -0,0 +1,132 @@
@tool
extends EditorPlugin
const Server := preload("res://addons/agent_tools/server.gd")
const Registry := preload("res://addons/agent_tools/registry.gd")
const DEFAULT_PORT := 9920
const PORT_RANGE_END := 9929 # scan 9920..9929 inclusive
const PORT_SETTING := "agent_tools/port"
const BRIDGE_AUTOLOAD_NAME := "_MCPGameBridge"
const BRIDGE_AUTOLOAD_PATH := "res://addons/agent_tools/runtime/game_bridge.gd"
const DEFAULT_INTERFACE := "127.0.0.1"
const INTERFACE_SETTING := "agent_tools/interface"
var _server: Server
var _registry: Registry
var _bound_port: int = -1
var _session_file_path: String = ""
func _enter_tree() -> void:
_registry = Registry.new()
_server = Server.new(_registry)
add_child(_server)
# Interface resolution
# Defaults to 127.0.0.1, override with project.godot agent_tools/interface
var forced_interface := DEFAULT_INTERFACE
if ProjectSettings.has_setting(INTERFACE_SETTING):
forced_interface = ProjectSettings.get_setting(INTERFACE_SETTING)
# Port resolution:
# 1. If 'agent_tools/port' is set in project.godot, honor it strictly (fail if busy).
# 2. Otherwise try DEFAULT_PORT..PORT_RANGE_END and use the first free one.
# This lets multiple Godot editors coexist: first-to-start takes 9920, second takes 9921, etc.
var forced_port := -1
if ProjectSettings.has_setting(PORT_SETTING):
forced_port = int(ProjectSettings.get_setting(PORT_SETTING))
if forced_port != -1:
if _server.start(forced_port, forced_interface):
_bound_port = forced_port
else:
push_error("[agent_tools] configured port %s:%d is busy. Either close the other process or change 'agent_tools/port' in project.godot." % [forced_interface, forced_port])
else:
for p in range(DEFAULT_PORT, PORT_RANGE_END + 1):
if _server.start(p, forced_interface):
_bound_port = p
break
if _bound_port == -1:
push_error("[agent_tools] all ports %d-%d are busy on %s. Close other Godot editors running agent_tools, or set 'agent_tools/port' in project.godot." % [DEFAULT_PORT, PORT_RANGE_END, forced_interface])
if _bound_port != -1:
print("[agent_tools] listening on %s:%d" % [forced_interface, _bound_port])
print("[agent_tools] If you haven't already, configure your MCP client (Claude Code, Cursor, Cline, etc.) to use these tools.")
print("[agent_tools] Setup guide: https://github.com/BlakeBukowsky/GodotTools#configure-your-agent")
_write_session_registry()
# Register the runtime bridge autoload so editor.game_screenshot / logs.read
# can reach the running game. add_autoload_singleton() writes an entry to
# project.godot; remove_autoload_singleton() in _exit_tree cleans it up.
if not ProjectSettings.has_setting("autoload/" + BRIDGE_AUTOLOAD_NAME):
add_autoload_singleton(BRIDGE_AUTOLOAD_NAME, BRIDGE_AUTOLOAD_PATH)
func _exit_tree() -> void:
_remove_session_registry()
if _server:
_server.stop()
_server.queue_free()
_server = null
_registry = null
_bound_port = -1
if ProjectSettings.has_setting("autoload/" + BRIDGE_AUTOLOAD_NAME):
remove_autoload_singleton(BRIDGE_AUTOLOAD_NAME)
# Session registry is a per-PID JSON file under a cross-project, user-scoped
# directory. The MCP shim reads these on startup so `session.list` can discover
# every running Godot editor + which port to talk to each.
func _write_session_registry() -> void:
var dir_path := _session_registry_dir()
if dir_path == "":
return
if not DirAccess.dir_exists_absolute(dir_path):
var derr := DirAccess.make_dir_recursive_absolute(dir_path)
if derr != OK:
push_warning("[agent_tools] could not create session registry dir: %s" % dir_path)
return
var pid := OS.get_process_id()
_session_file_path = dir_path.path_join("%d.json" % pid)
var project_name := ""
if ProjectSettings.has_setting("application/config/name"):
project_name = str(ProjectSettings.get_setting("application/config/name"))
var version := Engine.get_version_info()
var entry := {
"pid": pid,
"port": _bound_port,
"project_path": ProjectSettings.globalize_path("res://"),
"project_name": project_name,
"godot_version": "%d.%d.%d-%s" % [version.major, version.minor, version.patch, version.status],
"started_at_unix": int(Time.get_unix_time_from_system()),
}
var f := FileAccess.open(_session_file_path, FileAccess.WRITE)
if f == null:
push_warning("[agent_tools] could not write session file: %s" % _session_file_path)
return
f.store_string(JSON.stringify(entry, " "))
f.close()
func _remove_session_registry() -> void:
if _session_file_path == "":
return
if FileAccess.file_exists(_session_file_path):
@warning_ignore("return_value_discarded")
DirAccess.remove_absolute(_session_file_path)
_session_file_path = ""
# Cross-project location: <user home>/.godot-agent-tools/sessions/. Chosen to
# sit outside any single project's res:// or user:// tree.
func _session_registry_dir() -> String:
var home := OS.get_environment("USERPROFILE")
if home == "":
home = OS.get_environment("HOME")
if home == "":
return ""
return home.path_join(".godot-agent-tools").path_join("sessions")
+1
View File
@@ -0,0 +1 @@
uid://bkvb64lq51xyg
+129
View File
@@ -0,0 +1,129 @@
@tool
extends RefCounted
const SceneTools := preload("res://addons/agent_tools/tools/scene_tools.gd")
const SignalTools := preload("res://addons/agent_tools/tools/signal_tools.gd")
const ScriptTools := preload("res://addons/agent_tools/tools/script_tools.gd")
const ResourceTools := preload("res://addons/agent_tools/tools/resource_tools.gd")
const RefsTools := preload("res://addons/agent_tools/tools/refs_tools.gd")
const ProjectTools := preload("res://addons/agent_tools/tools/project_tools.gd")
const EditorTools := preload("res://addons/agent_tools/tools/editor_tools.gd")
const DocsTools := preload("res://addons/agent_tools/tools/docs_tools.gd")
const InputTools := preload("res://addons/agent_tools/tools/input_tools.gd")
const RunTools := preload("res://addons/agent_tools/tools/run_tools.gd")
const FsTools := preload("res://addons/agent_tools/tools/fs_tools.gd")
const AnimationTools := preload("res://addons/agent_tools/tools/animation_tools.gd")
const UserFsTools := preload("res://addons/agent_tools/tools/user_fs_tools.gd")
const PerformanceTools := preload("res://addons/agent_tools/tools/performance_tools.gd")
const TestTools := preload("res://addons/agent_tools/tools/test_tools.gd")
const ClientTools := preload("res://addons/agent_tools/tools/client_tools.gd")
const PhysicsTools := preload("res://addons/agent_tools/tools/physics_tools.gd")
const ThemeTools := preload("res://addons/agent_tools/tools/theme_tools.gd")
# Returns {"data": <any>} on success, {"error": {"code": int, "message": str}} on failure.
func dispatch(method: String, params: Dictionary) -> Dictionary:
match method:
"scene.new": return SceneTools.new_scene(params)
"scene.add_node": return SceneTools.add_node(params)
"scene.instance_packed": return SceneTools.instance_packed(params)
"scene.duplicate_node": return SceneTools.duplicate_node(params)
"scene.remove_node": return SceneTools.remove_node(params)
"scene.reparent": return SceneTools.reparent(params)
"scene.set_property": return SceneTools.set_property(params)
"scene.get_property": return SceneTools.get_property(params)
"scene.call_method": return SceneTools.call_method(params)
"scene.build_tree": return SceneTools.build_tree(params)
"scene.open": return SceneTools.open_scene(params)
"scene.save": return SceneTools.save_scene(params)
"scene.current": return SceneTools.current(params)
"scene.inspect": return SceneTools.inspect(params)
"scene.capture_screenshot": return SceneTools.capture_screenshot(params)
"signal.connect": return SignalTools.connect_signal(params)
"signal.disconnect": return SignalTools.disconnect_signal(params)
"signal.list": return SignalTools.list_signals(params)
"script.create": return ScriptTools.create(params)
"script.attach": return ScriptTools.attach(params)
"script.patch": return ScriptTools.patch(params)
"resource.create": return ResourceTools.create(params)
"resource.set_property": return ResourceTools.set_property(params)
"resource.call_method": return ResourceTools.call_method(params)
"refs.validate_project": return RefsTools.validate_project(params)
"refs.find_usages": return RefsTools.find_usages(params)
"refs.rename": return RefsTools.rename(params)
"refs.rename_class": return RefsTools.rename_class(params)
"project.get_setting": return ProjectTools.get_setting(params)
"project.set_setting": return ProjectTools.set_setting(params)
"autoload.add": return ProjectTools.autoload_add(params)
"autoload.remove": return ProjectTools.autoload_remove(params)
"autoload.list": return ProjectTools.autoload_list(params)
"editor.reload_filesystem": return EditorTools.reload_filesystem(params)
"editor.save_all_scenes": return EditorTools.save_all_scenes(params)
"editor.state": return EditorTools.state(params)
"editor.selection_get": return EditorTools.selection_get(params)
"editor.selection_set": return EditorTools.selection_set(params)
"editor.game_screenshot": return EditorTools.game_screenshot(params)
"logs.read": return EditorTools.logs_read(params)
"logs.clear": return EditorTools.logs_clear(params)
"docs.class_ref": return DocsTools.class_ref(params)
"input_map.add_action": return InputTools.add_action(params)
"input_map.add_event": return InputTools.add_event(params)
"input_map.list": return InputTools.list_actions(params)
"input_map.remove_action": return InputTools.remove_action(params)
"input_map.remove_event": return InputTools.remove_event(params)
"run.scene_headless": return RunTools.scene_headless(params)
"fs.list": return FsTools.list(params)
"fs.read_text": return FsTools.read_text(params)
"fs.write_text": return FsTools.write_text(params)
"user_fs.read": return UserFsTools.read(params)
"user_fs.list": return UserFsTools.list(params)
"animation.list": return AnimationTools.list_animations(params)
"animation.add_animation": return AnimationTools.add_animation(params)
"animation.remove_animation": return AnimationTools.remove_animation(params)
"animation.add_value_track": return AnimationTools.add_value_track(params)
"performance.monitors": return PerformanceTools.monitors(params)
"test.run": return TestTools.run(params)
"client.list": return ClientTools.list_clients(params)
"client.configure": return ClientTools.configure(params)
"client.remove": return ClientTools.remove(params)
"physics.autofit_collision_shape_2d": return PhysicsTools.autofit_collision_shape_2d(params)
"theme.set_color": return ThemeTools.set_color(params)
"theme.set_constant": return ThemeTools.set_constant(params)
"theme.set_font_size": return ThemeTools.set_font_size(params)
"theme.set_stylebox_flat": return ThemeTools.set_stylebox_flat(params)
"batch.execute": return batch_execute(params)
_:
return {"error": {"code": -32601, "message": "method not found: %s" % method}}
# batch.execute — run multiple tool calls in one round trip.
# Params: {calls: [{method, params?}, ...], stop_on_error?: false}
# Returns: {results: [{method, ok, data|error, index}, ...]}
func batch_execute(params: Dictionary) -> Dictionary:
var calls: Array = params.get("calls", [])
var stop_on_error: bool = params.get("stop_on_error", false)
if calls.is_empty():
return {"error": {"code": -32602, "message": "missing 'calls' (array of {method, params})"}}
var results: Array = []
for i in calls.size():
var entry = calls[i]
if typeof(entry) != TYPE_DICTIONARY or not entry.has("method"):
results.append({"index": i, "ok": false, "error": "each call needs {method, params?}"})
if stop_on_error:
break
continue
var sub_method: String = entry.method
var sub_params: Dictionary = entry.get("params", {})
var sub_result: Dictionary = dispatch(sub_method, sub_params)
var row := {"index": i, "method": sub_method}
if sub_result.has("error"):
row["ok"] = false
row["error"] = sub_result.error
else:
row["ok"] = true
row["data"] = sub_result.get("data")
results.append(row)
if stop_on_error and not row.ok:
break
return {"data": {"results": results, "count": results.size(), "requested": calls.size()}}
+1
View File
@@ -0,0 +1 @@
uid://b5pe4omwjkjgo
@@ -0,0 +1,31 @@
# Custom Logger that forwards every captured message to the game bridge's
# log buffer. Has to live in its own top-level file — nested classes don't
# reliably register Logger's virtual overrides (_log_error / _log_message).
extends Logger
var _bridge: Node
func _init(bridge: Node) -> void:
_bridge = bridge
func _log_error(function: String, file: String, line: int, code: String, rationale: String, editor_notify: bool, error_type: int, script_backtraces: Array) -> void:
# error_type is Logger.ErrorType: 0=ERROR, 1=WARNING, 2=SCRIPT, 3=SHADER.
var level: String
match error_type:
1: level = "WARNING"
2: level = "SCRIPT"
3: level = "SHADER"
_: level = "ERROR"
var msg: String = rationale if rationale != "" else code
if file != "" and line > 0:
msg += " (at %s:%d in %s)" % [file, line, function]
if _bridge and _bridge.has_method("log_entry"):
_bridge.log_entry(level, msg)
func _log_message(message: String, error: bool) -> void:
if _bridge and _bridge.has_method("log_entry"):
_bridge.log_entry("ERROR" if error else "INFO", message.strip_edges())
@@ -0,0 +1 @@
uid://wosqs8dlh1vx
+134
View File
@@ -0,0 +1,134 @@
# MCPGameBridge — runtime autoload registered by the Agent Tools plugin.
#
# Bridges the editor-side agent tools to the *running* game via a file-based
# request/response channel under res://.godot/agent_tools/bridge/. Currently:
# - screenshot: capture the running viewport, save to a path
# - logs: append print/push_error/push_warning output to a buffer
#
# This script ONLY runs in the game process (checks Engine.is_editor_hint()
# on startup and disables itself in the editor). The editor plugin registers
# it via add_autoload_singleton() on _enter_tree and removes it on _exit_tree.
extends Node
const BridgeLogger := preload("res://addons/agent_tools/runtime/bridge_logger.gd")
const BRIDGE_DIR := "res://.godot/agent_tools/bridge"
const REQUEST_PATH := BRIDGE_DIR + "/request.json"
const RESPONSE_PATH := BRIDGE_DIR + "/response.json"
const LOGS_PATH := BRIDGE_DIR + "/logs.json"
const POLL_INTERVAL_SEC := 0.05 # 20 Hz — cheap enough, responsive enough
const LOG_BUFFER_MAX := 500
var _timer: Timer
var _log_buffer: Array = []
var _initialized := false
func _ready() -> void:
if Engine.is_editor_hint():
# Tool mode — don't do anything inside the editor. The bridge only
# makes sense in the running game.
return
_initialized = true
_ensure_bridge_dir()
# Poll for editor requests on a timer so we don't eat a _process callback.
_timer = Timer.new()
_timer.wait_time = POLL_INTERVAL_SEC
_timer.one_shot = false
_timer.autostart = true
_timer.timeout.connect(_poll_request)
add_child(_timer)
# Attach our custom logger to capture print / push_error / push_warning
# from the game and mirror them to the log buffer file so logs.read can
# pick them up from the editor side.
var logger := BridgeLogger.new(self)
OS.add_logger(logger)
func _ensure_bridge_dir() -> void:
if not DirAccess.dir_exists_absolute(BRIDGE_DIR):
@warning_ignore("return_value_discarded")
DirAccess.make_dir_recursive_absolute(BRIDGE_DIR)
# Called by MCPBridgeLogger whenever the game writes to stdout/stderr.
func log_entry(level: String, message: String) -> void:
_log_buffer.append({
"level": level,
"message": message,
"time_ms": Time.get_ticks_msec(),
})
if _log_buffer.size() > LOG_BUFFER_MAX:
_log_buffer = _log_buffer.slice(_log_buffer.size() - LOG_BUFFER_MAX)
# Flush to disk so editor-side logs.read sees it. Debounced by poll interval
# in practice because each log call flushes but writes are cheap.
_flush_logs()
func _flush_logs() -> void:
var f := FileAccess.open(LOGS_PATH, FileAccess.WRITE)
if f == null:
return
f.store_string(JSON.stringify(_log_buffer))
f.close()
func _poll_request() -> void:
if not FileAccess.file_exists(REQUEST_PATH):
return
var f := FileAccess.open(REQUEST_PATH, FileAccess.READ)
if f == null:
return
var body := f.get_as_text()
f.close()
@warning_ignore("return_value_discarded")
DirAccess.remove_absolute(REQUEST_PATH)
var parsed = JSON.parse_string(body)
if typeof(parsed) != TYPE_DICTIONARY:
_write_response({"id": "", "error": "malformed request"})
return
var req_id: String = parsed.get("id", "")
match parsed.get("type", ""):
"screenshot":
_handle_screenshot(req_id, parsed)
_:
_write_response({"id": req_id, "error": "unknown request type: %s" % parsed.get("type", "<missing>")})
func _handle_screenshot(req_id: String, req: Dictionary) -> void:
var output_path: String = req.get("output", "")
if output_path == "":
_write_response({"id": req_id, "error": "missing 'output'"})
return
var vp := get_viewport()
if vp == null:
_write_response({"id": req_id, "error": "no viewport available"})
return
var tex := vp.get_texture()
if tex == null:
_write_response({"id": req_id, "error": "viewport has no texture"})
return
var img := tex.get_image()
if img == null or img.is_empty():
_write_response({"id": req_id, "error": "failed to capture image"})
return
var dir := output_path.get_base_dir()
if not DirAccess.dir_exists_absolute(dir):
@warning_ignore("return_value_discarded")
DirAccess.make_dir_recursive_absolute(dir)
var save_err := img.save_png(output_path)
if save_err != OK:
_write_response({"id": req_id, "error": "save_png failed: %d" % save_err})
return
_write_response({"id": req_id, "path": output_path})
func _write_response(payload: Dictionary) -> void:
var f := FileAccess.open(RESPONSE_PATH, FileAccess.WRITE)
if f == null:
return
f.store_string(JSON.stringify(payload))
f.close()
@@ -0,0 +1 @@
uid://cds186n8uv4f0
+125
View File
@@ -0,0 +1,125 @@
@tool
extends Node
# Line-delimited JSON-RPC over TCP on localhost.
# One request per line: {"id": <any>, "method": "<name>", "params": {...}}
# One response per line: {"id": <echoed>, "result": <data>} OR {"id": ..., "error": {"code": int, "message": str, "data"?: any}}
const MAX_MESSAGE_BYTES := 1_048_576 # 1 MiB per line — safety cap
var _tcp: TCPServer
var _clients: Array = [] # Array of {peer: StreamPeerTCP, buf: PackedByteArray}
var _registry
func _init(registry) -> void:
_registry = registry
func start(port: int, interface: String = "127.0.0.1") -> bool:
_tcp = TCPServer.new()
var err := _tcp.listen(port, interface)
if err != OK:
_tcp = null
return false
set_process(true)
return true
func stop() -> void:
set_process(false)
for c in _clients:
(c.peer as StreamPeerTCP).disconnect_from_host()
_clients.clear()
if _tcp:
_tcp.stop()
_tcp = null
func _process(_delta: float) -> void:
if _tcp == null:
return
while _tcp.is_connection_available():
var peer := _tcp.take_connection()
_clients.append({"peer": peer, "buf": PackedByteArray()})
var still_alive: Array = []
for c in _clients:
var peer: StreamPeerTCP = c.peer
peer.poll()
if peer.get_status() != StreamPeerTCP.STATUS_CONNECTED:
continue
var available := peer.get_available_bytes()
if available > 0:
var chunk := peer.get_data(available)
if chunk[0] == OK:
c.buf.append_array(chunk[1])
_drain_messages(c)
if (c.buf as PackedByteArray).size() > MAX_MESSAGE_BYTES:
_send_error(peer, null, -32700, "message exceeds %d bytes" % MAX_MESSAGE_BYTES)
peer.disconnect_from_host()
continue
still_alive.append(c)
_clients = still_alive
func _drain_messages(c: Dictionary) -> void:
while true:
var buf: PackedByteArray = c.buf
var nl := buf.find(0x0A) # \n
if nl < 0:
return
var line_bytes := buf.slice(0, nl)
c.buf = buf.slice(nl + 1)
var line := line_bytes.get_string_from_utf8().strip_edges()
if line == "":
continue
_handle_line(c.peer, line)
func _handle_line(peer: StreamPeerTCP, line: String) -> void:
var parsed = JSON.parse_string(line)
if typeof(parsed) != TYPE_DICTIONARY:
_send_error(peer, null, -32700, "parse error — expected JSON object")
return
var id = parsed.get("id")
var method: String = parsed.get("method", "")
var params: Dictionary = parsed.get("params", {})
if method == "":
_send_error(peer, id, -32600, "missing 'method'")
return
var result: Dictionary = _registry.dispatch(method, params)
# Guard against silent tool-module failures: when a tool's .gd file has a parse
# error, preload() returns null, calls on it produce no output, and dispatch
# returns its default {}. Surface that instead of sending {"result": null}.
if result.is_empty():
_send_error(peer, id, -32000,
"tool returned empty response — likely a parse error in the tool module. Check Godot's Output panel for the actual error.",
{"method": method})
elif result.has("error"):
var e: Dictionary = result.error
_send_error(peer, id, e.get("code", -32000), e.get("message", ""), e.get("data"))
elif result.has("data"):
_send_result(peer, id, result.data)
else:
_send_error(peer, id, -32000,
"tool returned malformed response (missing both 'data' and 'error')",
{"method": method})
func _send_result(peer: StreamPeerTCP, id, data) -> void:
_write(peer, {"id": id, "result": data})
func _send_error(peer: StreamPeerTCP, id, code: int, msg: String, data = null) -> void:
var err := {"code": code, "message": msg}
if data != null:
err["data"] = data
_write(peer, {"id": id, "error": err})
func _write(peer: StreamPeerTCP, obj: Dictionary) -> void:
var line := JSON.stringify(obj) + "\n"
peer.put_data(line.to_utf8_buffer())
+1
View File
@@ -0,0 +1 @@
uid://pcfsf0ijlm8x
+306
View File
@@ -0,0 +1,306 @@
@tool
extends RefCounted
# Shared JSON-to-Godot type coercion for scene.set_property, resource.set_property,
# and any other tool that assigns a user-provided value into a typed property slot.
#
# Contract:
# coerce(value, target_type: int) -> Variant
#
# Returns the coerced value on success. On mismatch, returns a Dictionary with
# a single "_error" key describing the failure — callers check `is Dictionary`
# and forward the message as a tool-level error.
#
# New types go here. Don't re-add `_coerce` to individual tool files; route
# through Coerce.coerce() instead.
static func coerce(value, target_type: int):
match target_type:
TYPE_BOOL:
# Godot 4 has no `bool()` constructor (it errors at runtime). Convert via
# truthiness, but parse strings explicitly so "false"/"0" don't read as
# truthy the way any non-empty string otherwise would.
if value is String:
return value.strip_edges().to_lower() in ["true", "1", "yes", "on"]
return true if value else false
TYPE_INT:
return int(value)
TYPE_FLOAT:
return float(value)
TYPE_STRING, TYPE_STRING_NAME:
return String(value)
TYPE_NODE_PATH:
return NodePath(String(value))
TYPE_VECTOR2:
if value is Array and value.size() == 2:
return Vector2(value[0], value[1])
return {"_error": "Vector2 expects [x, y]"}
TYPE_VECTOR2I:
if value is Array and value.size() == 2:
return Vector2i(int(value[0]), int(value[1]))
return {"_error": "Vector2i expects [x, y]"}
TYPE_VECTOR3:
if value is Array and value.size() == 3:
return Vector3(value[0], value[1], value[2])
return {"_error": "Vector3 expects [x, y, z]"}
TYPE_VECTOR3I:
if value is Array and value.size() == 3:
return Vector3i(int(value[0]), int(value[1]), int(value[2]))
return {"_error": "Vector3i expects [x, y, z]"}
TYPE_VECTOR4:
if value is Array and value.size() == 4:
return Vector4(value[0], value[1], value[2], value[3])
return {"_error": "Vector4 expects [x, y, z, w]"}
TYPE_VECTOR4I:
if value is Array and value.size() == 4:
return Vector4i(int(value[0]), int(value[1]), int(value[2]), int(value[3]))
return {"_error": "Vector4i expects [x, y, z, w]"}
TYPE_RECT2:
if value is Array and value.size() == 4:
return Rect2(value[0], value[1], value[2], value[3])
return {"_error": "Rect2 expects [x, y, width, height]"}
TYPE_RECT2I:
if value is Array and value.size() == 4:
return Rect2i(int(value[0]), int(value[1]), int(value[2]), int(value[3]))
return {"_error": "Rect2i expects [x, y, width, height]"}
TYPE_QUATERNION:
if value is Array and value.size() == 4:
return Quaternion(value[0], value[1], value[2], value[3])
return {"_error": "Quaternion expects [x, y, z, w]"}
TYPE_TRANSFORM2D:
# TRS form: {origin: [x,y], rotation: radians, scale: [x,y]} — all optional.
if value is Dictionary:
var origin = value.get("origin", [0, 0])
var rotation: float = float(value.get("rotation", 0.0))
var scale_v = value.get("scale", [1, 1])
var skew: float = float(value.get("skew", 0.0))
if not (origin is Array and origin.size() == 2 and scale_v is Array and scale_v.size() == 2):
return {"_error": "Transform2D origin/scale expect [x, y]"}
return Transform2D(
rotation,
Vector2(scale_v[0], scale_v[1]),
skew,
Vector2(origin[0], origin[1])
)
return {"_error": "Transform2D expects {origin: [x,y], rotation: radians, scale: [x,y], skew?}"}
TYPE_TRANSFORM3D:
# TRS form: {origin: [x,y,z], rotation: [x,y,z] euler radians, scale: [x,y,z]}.
if value is Dictionary:
var origin = value.get("origin", [0, 0, 0])
var rotation = value.get("rotation", [0, 0, 0])
var scale_v = value.get("scale", [1, 1, 1])
if not (origin is Array and origin.size() == 3
and rotation is Array and rotation.size() == 3
and scale_v is Array and scale_v.size() == 3):
return {"_error": "Transform3D origin/rotation/scale expect [x, y, z]"}
var basis := Basis.from_euler(Vector3(rotation[0], rotation[1], rotation[2]))
basis = basis.scaled(Vector3(scale_v[0], scale_v[1], scale_v[2]))
return Transform3D(basis, Vector3(origin[0], origin[1], origin[2]))
return {"_error": "Transform3D expects {origin: [x,y,z], rotation: [x,y,z] (euler rad), scale: [x,y,z]}"}
TYPE_BASIS:
if value is Dictionary:
var rotation = value.get("rotation", [0, 0, 0])
var scale_v = value.get("scale", [1, 1, 1])
if not (rotation is Array and rotation.size() == 3
and scale_v is Array and scale_v.size() == 3):
return {"_error": "Basis rotation/scale expect [x, y, z]"}
return Basis.from_euler(Vector3(rotation[0], rotation[1], rotation[2])).scaled(
Vector3(scale_v[0], scale_v[1], scale_v[2]))
return {"_error": "Basis expects {rotation: [x,y,z] (euler rad), scale: [x,y,z]}"}
TYPE_AABB:
if value is Dictionary:
var pos = value.get("position", [0, 0, 0])
var size = value.get("size", [0, 0, 0])
if not (pos is Array and pos.size() == 3 and size is Array and size.size() == 3):
return {"_error": "AABB position/size expect [x, y, z]"}
return AABB(Vector3(pos[0], pos[1], pos[2]), Vector3(size[0], size[1], size[2]))
return {"_error": "AABB expects {position: [x,y,z], size: [x,y,z]}"}
TYPE_PLANE:
if value is Dictionary:
var normal = value.get("normal", [0, 1, 0])
var d: float = float(value.get("d", 0.0))
if not (normal is Array and normal.size() == 3):
return {"_error": "Plane normal expects [x, y, z]"}
return Plane(Vector3(normal[0], normal[1], normal[2]), d)
return {"_error": "Plane expects {normal: [x,y,z], d: float}"}
TYPE_COLOR:
if value is Array and (value.size() == 3 or value.size() == 4):
var a := float(value[3]) if value.size() == 4 else 1.0
return Color(value[0], value[1], value[2], a)
if value is String:
return Color(value)
return {"_error": "Color expects [r,g,b(,a)] or '#rrggbb(aa)'"}
TYPE_PACKED_STRING_ARRAY:
if value is Array:
var out := PackedStringArray()
for s in value:
out.append(String(s))
return out
return {"_error": "PackedStringArray expects an array of strings"}
TYPE_PACKED_INT32_ARRAY:
if value is Array:
var out := PackedInt32Array()
for i in value:
out.append(int(i))
return out
return {"_error": "PackedInt32Array expects an array of numbers"}
TYPE_PACKED_INT64_ARRAY:
if value is Array:
var out := PackedInt64Array()
for i in value:
out.append(int(i))
return out
return {"_error": "PackedInt64Array expects an array of numbers"}
TYPE_PACKED_FLOAT32_ARRAY:
if value is Array:
var out := PackedFloat32Array()
for f in value:
out.append(float(f))
return out
return {"_error": "PackedFloat32Array expects an array of numbers"}
TYPE_PACKED_FLOAT64_ARRAY:
if value is Array:
var out := PackedFloat64Array()
for f in value:
out.append(float(f))
return out
return {"_error": "PackedFloat64Array expects an array of numbers"}
TYPE_PACKED_VECTOR2_ARRAY:
if value is Array:
var out := PackedVector2Array()
for v in value:
if v is Array and v.size() == 2:
out.append(Vector2(v[0], v[1]))
else:
return {"_error": "PackedVector2Array expects [[x, y], ...]"}
return out
return {"_error": "PackedVector2Array expects an array of [x, y] pairs"}
TYPE_PACKED_VECTOR3_ARRAY:
if value is Array:
var out := PackedVector3Array()
for v in value:
if v is Array and v.size() == 3:
out.append(Vector3(v[0], v[1], v[2]))
else:
return {"_error": "PackedVector3Array expects [[x, y, z], ...]"}
return out
return {"_error": "PackedVector3Array expects an array of [x, y, z] triples"}
TYPE_PACKED_COLOR_ARRAY:
if value is Array:
var out := PackedColorArray()
for c in value:
if c is Array and (c.size() == 3 or c.size() == 4):
var a := float(c[3]) if c.size() == 4 else 1.0
out.append(Color(c[0], c[1], c[2], a))
elif c is String:
out.append(Color(c))
else:
return {"_error": "PackedColorArray expects [[r,g,b(,a)], ...] or ['#hex', ...]"}
return out
return {"_error": "PackedColorArray expects an array of colors"}
TYPE_OBJECT:
# Auto-load a Resource when the caller passes a "res://..." or "uid://..." path string.
# Without this, Godot silently drops the assignment and leaves the slot null.
if value is String and (value.begins_with("res://") or value.begins_with("uid://")):
var loaded := ResourceLoader.load(value)
if loaded == null:
return {"_error": "failed to load resource: %s" % value}
return loaded
return value
_:
return value
# Inverse of coerce(): convert a Godot value back to a JSON-native form so the
# response echo is unambiguous data rather than Godot's str() stringification
# (which turns Vector2 into "(x, y)", Color into "(r, g, b, a)", null Object
# into "<Object#null>", etc., all of which look like success strings).
static func to_json(value):
match typeof(value):
TYPE_VECTOR2:
return [value.x, value.y]
TYPE_VECTOR2I:
return [int(value.x), int(value.y)]
TYPE_VECTOR3:
return [value.x, value.y, value.z]
TYPE_VECTOR3I:
return [int(value.x), int(value.y), int(value.z)]
TYPE_VECTOR4:
return [value.x, value.y, value.z, value.w]
TYPE_VECTOR4I:
return [int(value.x), int(value.y), int(value.z), int(value.w)]
TYPE_RECT2:
return [value.position.x, value.position.y, value.size.x, value.size.y]
TYPE_RECT2I:
return [int(value.position.x), int(value.position.y), int(value.size.x), int(value.size.y)]
TYPE_COLOR:
return [value.r, value.g, value.b, value.a]
TYPE_QUATERNION:
return [value.x, value.y, value.z, value.w]
TYPE_NODE_PATH:
return String(value)
TYPE_STRING_NAME:
return String(value)
TYPE_TRANSFORM2D:
return {
"origin": [value.origin.x, value.origin.y],
"x": [value.x.x, value.x.y],
"y": [value.y.x, value.y.y],
}
TYPE_TRANSFORM3D:
return {
"origin": [value.origin.x, value.origin.y, value.origin.z],
"basis": to_json(value.basis),
}
TYPE_BASIS:
return [
[value.x.x, value.x.y, value.x.z],
[value.y.x, value.y.y, value.y.z],
[value.z.x, value.z.y, value.z.z],
]
TYPE_AABB:
return {
"position": [value.position.x, value.position.y, value.position.z],
"size": [value.size.x, value.size.y, value.size.z],
}
TYPE_PLANE:
return {
"normal": [value.normal.x, value.normal.y, value.normal.z],
"d": value.d,
}
TYPE_OBJECT:
if value == null:
return null
if value is Resource and value.resource_path != "":
return {"class": value.get_class(), "resource_path": value.resource_path}
return {"class": (value as Object).get_class()}
TYPE_PACKED_BYTE_ARRAY, TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY, \
TYPE_PACKED_FLOAT32_ARRAY, TYPE_PACKED_FLOAT64_ARRAY, TYPE_PACKED_STRING_ARRAY:
return Array(value)
TYPE_PACKED_VECTOR2_ARRAY:
var out_v2: Array = []
for v in value:
out_v2.append([v.x, v.y])
return out_v2
TYPE_PACKED_VECTOR3_ARRAY:
var out_v3: Array = []
for v in value:
out_v3.append([v.x, v.y, v.z])
return out_v3
TYPE_PACKED_COLOR_ARRAY:
var out_c: Array = []
for c in value:
out_c.append([c.r, c.g, c.b, c.a])
return out_c
TYPE_ARRAY:
var out_a: Array = []
for item in value:
out_a.append(to_json(item))
return out_a
TYPE_DICTIONARY:
var out_d: Dictionary = {}
for k in value:
out_d[str(k)] = to_json(value[k])
return out_d
_:
return value
+1
View File
@@ -0,0 +1 @@
uid://cncjf8g7jufrc
+210
View File
@@ -0,0 +1,210 @@
@tool
extends RefCounted
# Tools for working with AnimationPlayer nodes in the currently-edited scene.
# Animations live in AnimationLibrary resources attached to the player; the "" library
# is the default, named libraries prefix animations as "lib/anim".
# animation.list — list animations on an AnimationPlayer with their tracks.
# Params: {node_path}
static func list_animations(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
if node_path == "":
return _err(-32602, "missing 'node_path'")
var root := EditorInterface.get_edited_scene_root()
if root == null:
return _err(-32001, "no scene open")
var node: Node = root if node_path == "." else root.get_node_or_null(node_path)
if node == null:
return _err(-32001, "node not found: %s" % node_path)
if not (node is AnimationPlayer):
return _err(-32602, "node is not an AnimationPlayer: %s (%s)" % [node_path, node.get_class()])
var ap: AnimationPlayer = node
var out: Array = []
for lib_name in ap.get_animation_library_list():
var lib: AnimationLibrary = ap.get_animation_library(lib_name)
for anim_name in lib.get_animation_list():
var anim: Animation = lib.get_animation(anim_name)
var full_name: String
if String(lib_name) == "":
full_name = String(anim_name)
else:
full_name = "%s/%s" % [lib_name, anim_name]
var tracks: Array = []
for i in anim.get_track_count():
tracks.append({
"index": i,
"type": _track_type_name(anim.track_get_type(i)),
"path": String(anim.track_get_path(i)),
"key_count": anim.track_get_key_count(i),
})
out.append({
"name": full_name,
"length": anim.length,
"loop_mode": anim.loop_mode,
"step": anim.step,
"tracks": tracks,
})
return _ok({"node_path": node_path, "animations": out})
# animation.add_animation — create an empty animation in the player's library.
# Params: {node_path, name, length?: 1.0, library?: ""}
static func add_animation(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
var anim_name: String = params.get("name", "")
var length: float = float(params.get("length", 1.0))
var library_name: String = params.get("library", "")
if node_path == "" or anim_name == "":
return _err(-32602, "missing 'node_path' or 'name'")
var root := EditorInterface.get_edited_scene_root()
if root == null:
return _err(-32001, "no scene open")
var node: Node = root if node_path == "." else root.get_node_or_null(node_path)
if node == null:
return _err(-32001, "node not found: %s" % node_path)
if not (node is AnimationPlayer):
return _err(-32602, "node is not an AnimationPlayer: %s (%s)" % [node_path, node.get_class()])
var ap: AnimationPlayer = node
var lib: AnimationLibrary
if ap.has_animation_library(library_name):
lib = ap.get_animation_library(library_name)
else:
lib = AnimationLibrary.new()
var add_err: int = ap.add_animation_library(library_name, lib)
if add_err != OK:
return _err(-32001, "add_animation_library failed: %d" % add_err)
if lib.has_animation(anim_name):
return _err(-32602, "animation already exists: %s" % anim_name)
var anim := Animation.new()
anim.length = length
@warning_ignore("return_value_discarded")
lib.add_animation(anim_name, anim)
EditorInterface.mark_scene_as_unsaved()
return _ok({"name": anim_name, "library": library_name, "length": length})
# animation.remove_animation — delete an animation from the player.
# Params: {node_path, name, library?: ""}
static func remove_animation(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
var anim_name: String = params.get("name", "")
var library_name: String = params.get("library", "")
if node_path == "" or anim_name == "":
return _err(-32602, "missing 'node_path' or 'name'")
var root := EditorInterface.get_edited_scene_root()
if root == null:
return _err(-32001, "no scene open")
var node: Node = root if node_path == "." else root.get_node_or_null(node_path)
if node == null:
return _err(-32001, "node not found: %s" % node_path)
if not (node is AnimationPlayer):
return _err(-32602, "node is not an AnimationPlayer")
var ap: AnimationPlayer = node
if not ap.has_animation_library(library_name):
return _err(-32001, "library not found: '%s'" % library_name)
var lib: AnimationLibrary = ap.get_animation_library(library_name)
if not lib.has_animation(anim_name):
return _err(-32001, "animation not found: %s" % anim_name)
lib.remove_animation(anim_name)
EditorInterface.mark_scene_as_unsaved()
return _ok({"removed": anim_name, "library": library_name})
# animation.add_value_track — add a value track that animates a property over time.
# target_node is resolved relative to the scene root (simpler than the
# AnimationPlayer.root_node-relative resolution, which is fiddly).
# Params: {node_path, animation, target_node, property, keyframes: [{time, value, easing?}]}
static func add_value_track(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
var anim_name: String = params.get("animation", "")
var target_node: String = params.get("target_node", "")
var property_name: String = params.get("property", "")
var keyframes: Array = params.get("keyframes", [])
if node_path == "" or anim_name == "" or target_node == "" or property_name == "":
return _err(-32602, "missing 'node_path', 'animation', 'target_node', or 'property'")
if keyframes.is_empty():
return _err(-32602, "'keyframes' must contain at least one entry")
var root := EditorInterface.get_edited_scene_root()
if root == null:
return _err(-32001, "no scene open")
var node: Node = root if node_path == "." else root.get_node_or_null(node_path)
if node == null:
return _err(-32001, "node not found: %s" % node_path)
if not (node is AnimationPlayer):
return _err(-32602, "node is not an AnimationPlayer")
var ap: AnimationPlayer = node
if not ap.has_animation(anim_name):
return _err(-32001, "animation not found on this player: %s" % anim_name)
var anim: Animation = ap.get_animation(anim_name)
# Validate that the target resolves from the scene root (a simpler contract
# than AP.root_node-relative — callers think in scene-tree terms already).
if root.get_node_or_null(target_node) == null:
return _err(-32001, "target_node does not resolve from scene root: %s" % target_node)
var track_idx: int = anim.add_track(Animation.TYPE_VALUE)
anim.track_set_path(track_idx, NodePath("%s:%s" % [target_node, property_name]))
var max_time: float = 0.0
for kf in keyframes:
if typeof(kf) != TYPE_DICTIONARY or not kf.has("time") or not kf.has("value"):
return _err(-32602, "each keyframe needs {time, value}")
var t: float = float(kf.time)
var transition: float = float(kf.get("easing", 1.0))
anim.track_insert_key(track_idx, t, kf.value, transition)
if t > max_time:
max_time = t
if max_time > anim.length:
anim.length = max_time
EditorInterface.mark_scene_as_unsaved()
return _ok({
"animation": anim_name,
"track_index": track_idx,
"target": "%s:%s" % [target_node, property_name],
"keyframes_added": keyframes.size(),
})
static func _track_type_name(t: int) -> String:
match t:
Animation.TYPE_VALUE:
return "value"
Animation.TYPE_POSITION_3D:
return "position_3d"
Animation.TYPE_ROTATION_3D:
return "rotation_3d"
Animation.TYPE_SCALE_3D:
return "scale_3d"
Animation.TYPE_BLEND_SHAPE:
return "blend_shape"
Animation.TYPE_METHOD:
return "method"
Animation.TYPE_BEZIER:
return "bezier"
Animation.TYPE_AUDIO:
return "audio"
Animation.TYPE_ANIMATION:
return "animation"
_:
return "unknown(%d)" % t
static func _ok(data) -> Dictionary:
return {"data": data}
static func _err(code: int, msg: String) -> Dictionary:
return {"error": {"code": code, "message": msg}}
@@ -0,0 +1 @@
uid://kurfdxg67ggk
+296
View File
@@ -0,0 +1,296 @@
@tool
extends RefCounted
# client.* — manage MCP client config files so users don't have to hand-edit them.
# Each client has its own config schema; we handle three shapes:
# "mcp_servers" → {"mcpServers": {"<name>": {command, args}}}
# Used by Claude Code, Claude Desktop, Cursor, Windsurf.
# "vscode_servers" → {"servers": {"<name>": {command, args}}}
# VS Code's native MCP config.
# "continue_experimental" → {"experimental": {"modelContextProtocolServers":
# [{"transport": {"type": "stdio", command, args}}]}}
# Continue.dev's MCP config shape (array, not dict).
#
# Clients requiring JSONC (with comments) or non-file storage — Zed, VS Code
# user-scope settings.json, Cline — aren't supported yet. Use manual config
# for those; see the README per-client section.
const SERVER_NAME := "godot-agent-tools"
const SERVER_COMMAND := "npx"
const SERVER_ARGS := ["-y", "godot-agent-tools-mcp"]
static func list_clients(_params: Dictionary) -> Dictionary:
var out: Array = []
for client_id in _client_ids():
var info := _client_info(client_id)
if info.has("_error"):
out.append({"client": client_id, "error": info._error})
continue
var res := _read_config(info.path)
var cfg: Dictionary = res.cfg
var installed := _has_server_entry(cfg, info.format)
out.append({
"client": client_id,
"config_path": info.path,
"exists": _path_exists(info.path),
"configured": installed,
"unparseable": res.unparseable,
})
return _ok({"clients": out})
static func configure(params: Dictionary) -> Dictionary:
var client_id: String = params.get("client", "")
var overwrite: bool = params.get("overwrite", false)
if client_id == "":
return _err(-32602, "missing 'client' (supported: %s)" % ", ".join(_client_ids()))
var info := _client_info(client_id)
if info.has("_error"):
return _err(-32602, info._error)
var res := _read_config(info.path)
if res.unparseable:
return _err(-32001, "existing config is not valid JSON — refusing to overwrite (fix or remove it first): %s" % info.path)
var cfg: Dictionary = res.cfg
if _has_server_entry(cfg, info.format) and not overwrite:
return _ok({"client": client_id, "path": info.path, "status": "already_configured"})
_set_server_entry(cfg, info.format)
var write_err := _write_config(info.path, cfg)
if write_err != "":
return _err(-32001, write_err)
return _ok({"client": client_id, "path": info.path, "status": "configured"})
static func remove(params: Dictionary) -> Dictionary:
var client_id: String = params.get("client", "")
if client_id == "":
return _err(-32602, "missing 'client'")
var info := _client_info(client_id)
if info.has("_error"):
return _err(-32602, info._error)
var res := _read_config(info.path)
if res.unparseable:
return _err(-32001, "existing config is not valid JSON — refusing to modify (fix or remove it first): %s" % info.path)
var cfg: Dictionary = res.cfg
if not _has_server_entry(cfg, info.format):
return _ok({"client": client_id, "path": info.path, "status": "not_present"})
_clear_server_entry(cfg, info.format)
var write_err := _write_config(info.path, cfg)
if write_err != "":
return _err(-32001, write_err)
return _ok({"client": client_id, "path": info.path, "status": "removed"})
static func _client_ids() -> Array:
return [
"claude_code_project",
"claude_code_user",
"claude_desktop",
"cursor_project",
"cursor_user",
"windsurf_user",
"continue_user",
"vscode_project",
]
static func _client_info(client_id: String) -> Dictionary:
var home := OS.get_environment("USERPROFILE")
if home == "":
home = OS.get_environment("HOME")
if home == "":
return {"_error": "cannot resolve home directory (neither USERPROFILE nor HOME set)"}
match client_id:
"claude_code_project":
return {"path": ProjectSettings.globalize_path("res://.mcp.json"), "format": "mcp_servers"}
"claude_code_user":
return {"path": home.path_join(".claude.json"), "format": "mcp_servers"}
"cursor_project":
return {"path": ProjectSettings.globalize_path("res://.cursor/mcp.json"), "format": "mcp_servers"}
"cursor_user":
return {"path": home.path_join(".cursor").path_join("mcp.json"), "format": "mcp_servers"}
"windsurf_user":
return {"path": home.path_join(".codeium").path_join("windsurf").path_join("mcp_config.json"), "format": "mcp_servers"}
"continue_user":
return {"path": home.path_join(".continue").path_join("config.json"), "format": "continue_experimental"}
"vscode_project":
return {"path": ProjectSettings.globalize_path("res://.vscode/mcp.json"), "format": "vscode_servers"}
"claude_desktop":
var os_name := OS.get_name()
var path: String
if os_name == "macOS":
path = home.path_join("Library").path_join("Application Support").path_join("Claude").path_join("claude_desktop_config.json")
elif os_name == "Windows":
var appdata := OS.get_environment("APPDATA")
if appdata == "":
return {"_error": "APPDATA env var not set on Windows"}
path = appdata.path_join("Claude").path_join("claude_desktop_config.json")
else:
path = home.path_join(".config").path_join("Claude").path_join("claude_desktop_config.json")
return {"path": path, "format": "mcp_servers"}
_:
return {"_error": "unknown client: %s (supported: %s)" % [client_id, ", ".join(_client_ids())]}
static func _path_exists(path: String) -> bool:
return FileAccess.file_exists(path)
# Returns {"cfg": Dictionary, "unparseable": bool}. unparseable=true means the
# file exists with non-empty content that is NOT a valid JSON object — callers
# that go on to WRITE must abort, since merging into {} would wipe the user's
# data (e.g. a large ~/.claude.json with a transient syntax error, or a JSONC
# file with comments that JSON.parse_string can't handle).
static func _read_config(path: String) -> Dictionary:
var content := ""
if _path_exists(path):
var f := FileAccess.open(path, FileAccess.READ)
if f:
content = f.get_as_text()
f.close()
if content.strip_edges() == "":
return {"cfg": {}, "unparseable": false}
var parsed = JSON.parse_string(content)
if typeof(parsed) != TYPE_DICTIONARY:
return {"cfg": {}, "unparseable": true}
return {"cfg": parsed, "unparseable": false}
static func _write_config(path: String, cfg: Dictionary) -> String:
var dir_path := path.get_base_dir()
if not DirAccess.dir_exists_absolute(dir_path):
var derr := DirAccess.make_dir_recursive_absolute(dir_path)
if derr != OK:
return "mkdir failed for %s (%d)" % [dir_path, derr]
# Back up any existing non-empty file before overwriting, so a mistaken merge
# or an interrupted write is recoverable.
if FileAccess.file_exists(path):
var prev := FileAccess.open(path, FileAccess.READ)
if prev:
var prev_content := prev.get_as_text()
prev.close()
if prev_content.strip_edges() != "":
var bak := FileAccess.open(path + ".bak", FileAccess.WRITE)
if bak:
bak.store_string(prev_content)
bak.close()
var f := FileAccess.open(path, FileAccess.WRITE)
if f == null:
return "could not open for write: %s (error %d)" % [path, FileAccess.get_open_error()]
f.store_string(JSON.stringify(cfg, " "))
f.close()
return ""
# --- Format-specific readers/writers -----------------------------------------
static func _has_server_entry(cfg: Dictionary, format: String) -> bool:
match format:
"mcp_servers":
var servers = cfg.get("mcpServers")
return typeof(servers) == TYPE_DICTIONARY and (servers as Dictionary).has(SERVER_NAME)
"vscode_servers":
var servers = cfg.get("servers")
return typeof(servers) == TYPE_DICTIONARY and (servers as Dictionary).has(SERVER_NAME)
"continue_experimental":
var exp = cfg.get("experimental")
if typeof(exp) != TYPE_DICTIONARY:
return false
var arr = exp.get("modelContextProtocolServers")
if typeof(arr) != TYPE_ARRAY:
return false
for entry in arr:
if typeof(entry) == TYPE_DICTIONARY and _entry_matches_continue(entry):
return true
return false
_:
return false
static func _set_server_entry(cfg: Dictionary, format: String) -> void:
match format:
"mcp_servers":
if typeof(cfg.get("mcpServers")) != TYPE_DICTIONARY:
cfg["mcpServers"] = {}
(cfg["mcpServers"] as Dictionary)[SERVER_NAME] = {
"command": SERVER_COMMAND,
"args": SERVER_ARGS.duplicate(),
}
"vscode_servers":
if typeof(cfg.get("servers")) != TYPE_DICTIONARY:
cfg["servers"] = {}
(cfg["servers"] as Dictionary)[SERVER_NAME] = {
"command": SERVER_COMMAND,
"args": SERVER_ARGS.duplicate(),
}
"continue_experimental":
if typeof(cfg.get("experimental")) != TYPE_DICTIONARY:
cfg["experimental"] = {}
var exp: Dictionary = cfg["experimental"]
if typeof(exp.get("modelContextProtocolServers")) != TYPE_ARRAY:
exp["modelContextProtocolServers"] = []
var arr: Array = exp["modelContextProtocolServers"]
# Remove any existing match so we don't duplicate.
var kept: Array = []
for entry in arr:
if typeof(entry) != TYPE_DICTIONARY or not _entry_matches_continue(entry):
kept.append(entry)
kept.append({
"transport": {
"type": "stdio",
"command": SERVER_COMMAND,
"args": SERVER_ARGS.duplicate(),
},
})
exp["modelContextProtocolServers"] = kept
static func _clear_server_entry(cfg: Dictionary, format: String) -> void:
match format:
"mcp_servers":
var servers = cfg.get("mcpServers")
if typeof(servers) == TYPE_DICTIONARY:
(servers as Dictionary).erase(SERVER_NAME)
"vscode_servers":
var servers = cfg.get("servers")
if typeof(servers) == TYPE_DICTIONARY:
(servers as Dictionary).erase(SERVER_NAME)
"continue_experimental":
var exp = cfg.get("experimental")
if typeof(exp) != TYPE_DICTIONARY:
return
var arr = exp.get("modelContextProtocolServers")
if typeof(arr) != TYPE_ARRAY:
return
var kept: Array = []
for entry in arr:
if typeof(entry) != TYPE_DICTIONARY or not _entry_matches_continue(entry):
kept.append(entry)
exp["modelContextProtocolServers"] = kept
# Continue's config uses a flat array with no `name` field — identify our entry
# by the transport command+args combo.
static func _entry_matches_continue(entry: Dictionary) -> bool:
var transport = entry.get("transport")
if typeof(transport) != TYPE_DICTIONARY:
return false
if transport.get("command") != SERVER_COMMAND:
return false
var a = transport.get("args")
if typeof(a) != TYPE_ARRAY or (a as Array).size() < 2:
return false
return a[1] == SERVER_ARGS[1]
static func _ok(data) -> Dictionary:
return {"data": data}
static func _err(code: int, msg: String) -> Dictionary:
return {"error": {"code": code, "message": msg}}
@@ -0,0 +1 @@
uid://b3u1el0113l81
+72
View File
@@ -0,0 +1,72 @@
@tool
extends RefCounted
# docs.class_ref — return the public API of a Godot class so the agent can plan
# work without guessing method/property/signal names.
# Params: {class_name, include_inherited?: false}
# Returns: {class, parent, methods: [{name, args, return}], properties, signals, constants}
static func class_ref(params: Dictionary) -> Dictionary:
var cname: String = params.get("class_name", "")
var include_inherited: bool = params.get("include_inherited", false)
if cname == "":
return _err(-32602, "missing 'class_name'")
if not ClassDB.class_exists(cname):
return _err(-32602, "unknown class: %s" % cname)
var no_inheritance := not include_inherited
var methods: Array = []
for m in ClassDB.class_get_method_list(cname, no_inheritance):
# Skip internal / editor-only methods — they're noise for an agent.
if (m.flags as int) & METHOD_FLAG_OBJECT_CORE:
continue
var args: Array = []
for a in m.args:
args.append({"name": a.name, "type": type_string(a.type)})
methods.append({
"name": m.name,
"args": args,
"return": type_string(m.return.type) if m.has("return") else "void",
"virtual": bool((m.flags as int) & METHOD_FLAG_VIRTUAL),
"static": bool((m.flags as int) & METHOD_FLAG_STATIC),
})
var properties: Array = []
for p in ClassDB.class_get_property_list(cname, no_inheritance):
# Only user-facing properties (storage + editor). Internal group markers have usage=0.
if not ((p.usage as int) & PROPERTY_USAGE_EDITOR):
continue
properties.append({
"name": p.name,
"type": type_string(p.type),
"hint": p.get("hint_string", ""),
})
var signals: Array = []
for s in ClassDB.class_get_signal_list(cname, no_inheritance):
var args: Array = []
for a in s.args:
args.append({"name": a.name, "type": type_string(a.type)})
signals.append({"name": s.name, "args": args})
var constants: Array = []
for c in ClassDB.class_get_integer_constant_list(cname, no_inheritance):
constants.append({"name": c, "value": ClassDB.class_get_integer_constant(cname, c)})
return _ok({
"class": cname,
"parent": ClassDB.get_parent_class(cname),
"inherited_included": include_inherited,
"methods": methods,
"properties": properties,
"signals": signals,
"constants": constants,
})
static func _ok(data) -> Dictionary:
return {"data": data}
static func _err(code: int, msg: String) -> Dictionary:
return {"error": {"code": code, "message": msg}}
@@ -0,0 +1 @@
uid://dwarklg56vsim
+201
View File
@@ -0,0 +1,201 @@
@tool
extends RefCounted
# editor.reload_filesystem — trigger an editor filesystem rescan.
# Run this after external file changes (created/moved/deleted by tools outside the editor)
# so load() and the FileSystem dock reflect reality.
static func reload_filesystem(_params: Dictionary) -> Dictionary:
EditorInterface.get_resource_filesystem().scan()
return {"data": {"scanned": true}}
# editor.save_all_scenes — save every open edited scene.
static func save_all_scenes(_params: Dictionary) -> Dictionary:
EditorInterface.save_all_scenes()
return {"data": {"saved": true}}
# editor.state — consolidated editor + project status in one call.
# Replaces piecewise scene.current + project.get_setting + etc.
static func state(_params: Dictionary) -> Dictionary:
var version := Engine.get_version_info()
var version_str: String = "%d.%d.%d-%s" % [version.major, version.minor, version.patch, version.status]
var current_scene_path := ""
var current_scene_class := ""
var current_scene_name := ""
var root := EditorInterface.get_edited_scene_root()
if root != null:
current_scene_path = root.scene_file_path
current_scene_class = root.get_class()
current_scene_name = String(root.name)
var open_scenes: Array = []
for s in EditorInterface.get_open_scenes():
open_scenes.append(s)
var project_name := ""
if ProjectSettings.has_setting("application/config/name"):
project_name = str(ProjectSettings.get_setting("application/config/name"))
return {"data": {
"godot_version": version_str,
"project_name": project_name,
"current_scene": {
"path": current_scene_path,
"root_name": current_scene_name,
"root_class": current_scene_class,
"open": root != null,
},
"open_scenes": open_scenes,
"playing_scene": EditorInterface.is_playing_scene(),
"playing_scene_path": EditorInterface.get_playing_scene() if EditorInterface.is_playing_scene() else "",
}}
# editor.selection_get — return the currently-selected nodes in the editor tree dock.
# Useful for "operate on whatever I clicked" workflows.
static func selection_get(_params: Dictionary) -> Dictionary:
var selection := EditorInterface.get_selection()
var root := EditorInterface.get_edited_scene_root()
var out: Array = []
for node in selection.get_selected_nodes():
var path_str := ""
if root != null and (node == root or root.is_ancestor_of(node)):
path_str = String(root.get_path_to(node))
out.append({
"name": String(node.name),
"class": node.get_class(),
"node_path": path_str,
})
return {"data": {"selected": out, "count": out.size()}}
# editor.selection_set — select a specific set of nodes in the editor tree dock.
# Params: {node_paths: ["Player", "Enemy"]}
static func selection_set(params: Dictionary) -> Dictionary:
var paths: Array = params.get("node_paths", [])
var root := EditorInterface.get_edited_scene_root()
if root == null:
return {"error": {"code": -32001, "message": "no scene open"}}
var selection := EditorInterface.get_selection()
selection.clear()
var resolved: Array = []
var missing: Array = []
for p in paths:
var node: Node = root if p == "." else root.get_node_or_null(p)
if node == null:
missing.append(p)
continue
selection.add_node(node)
resolved.append(p)
if not missing.is_empty():
return {"error": {"code": -32001, "message": "nodes not found: %s" % str(missing)}}
return {"data": {"selected": resolved}}
# editor.game_screenshot — capture the viewport of the currently-running game.
# Works when the user has pressed F5 (or equivalent) and a game process is running
# with our runtime bridge autoload registered. Writes a request file, polls for
# the bridge to produce the PNG, returns the path.
#
# Params: {output?: "res://.godot/agent_tools/game.png", timeout_ms?: 5000}
static func game_screenshot(params: Dictionary) -> Dictionary:
var output_path: String = params.get("output", "res://.godot/agent_tools/game_screenshot.png")
var timeout_ms: int = int(params.get("timeout_ms", 5000))
if not EditorInterface.is_playing_scene():
return {"error": {"code": -32001,
"message": "no game running — press F5 (or equivalent) to start the game, then call again. Alternative for arbitrary scenes: run.scene_headless with 'screenshot'."}}
var bridge_dir := "res://.godot/agent_tools/bridge"
if not DirAccess.dir_exists_absolute(bridge_dir):
var derr := DirAccess.make_dir_recursive_absolute(bridge_dir)
if derr != OK:
return {"error": {"code": -32001, "message": "mkdir failed: %d" % derr}}
# Wipe stale output so we can detect when the bridge has written fresh data.
if FileAccess.file_exists(output_path):
@warning_ignore("return_value_discarded")
DirAccess.remove_absolute(output_path)
var request_path := bridge_dir + "/request.json"
var request_id := str(Time.get_ticks_msec())
var req := {
"id": request_id,
"type": "screenshot",
"output": output_path,
}
var f := FileAccess.open(request_path, FileAccess.WRITE)
if f == null:
return {"error": {"code": -32001, "message": "could not write bridge request"}}
f.store_string(JSON.stringify(req))
f.close()
# Poll for response — bridge writes response.json with matching id when done.
var response_path := bridge_dir + "/response.json"
var deadline_usec: int = Time.get_ticks_usec() + timeout_ms * 1000
while Time.get_ticks_usec() < deadline_usec:
if FileAccess.file_exists(response_path):
var rf := FileAccess.open(response_path, FileAccess.READ)
if rf:
var body := rf.get_as_text()
rf.close()
var parsed = JSON.parse_string(body)
if typeof(parsed) == TYPE_DICTIONARY and parsed.get("id", "") == request_id:
@warning_ignore("return_value_discarded")
DirAccess.remove_absolute(response_path)
if parsed.has("error"):
return {"error": {"code": -32001, "message": "bridge: %s" % parsed.error}}
return {"data": {
"path": output_path,
"captured": FileAccess.file_exists(output_path),
}}
OS.delay_msec(50)
# Clean up stale request if bridge never picked it up.
if FileAccess.file_exists(request_path):
@warning_ignore("return_value_discarded")
DirAccess.remove_absolute(request_path)
return {"error": {"code": -32001,
"message": "bridge timed out after %dms — is the running game using the _MCPGameBridge autoload? Re-enable the plugin to re-register it, then press F5 again." % timeout_ms}}
# logs.read — pull recent log lines captured by the game-bridge autoload.
# Params: {clear?: false, max_lines?: 200}
static func logs_read(params: Dictionary) -> Dictionary:
var clear: bool = params.get("clear", false)
var max_lines: int = int(params.get("max_lines", 200))
var logs_path := "res://.godot/agent_tools/bridge/logs.json"
if not FileAccess.file_exists(logs_path):
return {"data": {
"entries": [],
"count": 0,
"note": "no log buffer found — the game may not be running or the autoload isn't initialized. Press F5 and try again.",
}}
var f := FileAccess.open(logs_path, FileAccess.READ)
if f == null:
return {"error": {"code": -32001, "message": "could not read logs"}}
var parsed = JSON.parse_string(f.get_as_text())
f.close()
if typeof(parsed) != TYPE_ARRAY:
parsed = []
var entries: Array = parsed
if entries.size() > max_lines:
entries = entries.slice(entries.size() - max_lines)
if clear:
@warning_ignore("return_value_discarded")
DirAccess.remove_absolute(logs_path)
return {"data": {"entries": entries, "count": entries.size()}}
# logs.clear — drop the log buffer regardless of read state.
static func logs_clear(_params: Dictionary) -> Dictionary:
var logs_path := "res://.godot/agent_tools/bridge/logs.json"
if FileAccess.file_exists(logs_path):
@warning_ignore("return_value_discarded")
DirAccess.remove_absolute(logs_path)
return {"data": {"cleared": true}}
@@ -0,0 +1 @@
uid://bgoufu3l40rvx
+144
View File
@@ -0,0 +1,144 @@
@tool
extends RefCounted
# fs.list — enumerate project files by type, with optional glob filter.
# Params: {type?: "all"|"scene"|"script"|"resource"|"shader"|"image"|"audio",
# glob?: "res://scenes/**/*.tscn",
# include_addons?: false}
# Returns: {type, count, files: [paths]}
static func list(params: Dictionary) -> Dictionary:
var type_filter: String = params.get("type", "all")
var glob: String = params.get("glob", "")
var include_addons: bool = params.get("include_addons", false)
var exts: Array
match type_filter:
"", "all":
exts = ["gd", "cs", "tscn", "tres", "res", "gdshader", "gdshaderinc",
"png", "jpg", "jpeg", "svg", "webp",
"ogg", "wav", "mp3",
"json", "cfg"]
"scene":
exts = ["tscn"]
"script":
exts = ["gd", "cs"]
"resource":
exts = ["tres", "res"]
"shader":
exts = ["gdshader", "gdshaderinc"]
"image":
exts = ["png", "jpg", "jpeg", "svg", "webp"]
"audio":
exts = ["ogg", "wav", "mp3"]
_:
return _err(-32602, "unknown type: %s (use all|scene|script|resource|shader|image|audio)" % type_filter)
var files: Array = []
_walk("res://", exts, files, include_addons)
if glob != "":
var filtered: Array = []
for f in files:
if f.matchn(glob): # case-insensitive glob match
filtered.append(f)
files = filtered
files.sort()
return _ok({
"type": type_filter,
"count": files.size(),
"files": files,
})
# fs.read_text — read a text file under res://. Complement to user_fs.read.
# Params: {path}
static func read_text(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
if path == "":
return _err(-32602, "missing 'path'")
if not path.begins_with("res://"):
return _err(-32602, "path must begin with 'res://' (use user_fs.read for user:// files)")
if not _within_sandbox(path, "res://"):
return _err(-32602, "path escapes the res:// project sandbox ('..' traversal not allowed): %s" % path)
var f := FileAccess.open(path, FileAccess.READ)
if f == null:
return _err(-32001, "failed to open %s (error %d)" % [path, FileAccess.get_open_error()])
var size_bytes := f.get_length()
var content := f.get_as_text()
f.close()
return _ok({"path": path, "content": content, "size_bytes": size_bytes})
# fs.write_text — write a text file under res://. Creates parent directories
# if needed. Triggers the editor's filesystem rescan so the new file shows up
# in the FileSystem dock.
# Params: {path, content, overwrite?: false}
static func write_text(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
var content: String = str(params.get("content", ""))
var overwrite: bool = params.get("overwrite", false)
if path == "":
return _err(-32602, "missing 'path'")
if not path.begins_with("res://"):
return _err(-32602, "path must begin with 'res://'")
if not _within_sandbox(path, "res://"):
return _err(-32602, "path escapes the res:// project sandbox ('..' traversal not allowed): %s" % path)
if FileAccess.file_exists(path) and not overwrite:
return _err(-32602, "file exists (pass overwrite:true to replace): %s" % path)
var dir_path := path.get_base_dir()
if not DirAccess.dir_exists_absolute(dir_path):
var derr := DirAccess.make_dir_recursive_absolute(dir_path)
if derr != OK:
return _err(-32001, "mkdir failed (%d): %s" % [derr, dir_path])
var f := FileAccess.open(path, FileAccess.WRITE)
if f == null:
return _err(-32001, "could not open for write: %s" % path)
f.store_string(content)
f.close()
EditorInterface.get_resource_filesystem().update_file(path)
return _ok({"path": path, "bytes_written": content.length()})
static func _walk(dir_path: String, exts: Array, out: Array, include_addons: bool) -> void:
var d := DirAccess.open(dir_path)
if d == null:
return
d.list_dir_begin()
while true:
var name := d.get_next()
if name == "":
break
if name.begins_with("."):
continue
var full := dir_path.path_join(name)
if d.current_is_dir():
if not include_addons and full == "res://addons/agent_tools":
continue # always skip our own addon
_walk(full, exts, out, include_addons)
else:
for e in exts:
if name.ends_with("." + e):
out.append(full)
break
d.list_dir_end()
# Path-traversal guard. `begins_with("res://")` is NOT a sandbox — Godot resolves
# `..` segments against the real filesystem, so "res://../../etc/x" escapes the
# project. Globalize to an absolute path, collapse `..`, and confirm the result
# stays inside the (globalized, collapsed) prefix root.
static func _within_sandbox(path: String, prefix: String) -> bool:
var root := ProjectSettings.globalize_path(prefix).simplify_path().trim_suffix("/")
var abs := ProjectSettings.globalize_path(path).simplify_path()
return abs == root or abs.begins_with(root + "/")
static func _ok(data) -> Dictionary:
return {"data": data}
static func _err(code: int, msg: String) -> Dictionary:
return {"error": {"code": code, "message": msg}}
+1
View File
@@ -0,0 +1 @@
uid://dxq0mo7kx65wd
+243
View File
@@ -0,0 +1,243 @@
@tool
extends RefCounted
# input_map.add_action — register a new input action in project.godot.
# Params: {name, deadzone?: 0.5}
static func add_action(params: Dictionary) -> Dictionary:
var action_name: String = params.get("name", "")
if action_name == "":
return _err(-32602, "missing 'name'")
var deadzone: float = float(params.get("deadzone", 0.5))
var key := "input/" + action_name
if ProjectSettings.has_setting(key):
return _err(-32602, "action already exists: %s" % action_name)
ProjectSettings.set_setting(key, {
"deadzone": deadzone,
"events": [],
})
var err := ProjectSettings.save()
if err != OK:
return _err(-32001, "save failed: %d" % err)
# Keep the runtime InputMap in sync with the on-disk project settings —
# ProjectSettings.save() only writes the file; it doesn't register actions
# with InputMap until editor restart.
if not InputMap.has_action(action_name):
InputMap.add_action(action_name, deadzone)
return _ok({"added": action_name})
# input_map.add_event — attach an input event to an existing action.
# Supported event shapes (pick one via 'type'):
# {type: "key", keycode: "A" | "Space" | "F1" ..., physical?: true}
# {type: "mouse_button", button_index: 1 (left) | 2 (right) | 3 (middle)}
# {type: "joy_button", button_index: 0..}
# Params: {action, event: <shape above>}
static func add_event(params: Dictionary) -> Dictionary:
var action_name: String = params.get("action", "")
var event_spec: Dictionary = params.get("event", {})
if action_name == "":
return _err(-32602, "missing 'action'")
if event_spec.is_empty():
return _err(-32602, "missing 'event'")
var key := "input/" + action_name
if not ProjectSettings.has_setting(key):
return _err(-32001, "action not found: %s" % action_name)
var event := _build_event(event_spec)
if event == null:
return _err(-32602, "invalid event spec — see tool description for supported shapes")
var current: Dictionary = ProjectSettings.get_setting(key)
var events_arr: Array = current.get("events", [])
events_arr.append(event)
current["events"] = events_arr
ProjectSettings.set_setting(key, current)
var err := ProjectSettings.save()
if err != OK:
return _err(-32001, "save failed: %d" % err)
# Sync InputMap so Input.is_action_pressed() etc. see the event without restart.
if InputMap.has_action(action_name):
InputMap.action_add_event(action_name, event)
return _ok({"action": action_name, "event": event_spec})
# input_map.list — return registered input actions with their events.
# Params: {include_builtins?: false}
# Defaults to user-defined only (parses the [input] section of project.godot,
# which contains only user overrides — Godot's ~90 ui_* builtins live in-memory).
# When include_builtins, falls back to InputMap which has the full union.
static func list_actions(params: Dictionary) -> Dictionary:
var include_builtins: bool = params.get("include_builtins", false)
var items: Array = []
if include_builtins:
for a in InputMap.get_actions():
var described: Array = []
for e in InputMap.action_get_events(a):
described.append(_describe_event(e))
items.append({
"name": String(a),
"deadzone": InputMap.action_get_deadzone(a),
"events": described,
})
else:
# Read straight from project.godot so newly-added actions show up even
# if they haven't been registered with the runtime InputMap yet.
var cf := ConfigFile.new()
var err := cf.load("res://project.godot")
if err == OK and cf.has_section("input"):
for action_name in cf.get_section_keys("input"):
var value: Dictionary = cf.get_value("input", action_name)
var described: Array = []
for e in value.get("events", []):
described.append(_describe_event(e))
items.append({
"name": action_name,
"deadzone": value.get("deadzone", 0.5),
"events": described,
})
return _ok({"actions": items})
# input_map.remove_event — remove an event from an action by index.
# Call input_map.list first to see indices; events are listed in the order they
# were added.
# Params: {action, event_index}
static func remove_event(params: Dictionary) -> Dictionary:
var action_name: String = params.get("action", "")
if action_name == "":
return _err(-32602, "missing 'action'")
if not params.has("event_index"):
return _err(-32602, "missing 'event_index'")
var idx: int = int(params.event_index)
var key := "input/" + action_name
if not ProjectSettings.has_setting(key):
return _err(-32001, "action not found: %s" % action_name)
var current: Dictionary = ProjectSettings.get_setting(key)
var events_arr: Array = current.get("events", [])
if idx < 0 or idx >= events_arr.size():
return _err(-32602, "event_index %d out of range (0..%d)" % [idx, events_arr.size() - 1])
var removed_event = events_arr[idx]
events_arr.remove_at(idx)
current["events"] = events_arr
ProjectSettings.set_setting(key, current)
var err := ProjectSettings.save()
if err != OK:
return _err(-32001, "save failed: %d" % err)
if InputMap.has_action(action_name) and removed_event != null:
InputMap.action_erase_event(action_name, removed_event)
return _ok({"action": action_name, "removed_index": idx})
# input_map.remove_action — delete a user-registered action.
static func remove_action(params: Dictionary) -> Dictionary:
var action_name: String = params.get("name", "")
if action_name == "":
return _err(-32602, "missing 'name'")
var key := "input/" + action_name
if not ProjectSettings.has_setting(key):
return _err(-32001, "action not found: %s" % action_name)
ProjectSettings.clear(key)
var err := ProjectSettings.save()
if err != OK:
return _err(-32001, "save failed: %d" % err)
if InputMap.has_action(action_name):
InputMap.erase_action(action_name)
return _ok({"removed": action_name})
static func _build_event(spec: Dictionary) -> InputEvent:
# device defaults to -1 (ALL_DEVICES) matching the editor's "All Devices" default.
# For local multiplayer bind device=0, device=1, etc. to distinguish controllers.
var device: int = int(spec.get("device", -1))
match spec.get("type", ""):
"key":
var e := InputEventKey.new()
e.device = device
var kc_raw = spec.get("keycode", "")
var keycode := 0
if kc_raw is int:
keycode = kc_raw
elif kc_raw is String:
keycode = OS.find_keycode_from_string(kc_raw)
if keycode == 0:
return null
if spec.get("physical", true):
e.physical_keycode = keycode
else:
e.keycode = keycode
return e
"mouse_button":
var e := InputEventMouseButton.new()
e.device = device
e.button_index = int(spec.get("button_index", 1))
return e
"joy_button":
var e := InputEventJoypadButton.new()
e.device = device
e.button_index = int(spec.get("button_index", 0))
return e
"joy_motion":
# axis accepts int 0..5 or one of:
# "left_x" / "left_y" / "right_x" / "right_y" / "trigger_left" / "trigger_right"
# axis_value: -1.0 (full negative) to 1.0 (full positive); sign picks the direction
# that triggers the action.
var e := InputEventJoypadMotion.new()
e.device = device
e.axis = _coerce_axis(spec.get("axis", 0))
e.axis_value = float(spec.get("axis_value", 1.0))
return e
_:
return null
static func _coerce_axis(v) -> int:
if v is int:
return v
if v is String:
match v:
"left_x":
return JOY_AXIS_LEFT_X
"left_y":
return JOY_AXIS_LEFT_Y
"right_x":
return JOY_AXIS_RIGHT_X
"right_y":
return JOY_AXIS_RIGHT_Y
"trigger_left":
return JOY_AXIS_TRIGGER_LEFT
"trigger_right":
return JOY_AXIS_TRIGGER_RIGHT
return 0
static func _describe_event(e) -> Dictionary:
if e is InputEventKey:
var kc: int = e.physical_keycode if e.physical_keycode != 0 else e.keycode
return {
"type": "key",
"keycode": OS.get_keycode_string(kc),
"physical": e.physical_keycode != 0,
"device": e.device,
}
if e is InputEventMouseButton:
return {"type": "mouse_button", "button_index": e.button_index, "device": e.device}
if e is InputEventJoypadButton:
return {"type": "joy_button", "button_index": e.button_index, "device": e.device}
if e is InputEventJoypadMotion:
return {
"type": "joy_motion",
"axis": e.axis,
"axis_value": e.axis_value,
"device": e.device,
}
return {"type": "unknown", "class": (e as Object).get_class() if e else "null"}
static func _ok(data) -> Dictionary:
return {"data": data}
static func _err(code: int, msg: String) -> Dictionary:
return {"error": {"code": code, "message": msg}}
@@ -0,0 +1 @@
uid://dp1m17i4hu7t1
@@ -0,0 +1,56 @@
@tool
extends RefCounted
# performance.monitors — read Godot's built-in performance monitors.
# Params: {monitors?: [string]} — specific monitor names, default = common set
# Returns: {fps, frame_time_ms, process_time_ms, physics_time_ms, mem_static_mb,
# mem_dynamic_mb, objects, resources, nodes, draw_calls, ...}
static func monitors(params: Dictionary) -> Dictionary:
var requested: Array = params.get("monitors", [])
var out: Dictionary = {}
# Default set — the monitors agents actually want for quick perf snapshots.
if requested.is_empty():
out["fps"] = Performance.get_monitor(Performance.TIME_FPS)
out["frame_time_ms"] = Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0
out["physics_time_ms"] = Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS) * 1000.0
out["mem_static_mb"] = Performance.get_monitor(Performance.MEMORY_STATIC) / 1048576.0
out["mem_static_max_mb"] = Performance.get_monitor(Performance.MEMORY_STATIC_MAX) / 1048576.0
out["object_count"] = int(Performance.get_monitor(Performance.OBJECT_COUNT))
out["resource_count"] = int(Performance.get_monitor(Performance.OBJECT_RESOURCE_COUNT))
out["node_count"] = int(Performance.get_monitor(Performance.OBJECT_NODE_COUNT))
out["orphan_node_count"] = int(Performance.get_monitor(Performance.OBJECT_ORPHAN_NODE_COUNT))
out["draw_calls"] = int(Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME))
else:
for name in requested:
var enum_value := _resolve_monitor_name(String(name))
if enum_value == -1:
out[name] = null
else:
out[name] = Performance.get_monitor(enum_value)
return _ok({"monitors": out})
# Map friendly strings to Performance.* enum values so callers don't have to
# remember the integer IDs.
static func _resolve_monitor_name(name: String) -> int:
match name:
"fps": return Performance.TIME_FPS
"frame_time": return Performance.TIME_PROCESS
"physics_time": return Performance.TIME_PHYSICS_PROCESS
"mem_static": return Performance.MEMORY_STATIC
"mem_static_max": return Performance.MEMORY_STATIC_MAX
"objects": return Performance.OBJECT_COUNT
"resources": return Performance.OBJECT_RESOURCE_COUNT
"nodes": return Performance.OBJECT_NODE_COUNT
"orphan_nodes": return Performance.OBJECT_ORPHAN_NODE_COUNT
"draw_calls": return Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME
"audio_latency": return Performance.AUDIO_OUTPUT_LATENCY
"physics_2d_active_objects": return Performance.PHYSICS_2D_ACTIVE_OBJECTS
"physics_3d_active_objects": return Performance.PHYSICS_3D_ACTIVE_OBJECTS
_: return -1
static func _ok(data) -> Dictionary:
return {"data": data}
@@ -0,0 +1 @@
uid://bijue5qgitvnk
+139
View File
@@ -0,0 +1,139 @@
@tool
extends RefCounted
# physics.autofit_collision_shape_2d — compute a CollisionShape2D sized to a
# sibling/child Sprite2D or AnimatedSprite2D's visual bounds. Common pattern
# that's tedious to set up by hand: you have a sprite, you want a rectangle or
# circle collider that roughly matches it.
#
# Params: {
# node_path: CollisionShape2D to fit (created if missing when create:true)
# source: NodePath to a Sprite2D / AnimatedSprite2D (auto-detected
# among siblings if omitted)
# shape: "rectangle" | "circle" | "capsule" (default "rectangle")
# margin: float pixels to shrink by (default 0)
# create: bool — if true and node_path doesn't exist, create it as
# a child of the source's parent. Default false.
# }
static func autofit_collision_shape_2d(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
var source_path: String = params.get("source", "")
var shape_type: String = params.get("shape", "rectangle")
var margin: float = float(params.get("margin", 0.0))
var create: bool = params.get("create", false)
if node_path == "":
return _err(-32602, "missing 'node_path'")
var root := EditorInterface.get_edited_scene_root()
if root == null:
return _err(-32001, "no scene open")
var target: Node = root if node_path == "." else root.get_node_or_null(node_path)
if target == null and not create:
return _err(-32001, "node not found: %s (pass create:true to auto-create)" % node_path)
# Resolve the visual source.
var source: Node = null
if source_path != "":
source = root.get_node_or_null(source_path)
if source == null:
return _err(-32001, "source not found: %s" % source_path)
elif target != null:
# Look among siblings for a Sprite2D / AnimatedSprite2D.
var parent := target.get_parent()
if parent:
for sib in parent.get_children():
if sib is Sprite2D or sib is AnimatedSprite2D:
source = sib
break
if source == null:
return _err(-32001, "no Sprite2D/AnimatedSprite2D source found — pass 'source'")
# Compute visual bounds (untransformed; CollisionShape2D inherits parent's transform).
var bounds := _sprite_bounds(source)
if bounds.size.x <= 0 or bounds.size.y <= 0:
return _err(-32001, "source has no visible extents (%s)" % source.get_class())
# Create the CollisionShape2D if needed.
if target == null and create:
var inst := CollisionShape2D.new()
inst.name = node_path.get_file() if "/" in node_path else node_path
var parent := source.get_parent()
parent.add_child(inst)
inst.owner = root
target = inst
if not target is CollisionShape2D:
return _err(-32602, "target is not a CollisionShape2D: %s (%s)" % [node_path, target.get_class()])
var cs: CollisionShape2D = target
# Build the requested shape sized to bounds.
var shape_res: Shape2D
match shape_type:
"rectangle":
var r := RectangleShape2D.new()
r.size = Vector2(
max(1.0, bounds.size.x - margin * 2.0),
max(1.0, bounds.size.y - margin * 2.0)
)
shape_res = r
"circle":
var c := CircleShape2D.new()
c.radius = max(1.0, min(bounds.size.x, bounds.size.y) / 2.0 - margin)
shape_res = c
"capsule":
var cap := CapsuleShape2D.new()
cap.radius = max(1.0, min(bounds.size.x, bounds.size.y) / 2.0 - margin)
cap.height = max(cap.radius * 2.0 + 1.0, bounds.size.y - margin * 2.0)
shape_res = cap
_:
return _err(-32602, "unknown shape '%s' (supported: rectangle, circle, capsule)" % shape_type)
cs.shape = shape_res
# Center the CollisionShape2D on the sprite's center.
cs.position = bounds.position + bounds.size / 2.0
EditorInterface.mark_scene_as_unsaved()
return _ok({
"node_path": String(root.get_path_to(cs)),
"shape": shape_type,
"size": [bounds.size.x - margin * 2.0, bounds.size.y - margin * 2.0],
"center": [cs.position.x, cs.position.y],
"source": String(root.get_path_to(source)),
})
static func _sprite_bounds(source: Node) -> Rect2:
if source is Sprite2D:
var s: Sprite2D = source
if s.texture == null:
return Rect2(Vector2.ZERO, Vector2.ZERO)
var tex_size: Vector2 = s.texture.get_size()
if s.region_enabled:
tex_size = s.region_rect.size
var size: Vector2 = tex_size * Vector2(abs(s.scale.x), abs(s.scale.y))
# For centered sprites, bounds are at (-size/2, -size/2).
var offset: Vector2 = -size / 2.0 if s.centered else Vector2.ZERO
offset += s.offset
return Rect2(offset, size)
if source is AnimatedSprite2D:
var a: AnimatedSprite2D = source
if a.sprite_frames == null or a.animation == "":
return Rect2(Vector2.ZERO, Vector2.ZERO)
var tex := a.sprite_frames.get_frame_texture(a.animation, a.frame)
if tex == null:
return Rect2(Vector2.ZERO, Vector2.ZERO)
var size: Vector2 = tex.get_size() * Vector2(abs(a.scale.x), abs(a.scale.y))
var offset: Vector2 = -size / 2.0 if a.centered else Vector2.ZERO
offset += a.offset
return Rect2(offset, size)
return Rect2(Vector2.ZERO, Vector2.ZERO)
static func _ok(data) -> Dictionary:
return {"data": data}
static func _err(code: int, msg: String) -> Dictionary:
return {"error": {"code": code, "message": msg}}
@@ -0,0 +1 @@
uid://c26p52t0pcj6q
+89
View File
@@ -0,0 +1,89 @@
@tool
extends RefCounted
# project.get_setting — read a value from project.godot. Returns {exists: false} if unset.
# Params: {key: "application/config/name"}
static func get_setting(params: Dictionary) -> Dictionary:
var key: String = params.get("key", "")
if key == "":
return _err(-32602, "missing 'key'")
if not ProjectSettings.has_setting(key):
return _ok({"key": key, "exists": false})
return _ok({"key": key, "exists": true, "value": ProjectSettings.get_setting(key)})
# project.set_setting — write a value to project.godot and save.
# DESTRUCTIVE: mutates project.godot. Prefer targeted tools (autoload.add, etc.) when available.
# Params: {key, value}
static func set_setting(params: Dictionary) -> Dictionary:
var key: String = params.get("key", "")
if key == "":
return _err(-32602, "missing 'key'")
if not params.has("value"):
return _err(-32602, "missing 'value'")
ProjectSettings.set_setting(key, params["value"])
var err := ProjectSettings.save()
if err != OK:
return _err(-32001, "ProjectSettings.save() failed: %d" % err)
return _ok({"key": key, "value": params["value"]})
# autoload.add — register an autoload (singleton by default).
# Params: {name, path, singleton?: true}
static func autoload_add(params: Dictionary) -> Dictionary:
var autoload_name: String = params.get("name", "")
var path: String = params.get("path", "")
var singleton: bool = params.get("singleton", true)
if autoload_name == "" or path == "":
return _err(-32602, "missing 'name' or 'path'")
if not ResourceLoader.exists(path):
return _err(-32001, "path does not exist: %s" % path)
var value := ("*" + path) if singleton else path
ProjectSettings.set_setting("autoload/" + autoload_name, value)
var err := ProjectSettings.save()
if err != OK:
return _err(-32001, "save failed: %d" % err)
return _ok({"name": autoload_name, "path": path, "singleton": singleton})
# autoload.remove — unregister an autoload.
# Params: {name}
static func autoload_remove(params: Dictionary) -> Dictionary:
var autoload_name: String = params.get("name", "")
if autoload_name == "":
return _err(-32602, "missing 'name'")
var key := "autoload/" + autoload_name
if not ProjectSettings.has_setting(key):
return _err(-32001, "autoload not found: %s" % autoload_name)
ProjectSettings.clear(key)
var err := ProjectSettings.save()
if err != OK:
return _err(-32001, "save failed: %d" % err)
return _ok({"removed": autoload_name})
# autoload.list — enumerate all registered autoloads.
static func autoload_list(_params: Dictionary) -> Dictionary:
var items: Array = []
# ProjectSettings.get_property_list() contains our autoload entries under "autoload/*".
for p in ProjectSettings.get_property_list():
var name: String = p.name
if not name.begins_with("autoload/"):
continue
var value: String = str(ProjectSettings.get_setting(name))
var singleton := value.begins_with("*")
items.append({
"name": name.trim_prefix("autoload/"),
"path": value.trim_prefix("*") if singleton else value,
"singleton": singleton,
})
return _ok({"autoloads": items})
static func _ok(data) -> Dictionary:
return {"data": data}
static func _err(code: int, msg: String) -> Dictionary:
return {"error": {"code": code, "message": msg}}
@@ -0,0 +1 @@
uid://bsfbwkk5nas36
+371
View File
@@ -0,0 +1,371 @@
@tool
extends RefCounted
# refs.validate_project — project-wide sweep. No params.
# Returns {checked: int, issues: [{path, kind, ...}]}.
# Kinds: script_parse_error, scene_load_failed, scene_instantiate_failed,
# resource_load_failed, missing_ext_resource, missing_uid,
# signal_method_missing, signal_target_missing.
static func validate_project(_params: Dictionary) -> Dictionary:
var issues: Array = []
var files: Array = []
_walk("res://", ["gd", "tscn", "tres"], files)
for path in files:
if path.begins_with("res://addons/agent_tools/"):
continue # don't validate ourselves
if path.ends_with(".gd"):
_check_script(path, issues)
elif path.ends_with(".tscn"):
_check_scene(path, issues)
elif path.ends_with(".tres"):
_check_resource(path, issues)
return {"data": {"checked": files.size(), "issues": issues}}
static func _walk(dir_path: String, exts: Array, out: Array) -> void:
var d := DirAccess.open(dir_path)
if d == null:
return
d.list_dir_begin()
while true:
var name := d.get_next()
if name == "":
break
if name.begins_with("."):
continue
var full := dir_path.path_join(name)
if d.current_is_dir():
_walk(full, exts, out)
else:
for e in exts:
if name.ends_with("." + e):
out.append(full)
break
d.list_dir_end()
static func _check_script(path: String, issues: Array) -> void:
# Go through the normal loader so class_name / preload chains resolve against
# the real global registry. A detached GDScript.new() + reload() would false-positive
# on any script with class_name (duplicate global class registration).
var script := ResourceLoader.load(path, "Script")
if script == null:
issues.append({"path": path, "kind": "script_load_failed"})
static func _check_scene(path: String, issues: Array) -> void:
_check_ext_resources_text(path, issues)
var packed := ResourceLoader.load(path, "PackedScene") as PackedScene
if packed == null:
issues.append({"path": path, "kind": "scene_load_failed"})
return
var root := packed.instantiate(PackedScene.GEN_EDIT_STATE_DISABLED)
if root == null:
issues.append({"path": path, "kind": "scene_instantiate_failed"})
return
_walk_scene_signals(root, root, path, issues)
root.free()
static func _walk_scene_signals(node: Node, scene_root: Node, scene_path: String, issues: Array) -> void:
for sig in node.get_signal_list():
var sig_name: String = sig.name
for c in node.get_signal_connection_list(sig_name):
var flags: int = c.flags
if not (flags & CONNECT_PERSIST):
continue # ignore runtime-only connections
var cb: Callable = c.callable
var target: Object = cb.get_object()
var method: String = cb.get_method()
if target == null:
issues.append({
"path": scene_path,
"kind": "signal_target_missing",
"node": String(scene_root.get_path_to(node)),
"signal": sig_name,
})
elif not target.has_method(method):
issues.append({
"path": scene_path,
"kind": "signal_method_missing",
"node": String(scene_root.get_path_to(node)),
"signal": sig_name,
"method": method,
})
for child in node.get_children():
if child.owner == scene_root or child == scene_root:
_walk_scene_signals(child, scene_root, scene_path, issues)
static func _check_resource(path: String, issues: Array) -> void:
var res := ResourceLoader.load(path)
if res == null:
issues.append({"path": path, "kind": "resource_load_failed"})
# refs.find_usages — find every file that references a given resource.
# Target may be either a path ("res://player.gd") or a uid ("uid://abc123").
# Searches for both forms so you catch uid-indirected references too.
# Params: {target: "res://..." | "uid://..."}
static func find_usages(params: Dictionary) -> Dictionary:
var target: String = params.get("target", "")
if target == "":
return _err(-32602, "missing 'target'")
var terms: Array = [target]
if target.begins_with("uid://"):
var id := ResourceUID.text_to_id(target)
if id != -1 and ResourceUID.has_id(id):
var p := ResourceUID.get_id_path(id)
if p != "" and p != target:
terms.append(p)
else:
var id := ResourceLoader.get_resource_uid(target)
if id != -1:
terms.append(ResourceUID.id_to_text(id))
var files: Array = []
_walk("res://", ["gd", "tscn", "tres", "cfg", "gdshader", "gdshaderinc", "cs", "godot"], files)
var matches: Array = []
for path in files:
if path.begins_with("res://addons/agent_tools/"):
continue
if path == target:
continue # skip the file itself
var f := FileAccess.open(path, FileAccess.READ)
if f == null:
continue
var line_num := 0
while not f.eof_reached():
line_num += 1
var line := f.get_line()
for term in terms:
if term in line:
matches.append({
"path": path,
"line": line_num,
"matched": term,
"text": line.strip_edges(),
})
break
return _ok({"target": target, "terms_searched": terms, "matches": matches})
# refs.rename — move a file and rewrite all references to it.
# Path-form references are text-replaced; uid-form references keep working because
# the .uid sidecar moves with the file (preserving the UID → new path mapping).
# Params: {from, to, overwrite?: false, dry_run?: false}
static func rename(params: Dictionary) -> Dictionary:
var from_path: String = params.get("from", "")
var to_path: String = params.get("to", "")
var overwrite: bool = params.get("overwrite", false)
var dry_run: bool = params.get("dry_run", false)
if from_path == "" or to_path == "":
return _err(-32602, "missing 'from' or 'to'")
if not (from_path.begins_with("res://") and to_path.begins_with("res://")):
return _err(-32602, "paths must be 'res://...'")
if from_path == to_path:
return _err(-32602, "'from' and 'to' are the same")
if not FileAccess.file_exists(from_path):
return _err(-32001, "source not found: %s" % from_path)
if FileAccess.file_exists(to_path) and not overwrite:
return _err(-32602, "destination exists (pass overwrite:true): %s" % to_path)
# Collect path-form usages. UID-form references are preserved automatically when
# the .uid sidecar moves with the file, so we don't need to rewrite them.
var usage := find_usages({"target": from_path})
if usage.has("error"):
return usage
var edits: Dictionary = {}
for m in usage.data.matches:
if m.matched != from_path:
continue # uid-form match — no rewrite needed
if not edits.has(m.path):
edits[m.path] = true
if dry_run:
return _ok({
"would_move": {"from": from_path, "to": to_path},
"would_update": edits.keys(),
})
var dir_path := to_path.get_base_dir()
if not DirAccess.dir_exists_absolute(dir_path):
var derr := DirAccess.make_dir_recursive_absolute(dir_path)
if derr != OK:
return _err(-32001, "mkdir failed (%d): %s" % [derr, dir_path])
var move_err := DirAccess.rename_absolute(from_path, to_path)
if move_err != OK:
return _err(-32001, "move failed (%d)" % move_err)
# Move .uid sidecar so UIDs continue resolving to the new path. Best-effort —
# sidecar failures don't block the main rename that already succeeded.
var from_uid := from_path + ".uid"
if FileAccess.file_exists(from_uid):
@warning_ignore("return_value_discarded")
DirAccess.rename_absolute(from_uid, to_path + ".uid")
# Move .import sidecar for imported assets (textures, audio, etc.).
var from_import := from_path + ".import"
if FileAccess.file_exists(from_import):
@warning_ignore("return_value_discarded")
DirAccess.rename_absolute(from_import, to_path + ".import")
var files_updated: Array = []
for ref_path in edits.keys():
var rf := FileAccess.open(ref_path, FileAccess.READ)
if rf == null:
continue
var content := rf.get_as_text()
rf.close()
var new_content := content.replace(from_path, to_path)
if new_content == content:
continue
var wf := FileAccess.open(ref_path, FileAccess.WRITE)
if wf == null:
continue
wf.store_string(new_content)
wf.close()
files_updated.append(ref_path)
EditorInterface.get_resource_filesystem().scan()
return _ok({
"moved": {"from": from_path, "to": to_path},
"files_updated": files_updated,
})
# refs.rename_class — rename `class_name X` to `class_name Y` and rewrite every
# word-boundary reference to X across .gd / .tscn / .tres files. Best-effort:
# won't rewrite an X that happens to be a local variable name (word boundary
# alone isn't semantic analysis). Use dry_run first on anything non-trivial.
# Params: {from, to, dry_run?: false}
static func rename_class(params: Dictionary) -> Dictionary:
var from_name: String = params.get("from", "")
var to_name: String = params.get("to", "")
var dry_run: bool = params.get("dry_run", false)
if from_name == "" or to_name == "":
return _err(-32602, "missing 'from' or 'to'")
if not _is_valid_identifier(from_name):
return _err(-32602, "'from' is not a valid identifier: %s" % from_name)
if not _is_valid_identifier(to_name):
return _err(-32602, "'to' is not a valid identifier: %s" % to_name)
if from_name == to_name:
return _err(-32602, "'from' and 'to' are identical")
var defining_script := _find_class_definition(from_name)
if defining_script == "":
return _err(-32001, "no script found declaring class_name %s" % from_name)
if _find_class_definition(to_name) != "":
return _err(-32602, "class_name %s already exists" % to_name)
var files: Array = []
_walk("res://", ["gd", "tscn", "tres"], files)
var regex := RegEx.new()
regex.compile("\\b%s\\b" % from_name)
var edits: Array = []
for path in files:
if path.begins_with("res://addons/agent_tools/"):
continue
var f := FileAccess.open(path, FileAccess.READ)
if f == null:
continue
var text := f.get_as_text()
f.close()
if regex.search(text) != null:
edits.append(path)
if dry_run:
return _ok({
"from": from_name,
"to": to_name,
"defining_script": defining_script,
"would_update": edits,
})
var updated: Array = []
for path in edits:
var f := FileAccess.open(path, FileAccess.READ)
if f == null:
continue
var text := f.get_as_text()
f.close()
var new_text := regex.sub(text, to_name, true)
if new_text == text:
continue
var wf := FileAccess.open(path, FileAccess.WRITE)
if wf == null:
continue
wf.store_string(new_text)
wf.close()
updated.append(path)
EditorInterface.get_resource_filesystem().scan()
return _ok({
"from": from_name,
"to": to_name,
"defining_script": defining_script,
"updated": updated,
})
static func _is_valid_identifier(s: String) -> bool:
var re := RegEx.new()
re.compile("^[A-Za-z_][A-Za-z0-9_]*$")
return re.search(s) != null
static func _find_class_definition(class_name_val: String) -> String:
var files: Array = []
_walk("res://", ["gd"], files)
var re := RegEx.new()
re.compile("^\\s*class_name\\s+%s\\b" % class_name_val)
for path in files:
if path.begins_with("res://addons/agent_tools/"):
continue
var f := FileAccess.open(path, FileAccess.READ)
if f == null:
continue
var text := f.get_as_text()
f.close()
for line in text.split("\n"):
if re.search(line) != null:
return path
return ""
static func _ok(data) -> Dictionary:
return {"data": data}
static func _err(code: int, msg: String) -> Dictionary:
return {"error": {"code": code, "message": msg}}
# Parse the raw .tscn/.tres text for [ext_resource ... path="..."] and verify each target resolves.
# Catches references to files that were deleted or moved without updating the scene.
static func _check_ext_resources_text(path: String, issues: Array) -> void:
var f := FileAccess.open(path, FileAccess.READ)
if f == null:
return
var text := f.get_as_text()
var regex := RegEx.new()
regex.compile("\\[ext_resource[^\\]]*path=\"([^\"]+)\"")
for m in regex.search_all(text):
var ref: String = m.get_string(1)
if ref.begins_with("uid://"):
var id := ResourceUID.text_to_id(ref)
if id == -1 or not ResourceUID.has_id(id):
issues.append({"path": path, "kind": "missing_uid", "uid": ref})
continue
if not ResourceLoader.exists(ref):
issues.append({"path": path, "kind": "missing_ext_resource", "target": ref})
@@ -0,0 +1 @@
uid://vlnvjolougnv
+175
View File
@@ -0,0 +1,175 @@
@tool
extends RefCounted
const Coerce := preload("res://addons/agent_tools/tools/_coerce.gd")
# resource.create — create a new .tres file.
# Params: {path, type, script?, properties?, overwrite?: false}
# 'type' must be a built-in Resource subclass (StyleBoxFlat, Theme, Curve, etc.).
# For custom Resource subclasses written in GDScript, pass 'script' pointing at the
# class's .gd file instead of 'type'.
static func create(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
var type_name: String = params.get("type", "")
var script_path: String = params.get("script", "")
var properties: Dictionary = params.get("properties", {})
var overwrite: bool = params.get("overwrite", false)
if path == "" or not path.begins_with("res://") or not path.ends_with(".tres"):
return _err(-32602, "'path' must be 'res://...' ending in .tres")
if FileAccess.file_exists(path) and not overwrite:
return _err(-32602, "file exists (pass overwrite:true to replace): %s" % path)
var res: Resource
if script_path != "":
var s := ResourceLoader.load(script_path, "Script") as Script
if s == null:
return _err(-32001, "failed to load script: %s" % script_path)
var inst = s.new()
if not (inst is Resource):
return _err(-32602, "script does not produce a Resource")
res = inst
elif type_name != "":
if not ClassDB.class_exists(type_name):
return _err(-32602, "unknown class: %s (use 'script' for custom Resource types)" % type_name)
if not ClassDB.is_parent_class(type_name, "Resource"):
return _err(-32602, "not a Resource subclass: %s" % type_name)
if not ClassDB.can_instantiate(type_name):
return _err(-32602, "class is not instantiable: %s" % type_name)
res = ClassDB.instantiate(type_name) as Resource
else:
return _err(-32602, "provide either 'type' (built-in Resource class) or 'script' (path to custom Resource .gd)")
var apply_res := _apply_properties(res, properties)
if apply_res.has("error"):
return apply_res
var dir_path := path.get_base_dir()
if not DirAccess.dir_exists_absolute(dir_path):
var derr := DirAccess.make_dir_recursive_absolute(dir_path)
if derr != OK:
return _err(-32001, "mkdir failed (%d): %s" % [derr, dir_path])
var save_err := ResourceSaver.save(res, path)
if save_err != OK:
return _err(-32001, "ResourceSaver.save failed: %d" % save_err)
EditorInterface.get_resource_filesystem().update_file(path)
return _ok({"path": path, "class": res.get_class()})
# resource.set_property — load a .tres, set one property, save it back.
# Params: {path, property, value} — same coercion as scene.set_property.
static func set_property(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
var property_name: String = params.get("property", "")
if path == "" or property_name == "":
return _err(-32602, "missing 'path' or 'property'")
var res := ResourceLoader.load(path)
if res == null:
return _err(-32001, "failed to load: %s" % path)
var prop_info: Dictionary = {}
for p in res.get_property_list():
if p.name == property_name:
prop_info = p
break
if prop_info.is_empty():
return _err(-32602, "property not found on %s: %s" % [res.get_class(), property_name])
if int(prop_info.get("usage", 0)) & PROPERTY_USAGE_READ_ONLY:
return _err(-32602, "property '%s' on %s is read-only" % [property_name, res.get_class()])
var coerced = Coerce.coerce(params.get("value"), prop_info.type)
if coerced is Dictionary and coerced.has("_error"):
return _err(-32602, coerced._error)
res.set(property_name, coerced)
var stored = res.get(property_name)
if coerced != null and stored == null and prop_info.type == TYPE_OBJECT:
return _err(-32001,
"property '%s' on %s was not accepted (assignment silently dropped — target expects a %s; passed value type didn't match)" %
[property_name, res.get_class(), prop_info.get("hint_string", "Resource")])
var save_err := ResourceSaver.save(res, path)
if save_err != OK:
return _err(-32001, "save failed: %d" % save_err)
EditorInterface.get_resource_filesystem().update_file(path)
return _ok({"path": path, "property": property_name, "value": Coerce.to_json(stored)})
# resource.call_method — load a .tres, invoke a method, save, return the method's result.
# Useful for helper methods that aren't properties — e.g. StyleBoxFlat.set_border_width_all(4)
# or set_corner_radius_all(14). Args are coerced against the method's declared types.
# Params: {path, method, args?: [], save?: true}
static func call_method(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
var method_name: String = params.get("method", "")
var args: Array = params.get("args", [])
var should_save: bool = params.get("save", true)
if path == "" or method_name == "":
return _err(-32602, "missing 'path' or 'method'")
var res := ResourceLoader.load(path)
if res == null:
return _err(-32001, "failed to load: %s" % path)
if not res.has_method(method_name):
return _err(-32602, "method not found: %s.%s" % [res.get_class(), method_name])
var coerced_args = _coerce_args(res, method_name, args)
if coerced_args is Dictionary:
return coerced_args # error passthrough (same reason as scene.call_method: no type annotation)
var result = res.callv(method_name, coerced_args)
if should_save:
var save_err := ResourceSaver.save(res, path)
if save_err != OK:
return _err(-32001, "save failed: %d" % save_err)
EditorInterface.get_resource_filesystem().update_file(path)
return _ok({
"path": path,
"method": method_name,
"return": Coerce.to_json(result),
"saved": should_save,
})
static func _coerce_args(obj: Object, method_name: String, args: Array):
var method_info: Dictionary = {}
for m in obj.get_method_list():
if m.name == method_name:
method_info = m
break
var arg_types: Array = method_info.get("args", [])
var out: Array = []
for i in args.size():
if i < arg_types.size():
var coerced = Coerce.coerce(args[i], arg_types[i].type)
if coerced is Dictionary and coerced.has("_error"):
return _err(-32602, "arg %d: %s" % [i, coerced._error])
out.append(coerced)
else:
out.append(args[i])
return out
static func _apply_properties(res: Resource, props: Dictionary) -> Dictionary:
var by_name: Dictionary = {}
for p in res.get_property_list():
by_name[p.name] = p
for key in props:
if not by_name.has(key):
return _err(-32602, "property not found on %s: %s" % [res.get_class(), key])
var coerced = Coerce.coerce(props[key], by_name[key].type)
if coerced is Dictionary and coerced.has("_error"):
return _err(-32602, "property %s: %s" % [key, coerced._error])
res.set(key, coerced)
return {}
static func _ok(data) -> Dictionary:
return {"data": data}
static func _err(code: int, msg: String) -> Dictionary:
return {"error": {"code": code, "message": msg}}
@@ -0,0 +1 @@
uid://cw3smf38qys7f
+239
View File
@@ -0,0 +1,239 @@
@tool
extends RefCounted
# run.scene_headless — run a scene in a child process and capture structured results.
#
# MODES
# BARE (no input_script / screenshot / screenshots / state_dump):
# Runs the target directly with --headless. Fast, no window. Right for
# "does _ready not crash" checks.
#
# DRIVEN (anything beyond "path" and "quit_after_seconds"):
# Runs the target under a shipped wrapper driver (addons/agent_tools/headless/
# driver.tscn) that reads a per-call config, injects scripted input per frame,
# captures one or more screenshots, and dumps final scene state on quit.
#
# SCREENSHOT NOTE
# Godot 4.6's --headless forces a Dummy renderer that makes captures impossible.
# When any screenshot is requested the subprocess runs WITH a visible window
# positioned at -9999,-9999 (offscreen on multi-monitor setups) and the real
# Vulkan/OpenGL renderer. Brief window flash is unavoidable.
#
# Params:
# path: required, "res://scene.tscn"
# quit_after_seconds: default 2.0
# extra_args: additional CLI args passed to the child godot process
# input_script: [{frame, type, ...}, ...] see event shapes below
# screenshot: single output PNG path (shorthand for one capture at quit_frame)
# screenshots: [{frame, path}, ...] multiple captures at specific frames
# resolution: "WxH", default "320x240". Only applies when screenshots requested.
# state_dump: bool, default false. When true, driver writes a JSON snapshot
# of the final scene tree (name/class/node_path/script/common
# properties) that the tool returns as result.final_state.
# seed: int, optional. Seeds the global RNG before instancing the
# target so runs are reproducible.
#
# Event types for input_script:
# {frame, type: "action_tap", action}
# {frame, type: "action_press", action, strength?}
# {frame, type: "action_release", action}
# {frame, type: "key", keycode, pressed?: true}
# {frame, type: "mouse_click", position: [x, y], button?: 1}
# {frame, type: "mouse_motion", position: [x, y]}
#
# Return includes: exit_code, output (stdout/stderr), command, errors, warnings,
# screenshots (array of {frame, path, captured: bool}), final_state (if state_dump),
# mode, quit_after_frames.
const STATE_DUMP_PATH := "res://.godot/agent_tools/headless/state_dump.json"
static func scene_headless(params: Dictionary) -> Dictionary:
var scene_path: String = params.get("path", "")
var quit_after_seconds: float = float(params.get("quit_after_seconds", 2.0))
var extra_args: Array = params.get("extra_args", [])
var input_script = params.get("input_script", null)
var resolution: String = params.get("resolution", "320x240")
var state_dump: bool = params.get("state_dump", false)
var rng_seed = params.get("seed", null)
# Normalize both screenshot forms into a single list.
var screenshots: Array = []
var single_shot: String = params.get("screenshot", "")
if single_shot != "":
screenshots.append({"frame": int(round(quit_after_seconds * 60.0)), "path": single_shot})
var multi: Array = params.get("screenshots", [])
for s in multi:
if typeof(s) != TYPE_DICTIONARY or not s.has("frame") or not s.has("path"):
return _err(-32602, "each 'screenshots' entry needs {frame, path}")
screenshots.append({"frame": int(s.frame), "path": str(s.path)})
if scene_path == "":
return _err(-32602, "missing 'path'")
if not ResourceLoader.exists(scene_path, "PackedScene"):
return _err(-32001, "scene not found: %s" % scene_path)
var frames: int = max(1, int(round(quit_after_seconds * 60.0)))
var use_driver := input_script != null or not screenshots.is_empty() or state_dump or rng_seed != null
var needs_window := not screenshots.is_empty()
var run_scene_path := scene_path
if use_driver:
# Wipe any stale state dump from prior run so "file exists" is a reliable signal.
if FileAccess.file_exists(STATE_DUMP_PATH):
@warning_ignore("return_value_discarded")
DirAccess.remove_absolute(STATE_DUMP_PATH)
# Strict-typed arg: can't pass a Variant-valued ternary directly into an
# Array param (parse error). Normalize first.
var script_list: Array = []
if input_script is Array:
script_list = input_script
var prep := _prepare_driver_config(scene_path, script_list, frames, screenshots, state_dump, rng_seed)
if prep.has("_error"):
return _err(-32001, prep._error)
run_scene_path = "res://addons/agent_tools/headless/driver.tscn"
var exe := OS.get_executable_path()
var project_dir := ProjectSettings.globalize_path("res://")
var hard_quit_frames: int = frames + 30
var args: Array = ["--path", project_dir]
if needs_window:
# Real renderer required for screenshots; run windowed but offscreen.
args.append("--windowed")
args.append("--resolution")
args.append(resolution)
args.append("--position")
args.append("-9999,-9999")
else:
args.append("--headless")
args.append("--quit-after")
args.append(str(hard_quit_frames))
for a in extra_args:
args.append(str(a))
args.append(run_scene_path)
var output: Array = []
var exit_code := OS.execute(exe, args, output, true)
# str() always returns a String; avoids Variant-via-ternary parse issue.
var stdout: String = ""
if output.size() > 0:
stdout = str(output[0])
var diagnostics := _extract_diagnostics(stdout)
var result: Dictionary = {
"exit_code": exit_code,
"output": stdout,
"command": "%s %s" % [exe, " ".join(args)],
"quit_after_frames": frames,
"mode": "driven" if use_driver else "bare",
"errors": diagnostics.errors,
"warnings": diagnostics.warnings,
}
if use_driver:
result["driver_scene"] = run_scene_path
if not screenshots.is_empty():
var shot_results: Array = []
for s in screenshots:
shot_results.append({
"frame": s.frame,
"path": s.path,
"captured": FileAccess.file_exists(s.path),
})
result["screenshots"] = shot_results
if state_dump:
# = not := — _load_state_dump has no return annotation so := can't infer.
var state = _load_state_dump()
if state != null:
result["final_state"] = state
return _ok(result)
static func _prepare_driver_config(target: String, input_script: Array, quit_frame: int, screenshots: Array, state_dump: bool, rng_seed) -> Dictionary:
var cfg_dir := "res://.godot/agent_tools/headless"
if not DirAccess.dir_exists_absolute(cfg_dir):
var derr := DirAccess.make_dir_recursive_absolute(cfg_dir)
if derr != OK:
return {"_error": "mkdir failed for %s (%d)" % [cfg_dir, derr]}
var config := {
"target_scene": target,
"input_script": input_script,
"quit_frame": quit_frame,
"screenshots": screenshots,
"state_dump": state_dump,
"state_dump_path": STATE_DUMP_PATH,
}
if rng_seed != null:
config["seed"] = int(rng_seed)
var cfg_path := cfg_dir + "/config.json"
var f := FileAccess.open(cfg_path, FileAccess.WRITE)
if f == null:
return {"_error": "failed to write driver config at %s" % cfg_path}
f.store_string(JSON.stringify(config, " "))
f.close()
return {"config_path": cfg_path}
# Parse Godot's stdout for ERROR:/WARNING:/SCRIPT ERROR:/USER ERROR:/USER WARNING: lines.
# Returns {errors: [{category, message, context}], warnings: [{category, message}]}.
# Context is the next line if it starts with whitespace (typical "at: file:line" trailer).
static func _extract_diagnostics(stdout: String) -> Dictionary:
var errors: Array = []
var warnings: Array = []
var lines: PackedStringArray = stdout.split("\n")
var i := 0
while i < lines.size():
var line: String = lines[i].trim_suffix("\r")
var category := ""
var is_error := false
var is_warning := false
for prefix in ["ERROR:", "USER ERROR:", "SCRIPT ERROR:"]:
if line.begins_with(prefix):
category = prefix.trim_suffix(":")
is_error = true
break
if not is_error:
for prefix in ["WARNING:", "USER WARNING:"]:
if line.begins_with(prefix):
category = prefix.trim_suffix(":")
is_warning = true
break
if is_error or is_warning:
var msg: String = line.substr(category.length() + 1).strip_edges()
var context := ""
if i + 1 < lines.size():
var next := lines[i + 1].trim_suffix("\r")
if next.begins_with(" ") or next.begins_with("\t"):
context = next.strip_edges()
i += 1
if is_error:
errors.append({"category": category, "message": msg, "context": context})
else:
warnings.append({"category": category, "message": msg, "context": context})
i += 1
return {"errors": errors, "warnings": warnings}
static func _load_state_dump():
if not FileAccess.file_exists(STATE_DUMP_PATH):
return null
var f := FileAccess.open(STATE_DUMP_PATH, FileAccess.READ)
if f == null:
return null
var text := f.get_as_text()
f.close()
var parsed = JSON.parse_string(text)
if typeof(parsed) != TYPE_DICTIONARY:
return null
return parsed
static func _ok(data) -> Dictionary:
return {"data": data}
static func _err(code: int, msg: String) -> Dictionary:
return {"error": {"code": code, "message": msg}}
@@ -0,0 +1 @@
uid://7r486ud37chv
+734
View File
@@ -0,0 +1,734 @@
@tool
extends RefCounted
const Coerce := preload("res://addons/agent_tools/tools/_coerce.gd")
# scene.inspect — read-only. Accepts {"path": "res://..."} or omits path to use the currently-edited scene.
# Returns {root: <NodeDict>, path: "<scene_file_path>"} where NodeDict = {name, class, node_path, script, children}.
static func inspect(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
var root: Node
var owns_root := false
var scene_file_path: String
if path == "":
root = EditorInterface.get_edited_scene_root()
if root == null:
return _err(-32001, "no scene open and no 'path' provided")
scene_file_path = root.scene_file_path
else:
var packed := ResourceLoader.load(path, "PackedScene") as PackedScene
if packed == null:
return _err(-32001, "failed to load scene: %s" % path)
root = packed.instantiate(PackedScene.GEN_EDIT_STATE_DISABLED)
if root == null:
return _err(-32001, "instantiate failed: %s" % path)
owns_root = true
scene_file_path = path
var tree := _node_to_dict(root, root)
if owns_root:
root.free()
return _ok({"path": scene_file_path, "root": tree})
static func _node_to_dict(node: Node, scene_root: Node) -> Dictionary:
var d := {
"name": String(node.name),
"class": node.get_class(),
"node_path": "." if node == scene_root else String(scene_root.get_path_to(node)),
"script": "",
"children": [],
}
var scr := node.get_script() as Script
if scr:
d.script = scr.resource_path
for child in node.get_children():
# Only include nodes belonging to this scene (skip runtime-added children of instanced sub-scenes).
if child.owner == scene_root or child == scene_root:
d.children.append(_node_to_dict(child, scene_root))
return d
# scene.add_node — operates on the currently-edited scene.
# Params: {type: "Node2D", name?: "Foo", parent_path?: "."}
static func add_node(params: Dictionary) -> Dictionary:
var node_type: String = params.get("type", "")
var node_name: String = params.get("name", "")
var parent_path: String = params.get("parent_path", ".")
if node_type == "":
return _err(-32602, "missing 'type'")
if not ClassDB.class_exists(node_type):
return _err(-32602, "unknown class: %s" % node_type)
if not ClassDB.is_parent_class(node_type, "Node"):
return _err(-32602, "type must derive from Node: %s" % node_type)
if not ClassDB.can_instantiate(node_type):
return _err(-32602, "class is not instantiable: %s" % node_type)
var root := EditorInterface.get_edited_scene_root()
if root == null:
return _err(-32001, "no scene open")
var parent: Node = root if parent_path == "." or parent_path == "" else root.get_node_or_null(parent_path)
if parent == null:
return _err(-32001, "parent not found: %s" % parent_path)
var inst := ClassDB.instantiate(node_type) as Node
if inst == null:
return _err(-32001, "failed to instantiate %s" % node_type)
parent.add_child(inst)
if node_name != "":
inst.name = node_name
# Owner MUST be set to the scene root, otherwise the node won't be saved.
inst.owner = root
EditorInterface.mark_scene_as_unsaved()
return _ok({
"node_path": String(root.get_path_to(inst)),
"name": String(inst.name),
"class": inst.get_class(),
})
# scene.set_property — operates on the currently-edited scene.
# Params: {node_path, property, value} — value is coerced via Coerce.coerce based on
# the target property's type. See _coerce.gd for the full supported-type list.
static func set_property(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
var property_name: String = params.get("property", "")
if node_path == "" or property_name == "":
return _err(-32602, "missing 'node_path' or 'property'")
var root := EditorInterface.get_edited_scene_root()
if root == null:
return _err(-32001, "no scene open")
var node: Node = root if node_path == "." else root.get_node_or_null(node_path)
if node == null:
return _err(-32001, "node not found: %s" % node_path)
var prop_info: Dictionary = {}
for p in node.get_property_list():
if p.name == property_name:
prop_info = p
break
if prop_info.is_empty():
return _err(-32602, "property not found on %s: %s" % [node.get_class(), property_name])
# Reject read-only properties upfront — Godot would silently ignore the set().
if int(prop_info.get("usage", 0)) & PROPERTY_USAGE_READ_ONLY:
return _err(-32602, "property '%s' on %s is read-only" % [property_name, node.get_class()])
var assigned := _assign_property(node, property_name, prop_info, params.get("value"))
if assigned.has("error"):
return _err(int(assigned.get("code", -32001)), assigned.error)
EditorInterface.mark_scene_as_unsaved()
return _ok({
"node_path": node_path,
"property": property_name,
"value": Coerce.to_json(assigned.get("stored")),
})
# Assign a JSON-native value into node[property_name], handling three failure
# modes the bare `node.set()` path gets wrong:
#
# 1. Node-typed @exports (`@export var foo: Node2D`) — TYPE_OBJECT with a
# PROPERTY_HINT_NODE_TYPE hint. A path string must be resolved to the actual
# Node so PackedScene.pack() emits the `node_paths=PackedStringArray(...)`
# metadata and serializes it (the inspector drag-drop equivalent). A bare
# string assignment is silently dropped at save time.
# 2. Setter-routed properties (notably PROPERTY_USAGE_NO_EDITOR ones like
# `unique_name_in_owner`) — `node.set()` doesn't reliably dispatch to the
# registered setter. If the readback doesn't match, route through the
# explicit `set_<name>` method.
# 3. Silently-dropped assignments — Godot accepts the set() call but drops the
# value (type mismatch on a Resource slot, or a primitive that didn't take).
# Surface an error instead of echoing a misleading value.
#
# Returns {"stored": <value>} on success, or {"error": msg, "code": int} on failure.
static func _assign_property(node: Node, property_name: String, prop_info: Dictionary, raw_value) -> Dictionary:
# Case 1: node-typed export. Resolve a path string to the actual Node.
if prop_info.type == TYPE_OBJECT and int(prop_info.get("hint", 0)) == PROPERTY_HINT_NODE_TYPE:
if raw_value == null:
node.set(property_name, null)
return {"stored": null}
if not (raw_value is String):
return {"code": -32602, "error":
"node-typed export '%s' expects a node path string (e.g. '../Target'), got %s" %
[property_name, type_string(typeof(raw_value))]}
var target := node.get_node_or_null(NodePath(raw_value))
if target == null:
return {"code": -32001, "error":
"node-typed export '%s' on %s: no node at path '%s' (resolved relative to '%s'). " %
[property_name, node.get_class(), raw_value, node.name] +
"The target node must already exist in the scene."}
node.set(property_name, target)
var stored_node = node.get(property_name)
if stored_node != target:
return {"code": -32001, "error":
"node-typed export '%s' on %s: assignment dropped — resolved node is a %s but the slot expects a %s" %
[property_name, node.get_class(), target.get_class(), prop_info.get("hint_string", "Node")]}
return {"stored": stored_node}
var coerced = Coerce.coerce(raw_value, prop_info.type)
if coerced is Dictionary and coerced.has("_error"):
return {"code": -32602, "error": coerced._error}
node.set(property_name, coerced)
var stored = node.get(property_name)
# Case 2: setter routing. Some properties don't dispatch through node.set();
# if the readback diverged and an explicit setter exists, go through it.
if not _values_match(stored, coerced) and node.has_method("set_" + property_name):
node.call("set_" + property_name, coerced)
stored = node.get(property_name)
# Case 3a: Resource-typed slot silently rejected a non-Resource value.
if coerced != null and stored == null and prop_info.type == TYPE_OBJECT:
return {"code": -32001, "error":
"property '%s' on %s was not accepted (assignment silently dropped — target expects a %s; passed value type didn't match)" %
[property_name, node.get_class(), prop_info.get("hint_string", "Resource")]}
# Case 3b: a bool that didn't take. Limited to bool (the documented pitfall,
# e.g. unique_name_in_owner) because other primitives legitimately transform
# on set (name auto-deduplicates, ints clamp to range, floats lose precision),
# which would make a strict match check fire false positives.
if prop_info.type == TYPE_BOOL and not _values_match(stored, coerced):
return {"code": -32001, "error":
"property '%s' on %s was not accepted (set silently dropped: requested %s, stored %s)" %
[property_name, node.get_class(), str(coerced), str(stored)]}
return {"stored": stored}
# Equality check tolerant of GDScript's cross-type Variant comparison. Used to
# detect whether an assignment actually took before deciding to route through a
# setter or flag a silent drop.
static func _values_match(a, b) -> bool:
return a == b
# scene.remove_node — operates on the currently-edited scene. Cannot remove the scene root.
# Params: {node_path: "Player/Sprite2D"}
static func remove_node(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
if node_path == "":
return _err(-32602, "missing 'node_path'")
if node_path == ".":
return _err(-32602, "cannot remove scene root")
var root := EditorInterface.get_edited_scene_root()
if root == null:
return _err(-32001, "no scene open")
var node: Node = root.get_node_or_null(node_path)
if node == null:
return _err(-32001, "node not found: %s" % node_path)
node.get_parent().remove_child(node)
node.queue_free()
EditorInterface.mark_scene_as_unsaved()
return _ok({"removed": node_path})
# scene.reparent — move a node under a new parent in the currently-edited scene.
# Params: {node_path, new_parent_path, keep_global_transform?: true}
static func reparent(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
var new_parent_path: String = params.get("new_parent_path", "")
var keep_xform: bool = params.get("keep_global_transform", true)
if node_path == "" or new_parent_path == "":
return _err(-32602, "missing 'node_path' or 'new_parent_path'")
if node_path == ".":
return _err(-32602, "cannot reparent scene root")
var root := EditorInterface.get_edited_scene_root()
if root == null:
return _err(-32001, "no scene open")
var node: Node = root.get_node_or_null(node_path)
if node == null:
return _err(-32001, "node not found: %s" % node_path)
var new_parent: Node = root if new_parent_path == "." else root.get_node_or_null(new_parent_path)
if new_parent == null:
return _err(-32001, "new parent not found: %s" % new_parent_path)
if new_parent == node or node.is_ancestor_of(new_parent):
return _err(-32602, "cannot reparent under self or descendant (would create cycle)")
node.reparent(new_parent, keep_xform)
# reparent() preserves owner in 4.3+, but be explicit so the node still serializes.
node.owner = root
EditorInterface.mark_scene_as_unsaved()
return _ok({"node_path": String(root.get_path_to(node))})
# scene.open — open a scene in the editor, making it the currently-edited scene.
# Params: {path: "res://Main.tscn"}
static func open_scene(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
if path == "":
return _err(-32602, "missing 'path'")
if not ResourceLoader.exists(path, "PackedScene"):
return _err(-32001, "scene not found: %s" % path)
EditorInterface.open_scene_from_path(path)
return _ok({"path": path})
# scene.save — save the currently-edited scene. Pass 'path' to save-as (rebinds
# the scene to that path).
#
# Fresh scenes (created via `scene.add_node`-only workflows with no backing file,
# or never-saved manual scenes) have an empty `scene_file_path`. Godot's bare
# `save_scene()` responds by opening the native Save-As file dialog — blocking
# the editor and requiring a human click. We detect that case and return an
# error instead, so the agent can react cleanly by re-calling with `path`.
static func save_scene(params: Dictionary) -> Dictionary:
var root := EditorInterface.get_edited_scene_root()
if root == null:
return _err(-32001, "no scene open")
var path: String = params.get("path", "")
var current_path: String = root.scene_file_path
if path == "" and current_path == "":
return _err(-32602,
"scene has never been saved and no 'path' was given. " +
"Calling bare save would open the Save-As dialog and block the editor. " +
"Re-call with 'path' set to a res:// target (e.g. 'res://scenes/foo.tscn').")
if path == "":
var err := EditorInterface.save_scene()
if err != OK:
return _err(-32001, "save failed: error %d" % err)
else:
# save_scene_as does NOT auto-create parent directories — it silently
# fails if the target dir doesn't exist. Ensure the dir first.
var dir_path := path.get_base_dir()
if not DirAccess.dir_exists_absolute(dir_path):
var derr := DirAccess.make_dir_recursive_absolute(dir_path)
if derr != OK:
return _err(-32001, "mkdir failed (%d): %s" % [derr, dir_path])
# save_scene_as returns void in Godot 4.x — no error to check directly.
EditorInterface.save_scene_as(path)
var final_path: String = EditorInterface.get_edited_scene_root().scene_file_path
# If we passed a path but the scene still isn't bound, the save didn't land.
# Surface that explicitly instead of returning {"path": ""} which looks like success.
if path != "" and final_path == "":
return _err(-32001,
"save_scene_as('%s') did not bind the scene — verify the path is writable and ends in .tscn" % path)
return _ok({"path": final_path})
# scene.new — create a new .tscn file with a root node of the given type. By default
# opens the new scene in the editor so subsequent scene.* calls operate on it.
# Params: {path, root_type?: "Node", root_name?, overwrite?: false, open_after?: true}
static func new_scene(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
var root_type: String = params.get("root_type", "Node")
var root_name: String = params.get("root_name", "")
var overwrite: bool = params.get("overwrite", false)
var open_after: bool = params.get("open_after", true)
if path == "" or not path.begins_with("res://") or not path.ends_with(".tscn"):
return _err(-32602, "'path' must be 'res://...' ending in .tscn")
if FileAccess.file_exists(path) and not overwrite:
return _err(-32602, "file exists (pass overwrite:true to replace): %s" % path)
if not ClassDB.class_exists(root_type):
return _err(-32602, "unknown class: %s" % root_type)
if not ClassDB.is_parent_class(root_type, "Node"):
return _err(-32602, "root_type must derive from Node: %s" % root_type)
if not ClassDB.can_instantiate(root_type):
return _err(-32602, "class is not instantiable: %s" % root_type)
var root := ClassDB.instantiate(root_type) as Node
if root == null:
return _err(-32001, "failed to instantiate %s" % root_type)
if root_name != "":
root.name = root_name
var dir_path := path.get_base_dir()
if not DirAccess.dir_exists_absolute(dir_path):
var derr := DirAccess.make_dir_recursive_absolute(dir_path)
if derr != OK:
root.free()
return _err(-32001, "mkdir failed (%d): %s" % [derr, dir_path])
var packed := PackedScene.new()
var pack_err := packed.pack(root)
var final_name := String(root.name)
root.free()
if pack_err != OK:
return _err(-32001, "PackedScene.pack failed: %d" % pack_err)
var save_err := ResourceSaver.save(packed, path)
if save_err != OK:
return _err(-32001, "ResourceSaver.save failed: %d" % save_err)
EditorInterface.get_resource_filesystem().update_file(path)
if open_after:
# If the target path is already the currently-edited scene, open_scene_from_path
# will just switch to the existing (stale) in-memory tab. Force a reload so the
# freshly-written file is what the editor shows.
var current_root := EditorInterface.get_edited_scene_root()
if current_root != null and current_root.scene_file_path == path:
EditorInterface.reload_scene_from_path(path)
else:
EditorInterface.open_scene_from_path(path)
return _ok({"path": path, "root_type": root_type, "root_name": final_name})
# scene.instance_packed — add an existing .tscn as a sub-scene child in the currently-edited scene.
# Params: {scene_path, parent_path?: ".", name?}
static func instance_packed(params: Dictionary) -> Dictionary:
var scene_path: String = params.get("scene_path", "")
var parent_path: String = params.get("parent_path", ".")
var node_name: String = params.get("name", "")
if scene_path == "":
return _err(-32602, "missing 'scene_path'")
var packed := ResourceLoader.load(scene_path, "PackedScene") as PackedScene
if packed == null:
return _err(-32001, "failed to load scene: %s" % scene_path)
var root := EditorInterface.get_edited_scene_root()
if root == null:
return _err(-32001, "no scene open")
var parent: Node = root if parent_path == "." or parent_path == "" else root.get_node_or_null(parent_path)
if parent == null:
return _err(-32001, "parent not found: %s" % parent_path)
if scene_path == root.scene_file_path:
return _err(-32602, "cannot instance a scene into itself (would recurse)")
var inst := packed.instantiate()
if inst == null:
return _err(-32001, "instantiate failed")
parent.add_child(inst)
if node_name != "":
inst.name = node_name
# Owner is the scene root so the sub-scene instance serializes with this scene.
inst.owner = root
EditorInterface.mark_scene_as_unsaved()
return _ok({
"node_path": String(root.get_path_to(inst)),
"scene_path": scene_path,
"name": String(inst.name),
})
# scene.get_property — read a property from a node. Mirror of set_property.
# Params: {node_path, property}
static func get_property(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
var property_name: String = params.get("property", "")
if node_path == "" or property_name == "":
return _err(-32602, "missing 'node_path' or 'property'")
var root := EditorInterface.get_edited_scene_root()
if root == null:
return _err(-32001, "no scene open")
var node: Node = root if node_path == "." else root.get_node_or_null(node_path)
if node == null:
return _err(-32001, "node not found: %s" % node_path)
var prop_type: int = TYPE_NIL
var found := false
for p in node.get_property_list():
if p.name == property_name:
prop_type = p.type
found = true
break
if not found:
return _err(-32602, "property not found on %s: %s" % [node.get_class(), property_name])
return _ok({
"node_path": node_path,
"property": property_name,
"value": Coerce.to_json(node.get(property_name)),
"type": type_string(prop_type),
})
# scene.capture_screenshot — save a PNG of what the editor is currently showing
# for the open scene. Captures the appropriate editor viewport (2D for 2D/Control
# root nodes, 3D for Node3D roots). Includes the editor's grid/gizmos — this is
# "what the user sees" rather than a clean render, which is actually useful for
# the agent to spot layout problems.
# Params: {output?: "res://screenshot.png"}
# Returns: {path, width, height}
static func capture_screenshot(params: Dictionary) -> Dictionary:
var output_path: String = params.get("output", "")
var root := EditorInterface.get_edited_scene_root()
if root == null:
return _err(-32001, "no scene open — open one first with scene.open")
var viewport: SubViewport
if root is Node3D:
viewport = EditorInterface.get_editor_viewport_3d(0)
else:
viewport = EditorInterface.get_editor_viewport_2d()
if viewport == null:
return _err(-32001, "could not obtain editor viewport")
var tex := viewport.get_texture()
if tex == null:
return _err(-32001, "viewport has no texture yet (try again after the editor renders a frame)")
var img := tex.get_image()
if img == null or img.is_empty():
return _err(-32001, "failed to capture image from viewport")
if output_path == "":
var base: String = "screenshot"
if root.scene_file_path != "":
base = root.scene_file_path.get_file().get_basename()
output_path = "res://.godot/agent_tools/%s.png" % base
var dir_path := output_path.get_base_dir()
if not DirAccess.dir_exists_absolute(dir_path):
var derr := DirAccess.make_dir_recursive_absolute(dir_path)
if derr != OK:
return _err(-32001, "mkdir failed: %d" % derr)
var save_err := img.save_png(output_path)
if save_err != OK:
return _err(-32001, "save_png failed: %d" % save_err)
return _ok({
"path": output_path,
"width": img.get_width(),
"height": img.get_height(),
"note": "clean viewport capture (no editor grid/gizmos); empty scenes render as the viewport background color",
})
# scene.duplicate_node — clone a node (with its descendants) into the currently-edited scene.
# Owner is set recursively so the duplicated subtree serializes with the scene.
# Params: {node_path, new_name?, parent_path?: same parent}
static func duplicate_node(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
var new_name: String = params.get("new_name", "")
var parent_path: String = params.get("parent_path", "")
if node_path == "":
return _err(-32602, "missing 'node_path'")
if node_path == ".":
return _err(-32602, "cannot duplicate scene root")
var root := EditorInterface.get_edited_scene_root()
if root == null:
return _err(-32001, "no scene open")
var src: Node = root.get_node_or_null(node_path)
if src == null:
return _err(-32001, "node not found: %s" % node_path)
# DUPLICATE_SIGNALS | DUPLICATE_GROUPS | DUPLICATE_SCRIPTS — default flags.
var copy: Node = src.duplicate(7)
if copy == null:
return _err(-32001, "duplicate() failed")
var parent: Node = src.get_parent() if parent_path == "" else root.get_node_or_null(parent_path)
if parent == null:
return _err(-32001, "parent not found: %s" % parent_path)
parent.add_child(copy)
if new_name != "":
copy.name = new_name
_set_owner_recursive(copy, root)
EditorInterface.mark_scene_as_unsaved()
return _ok({
"source": node_path,
"node_path": String(root.get_path_to(copy)),
"name": String(copy.name),
})
static func _set_owner_recursive(node: Node, owner: Node) -> void:
node.owner = owner
for child in node.get_children():
_set_owner_recursive(child, owner)
# scene.build_tree — build a subtree in one call.
# Spec is recursive: each entry is {type (required), name?, properties?, script?, children?}.
# Collapses what would otherwise be N scene.add_node + M scene.set_property + K script.attach
# calls into a single request, which matters for any UI-heavy scene where hand-writing the
# tree by calling the atomic tools balloons into dozens of round trips.
# Properties use the same Coerce rules as scene.set_property (Vectors from arrays, Resources
# auto-loaded from res:// paths, etc.).
# On any failure, all nodes created during the call are rolled back so the scene doesn't
# end up partly built.
# Params: {parent_path?: ".", nodes: [TreeNode, ...]}
# TreeNode: {type: "ClassName", name?, properties?: {name: value}, script?: "res://...", children?: [TreeNode, ...]}
static func build_tree(params: Dictionary) -> Dictionary:
var parent_path: String = params.get("parent_path", ".")
var nodes: Array = params.get("nodes", [])
if nodes.is_empty():
return _err(-32602, "missing 'nodes' (array of tree entries)")
var root := EditorInterface.get_edited_scene_root()
if root == null:
return _err(-32001, "no scene open")
var parent: Node = root if parent_path == "." or parent_path == "" else root.get_node_or_null(parent_path)
if parent == null:
return _err(-32001, "parent not found: %s" % parent_path)
var created_nodes: Array = []
var created_paths: Array = []
for spec in nodes:
var err_msg := _build_subtree(spec, parent, root, created_nodes, created_paths)
if err_msg != "":
# Rollback: free everything created during this call in reverse order.
for i in range(created_nodes.size() - 1, -1, -1):
var n: Node = created_nodes[i]
if is_instance_valid(n):
if n.get_parent():
n.get_parent().remove_child(n)
n.queue_free()
return _err(-32001, err_msg)
EditorInterface.mark_scene_as_unsaved()
return _ok({
"parent_path": parent_path,
"created": created_paths,
"count": created_paths.size(),
})
# Recursive worker. Appends to the shared created_nodes/created_paths arrays so
# the caller can roll back on failure. Returns "" on success, or an error message.
static func _build_subtree(spec, parent: Node, scene_root: Node, created_nodes: Array, created_paths: Array) -> String:
if typeof(spec) != TYPE_DICTIONARY:
return "tree entry must be an object, got %s" % type_string(typeof(spec))
var node_type: String = spec.get("type", "")
if node_type == "":
return "tree entry missing 'type'"
if not ClassDB.class_exists(node_type):
return "unknown class: %s" % node_type
if not ClassDB.is_parent_class(node_type, "Node"):
return "type must derive from Node: %s" % node_type
if not ClassDB.can_instantiate(node_type):
return "class is not instantiable: %s" % node_type
var inst := ClassDB.instantiate(node_type) as Node
if inst == null:
return "failed to instantiate %s" % node_type
var node_name: String = spec.get("name", "")
if node_name != "":
inst.name = node_name
parent.add_child(inst)
inst.owner = scene_root
created_nodes.append(inst)
var this_path := String(scene_root.get_path_to(inst))
created_paths.append(this_path)
# Attach a script before applying properties so script-exported properties
# become settable in this same call.
var script_path: String = spec.get("script", "")
if script_path != "":
var script := ResourceLoader.load(script_path, "Script") as Script
if script == null:
return "failed to load script '%s' for '%s'" % [script_path, this_path]
inst.set_script(script)
var properties: Dictionary = spec.get("properties", {})
if not properties.is_empty():
var by_name: Dictionary = {}
for p in inst.get_property_list():
by_name[p.name] = p
for prop_name in properties:
if not by_name.has(prop_name):
return "property not found on %s ('%s'): %s" % [node_type, this_path, prop_name]
var prop_info: Dictionary = by_name[prop_name]
if int(prop_info.get("usage", 0)) & PROPERTY_USAGE_READ_ONLY:
return "property '%s' on '%s' is read-only" % [prop_name, this_path]
var assigned := _assign_property(inst, prop_name, prop_info, properties[prop_name])
if assigned.has("error"):
return "property '%s' on '%s': %s" % [prop_name, this_path, assigned.error]
var children: Array = spec.get("children", [])
for child_spec in children:
var child_err := _build_subtree(child_spec, inst, scene_root, created_nodes, created_paths)
if child_err != "":
return child_err
return ""
# scene.call_method — invoke a method on a node in the currently-edited scene.
# Args are coerced based on the target method's declared parameter types
# (same rules as scene.set_property — including res:// → Resource auto-load).
# Return value is serialized via Coerce.to_json so the echo is unambiguous data.
# Params: {node_path, method, args?: []}
static func call_method(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
var method_name: String = params.get("method", "")
var args: Array = params.get("args", [])
if node_path == "" or method_name == "":
return _err(-32602, "missing 'node_path' or 'method'")
var root := EditorInterface.get_edited_scene_root()
if root == null:
return _err(-32001, "no scene open")
var node: Node = root if node_path == "." else root.get_node_or_null(node_path)
if node == null:
return _err(-32001, "node not found: %s" % node_path)
if not node.has_method(method_name):
return _err(-32602, "method not found: %s.%s" % [node.get_class(), method_name])
# Not typed as Array — _coerce_args returns a Dictionary on error, and strict
# typing would reject the assignment before we can branch on it.
var coerced_args = _coerce_args(node, method_name, args)
if coerced_args is Dictionary:
return coerced_args # error passthrough
var result = node.callv(method_name, coerced_args)
EditorInterface.mark_scene_as_unsaved()
return _ok({
"node_path": node_path,
"method": method_name,
"return": Coerce.to_json(result),
})
# Look up the method's parameter types and coerce each arg accordingly.
# Returns a coerced Array, or a Dictionary with {"error": ...} on failure.
static func _coerce_args(obj: Object, method_name: String, args: Array):
var method_info: Dictionary = {}
for m in obj.get_method_list():
if m.name == method_name:
method_info = m
break
var arg_types: Array = method_info.get("args", [])
var out: Array = []
for i in args.size():
if i < arg_types.size():
var coerced = Coerce.coerce(args[i], arg_types[i].type)
if coerced is Dictionary and coerced.has("_error"):
return _err(-32602, "arg %d: %s" % [i, coerced._error])
out.append(coerced)
else:
out.append(args[i])
return out
# scene.current — describe the currently-edited scene, or {open: false} if none.
static func current(_params: Dictionary) -> Dictionary:
var root := EditorInterface.get_edited_scene_root()
if root == null:
return _ok({"open": false})
return _ok({
"open": true,
"path": root.scene_file_path,
"root_name": String(root.name),
"root_class": root.get_class(),
})
static func _ok(data) -> Dictionary:
return {"data": data}
static func _err(code: int, msg: String) -> Dictionary:
return {"error": {"code": code, "message": msg}}
@@ -0,0 +1 @@
uid://b2d5doqppnqvn
+171
View File
@@ -0,0 +1,171 @@
@tool
extends RefCounted
# script.create — write a new .gd file with an extends/class_name header.
# Params: {path: "res://scripts/Player.gd", extends?: "Node", class_name?: "Player",
# attach_to_node?: "Player", overwrite?: false}
static func create(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
var base_class: String = params.get("extends", "Node")
var class_name_val: String = params.get("class_name", "")
var overwrite: bool = params.get("overwrite", false)
var attach_to: String = params.get("attach_to_node", "")
if path == "" or not path.begins_with("res://") or not path.ends_with(".gd"):
return _err(-32602, "'path' must be 'res://...' ending in .gd")
if FileAccess.file_exists(path) and not overwrite:
return _err(-32602, "file exists (pass overwrite:true to replace): %s" % path)
var dir_path := path.get_base_dir()
if not DirAccess.dir_exists_absolute(dir_path):
var derr := DirAccess.make_dir_recursive_absolute(dir_path)
if derr != OK:
return _err(-32001, "mkdir failed (%d): %s" % [derr, dir_path])
var body := "extends %s\n" % base_class
if class_name_val != "":
body += "class_name %s\n" % class_name_val
body += "\n"
var f := FileAccess.open(path, FileAccess.WRITE)
if f == null:
return _err(-32001, "failed to open for write: %s" % path)
f.store_string(body)
f.close()
# Let the editor discover the new file so load() succeeds immediately.
EditorInterface.get_resource_filesystem().update_file(path)
var data := {"path": path, "extends": base_class, "class_name": class_name_val}
if attach_to != "":
var attach_res := _attach_script_to_node(attach_to, path)
if attach_res.has("error"):
return attach_res
data["attached_to"] = attach_to
return _ok(data)
# script.attach — attach an existing script to a node in the currently-edited scene.
# Params: {node_path, script_path}
static func attach(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
var script_path: String = params.get("script_path", "")
if node_path == "" or script_path == "":
return _err(-32602, "missing 'node_path' or 'script_path'")
return _attach_script_to_node(node_path, script_path)
# script.patch — targeted edits to an existing .gd file. Two modes:
# replacements: [{old, new}] — each 'old' must appear exactly once in the file;
# fails atomically if any replacement is ambiguous
# or missing. Useful for surgical edits.
# full_source: string — overwrite the whole file (same as script.create
# overwrite, kept here for symmetry).
# After writing, the tool parse-checks the result via ResourceLoader.load(); if
# that fails, the original file is restored and an error is returned.
# Params: {path, replacements?: [{old, new}], full_source?: string, dry_run?: false}
static func patch(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
var replacements: Array = params.get("replacements", [])
var full_source: String = params.get("full_source", "")
var dry_run: bool = params.get("dry_run", false)
if path == "" or not path.ends_with(".gd"):
return _err(-32602, "'path' must be a .gd file")
if not FileAccess.file_exists(path):
return _err(-32001, "file not found: %s" % path)
if replacements.is_empty() and full_source == "":
return _err(-32602, "provide either 'replacements' or 'full_source'")
var f := FileAccess.open(path, FileAccess.READ)
if f == null:
return _err(-32001, "could not read %s" % path)
var original := f.get_as_text()
f.close()
var patched: String = original
var applied: Array = []
if full_source != "":
patched = full_source
applied.append("full_source")
else:
for entry in replacements:
if typeof(entry) != TYPE_DICTIONARY or not entry.has("old") or not entry.has("new"):
return _err(-32602, "each replacement needs {old, new}")
var needle: String = str(entry.old)
var replacement: String = str(entry.new)
var count := patched.count(needle)
if count == 0:
return _err(-32602, "'old' string not found (would leave file unchanged): %s" % needle.substr(0, 60))
if count > 1:
return _err(-32602, "'old' string appears %d times — ambiguous. Include more context to make it unique: %s" % [count, needle.substr(0, 60)])
patched = patched.replace(needle, replacement)
applied.append("replaced %d chars" % needle.length())
if patched == original:
return _ok({"path": path, "changed": false, "note": "patched content identical to original"})
if dry_run:
return _ok({
"path": path,
"changed": true,
"applied": applied,
"new_length": patched.length(),
"original_length": original.length(),
})
# Write, then parse-check. If parse fails, restore.
var wf := FileAccess.open(path, FileAccess.WRITE)
if wf == null:
return _err(-32001, "could not write %s" % path)
wf.store_string(patched)
wf.close()
EditorInterface.get_resource_filesystem().update_file(path)
# Force a fresh parse — ResourceLoader.load returns a cached/placeholder
# Script for malformed sources, so we can't just null-check. Call reload()
# which actually re-parses and returns an Error.
var check := ResourceLoader.load(path, "Script", ResourceLoader.CACHE_MODE_REPLACE)
var parse_ok := false
if check != null and check is GDScript:
parse_ok = (check.reload(true) == OK)
if not parse_ok:
# Restore original to keep the file loadable.
var rf := FileAccess.open(path, FileAccess.WRITE)
if rf:
rf.store_string(original)
rf.close()
EditorInterface.get_resource_filesystem().update_file(path)
return _err(-32001, "patched script failed to parse — changes rolled back. Check Godot's Output panel for the exact error.")
return _ok({
"path": path,
"changed": true,
"applied": applied,
"new_length": patched.length(),
})
static func _attach_script_to_node(node_path: String, script_path: String) -> Dictionary:
var root := EditorInterface.get_edited_scene_root()
if root == null:
return _err(-32001, "no scene open")
var node: Node = root if node_path == "." else root.get_node_or_null(node_path)
if node == null:
return _err(-32001, "node not found: %s" % node_path)
var script := ResourceLoader.load(script_path, "Script") as Script
if script == null:
return _err(-32001, "failed to load script: %s" % script_path)
node.set_script(script)
EditorInterface.mark_scene_as_unsaved()
return _ok({"node_path": node_path, "script": script_path})
static func _ok(data) -> Dictionary:
return {"data": data}
static func _err(code: int, msg: String) -> Dictionary:
return {"error": {"code": code, "message": msg}}
@@ -0,0 +1 @@
uid://bfewrbdp2o52o
+148
View File
@@ -0,0 +1,148 @@
@tool
extends RefCounted
# signal.connect — operates on the currently-edited scene.
# Params: {from: "Player", signal: "died", to: ".", method: "_on_player_died"}
# Uses CONNECT_PERSIST so the connection is serialized into the .tscn as a [connection] block.
static func connect_signal(params: Dictionary) -> Dictionary:
var from_path: String = params.get("from", "")
var signal_name: String = params.get("signal", "")
var to_path: String = params.get("to", "")
var method_name: String = params.get("method", "")
for required in ["from", "signal", "to", "method"]:
if params.get(required, "") == "":
return _err(-32602, "missing '%s'" % required)
var root := EditorInterface.get_edited_scene_root()
if root == null:
return _err(-32001, "no scene open")
var source: Node = root if from_path == "." else root.get_node_or_null(from_path)
if source == null:
return _err(-32001, "source node not found: %s" % from_path)
var target: Node = root if to_path == "." else root.get_node_or_null(to_path)
if target == null:
return _err(-32001, "target node not found: %s" % to_path)
if not source.has_signal(signal_name):
return _err(-32602, "%s has no signal '%s'" % [source.get_class(), signal_name])
if not target.has_method(method_name):
return _err(-32602, "target has no method '%s' — attach a script with it first" % method_name)
# Arity check: number of non-default signal args must be <= target method arg count.
var sig_info := _find_signal(source, signal_name)
var method_info := _find_method(target, method_name)
if sig_info and method_info:
var sig_args: int = (sig_info.args as Array).size()
var method_args: int = (method_info.args as Array).size()
if method_args < sig_args:
return _err(-32602, "arity mismatch: signal '%s' emits %d args, method '%s' accepts %d" %
[signal_name, sig_args, method_name, method_args])
var callable := Callable(target, method_name)
if source.is_connected(signal_name, callable):
return _ok({"already_connected": true})
var err := source.connect(signal_name, callable, CONNECT_PERSIST)
if err != OK:
return _err(-32001, "connect() returned %d" % err)
EditorInterface.mark_scene_as_unsaved()
return _ok({
"from": from_path,
"signal": signal_name,
"to": to_path,
"method": method_name,
})
# signal.disconnect — remove a specific wiring in the currently-edited scene.
# Params: {from, signal, to, method} — same shape as signal.connect.
static func disconnect_signal(params: Dictionary) -> Dictionary:
for required in ["from", "signal", "to", "method"]:
if params.get(required, "") == "":
return _err(-32602, "missing '%s'" % required)
var root := EditorInterface.get_edited_scene_root()
if root == null:
return _err(-32001, "no scene open")
var from_path: String = params["from"]
var to_path: String = params["to"]
var source: Node = root if from_path == "." else root.get_node_or_null(from_path)
var target: Node = root if to_path == "." else root.get_node_or_null(to_path)
if source == null:
return _err(-32001, "source not found: %s" % from_path)
if target == null:
return _err(-32001, "target not found: %s" % to_path)
var callable := Callable(target, params["method"])
if not source.is_connected(params["signal"], callable):
return _err(-32001, "no such connection")
source.disconnect(params["signal"], callable)
EditorInterface.mark_scene_as_unsaved()
return _ok({"disconnected": true})
# signal.list — list outgoing connections on a node in the currently-edited scene.
# Params: {node_path, persistent_only?: false}
# Returns connections with their flags so callers can distinguish editor-serialized
# connections from runtime ones.
static func list_signals(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
var persistent_only: bool = params.get("persistent_only", false)
if node_path == "":
return _err(-32602, "missing 'node_path'")
var root := EditorInterface.get_edited_scene_root()
if root == null:
return _err(-32001, "no scene open")
var node: Node = root if node_path == "." else root.get_node_or_null(node_path)
if node == null:
return _err(-32001, "node not found: %s" % node_path)
var outgoing: Array = []
for sig in node.get_signal_list():
var sig_name: String = sig.name
for c in node.get_signal_connection_list(sig_name):
var flags: int = c.flags
var is_persistent: bool = bool(flags & CONNECT_PERSIST)
if persistent_only and not is_persistent:
continue
var cb: Callable = c.callable
var target: Object = cb.get_object()
var target_path := ""
if target is Node and (target == root or root.is_ancestor_of(target)):
target_path = String(root.get_path_to(target))
outgoing.append({
"signal": sig_name,
"to": target_path,
"to_object": str(target) if target_path == "" else "",
"method": cb.get_method(),
"flags": flags,
"persistent": is_persistent,
})
return _ok({"node_path": node_path, "outgoing": outgoing})
static func _find_signal(obj: Object, name: String) -> Dictionary:
for s in obj.get_signal_list():
if s.name == name:
return s
return {}
static func _find_method(obj: Object, name: String) -> Dictionary:
for m in obj.get_method_list():
if m.name == name:
return m
return {}
static func _ok(data) -> Dictionary:
return {"data": data}
static func _err(code: int, msg: String) -> Dictionary:
return {"error": {"code": code, "message": msg}}
@@ -0,0 +1 @@
uid://cirt1dodck6pm
+230
View File
@@ -0,0 +1,230 @@
@tool
extends RefCounted
# test.run — detect and run a GDScript test framework, return structured results.
#
# Supports GUT (addons/gut) and GdUnit4 (addons/gdUnit4). Auto-detects which is
# installed; caller can force with 'framework'. Runs via headless subprocess so
# it doesn't block the editor.
#
# Params: {
# framework?: "auto" | "gut" | "gdunit4",
# directory?: "res://test", # test directory; defaults by framework
# pattern?: "test_*.gd", # file name pattern; defaults by framework
# timeout_seconds?: 60,
# }
#
# Returns: {
# framework: "gut" | "gdunit4",
# total, passed, failed, skipped,
# failures: [{name, file, line, message}],
# raw_output: string,
# exit_code,
# }
static func run(params: Dictionary) -> Dictionary:
var framework: String = params.get("framework", "auto")
var directory: String = params.get("directory", "")
var pattern: String = params.get("pattern", "")
var timeout_seconds: int = int(params.get("timeout_seconds", 60))
if framework == "auto":
framework = _detect_framework()
if framework == "":
return _err(-32001,
"no test framework detected. Install GUT (addons/gut) or GdUnit4 (addons/gdUnit4), " +
"or pass 'framework' explicitly.")
var runner_info := _runner_for(framework, directory, pattern)
if runner_info.has("_error"):
return _err(-32001, runner_info._error)
var exe := OS.get_executable_path()
var project_dir := ProjectSettings.globalize_path("res://")
var args: Array = ["--path", project_dir, "--headless"]
for a in runner_info.args:
args.append(str(a))
var output: Array = []
var exit_code := OS.execute(exe, args, output, true)
var stdout: String = ""
if output.size() > 0:
stdout = str(output[0])
var parsed: Dictionary
match framework:
"gut": parsed = _parse_gut_output(stdout)
"gdunit4": parsed = _parse_gdunit4_output(stdout)
_: parsed = {"total": 0, "passed": 0, "failed": 0, "skipped": 0, "failures": []}
parsed["framework"] = framework
parsed["exit_code"] = exit_code
# Strip ANSI escape codes from raw_output so the JSON wire is always clean.
# Godot's JSON.stringify doesn't escape every control char; leaving \x1b in
# the string breaks strict JSON parsers downstream.
parsed["raw_output"] = _strip_ansi(stdout)
parsed["command"] = "%s %s" % [exe, " ".join(args)]
return _ok(parsed)
static func _detect_framework() -> String:
if DirAccess.dir_exists_absolute("res://addons/gut"):
return "gut"
if DirAccess.dir_exists_absolute("res://addons/gdUnit4"):
return "gdunit4"
return ""
static func _runner_for(framework: String, directory: String, pattern: String) -> Dictionary:
match framework:
"gut":
var gut_cmdln := "res://addons/gut/gut_cmdln.gd"
if not FileAccess.file_exists(gut_cmdln):
return {"_error": "GUT framework dir exists but gut_cmdln.gd not found — GUT install may be broken"}
var args: Array = ["-s", gut_cmdln, "-gexit"]
if directory != "":
args.append("-gdir=%s" % directory)
else:
args.append("-gdir=res://test")
if pattern != "":
args.append("-gprefix=%s" % pattern)
return {"args": args}
"gdunit4":
# GdUnit4 ships GdUnitRunner.gd for CLI execution.
var runner := "res://addons/gdUnit4/bin/GdUnitCmdTool.gd"
if not FileAccess.file_exists(runner):
return {"_error": "GdUnit4 framework dir exists but GdUnitCmdTool.gd not found"}
var args: Array = ["-s", runner]
if directory != "":
args.append("--add=%s" % directory)
else:
args.append("--add=res://test")
return {"args": args}
_:
return {"_error": "unknown framework: %s (supported: gut, gdunit4)" % framework}
# Parse GUT's text output. GUT's actual format (not the documented :-separated form):
#
# Totals
# ------
# Scripts 1
# Tests 3
# Passing Tests 2
# Failing Tests 1
# Pending Tests 0 <- optional, only if tests are pending
# Asserts 2/3
#
# Failures appear under a per-file header in the "Run Summary" section:
#
# res://test/foo.gd
# - test_failing_case
# [Failed]: <assertion message>
# at line 14
#
# Output has ANSI color escapes; strip them first so regex matches cleanly.
static func _parse_gut_output(raw: String) -> Dictionary:
var text := _strip_ansi(raw)
var total := _gut_extract_count(text, "Tests")
var passed := _gut_extract_count(text, "Passing Tests")
var failed := _gut_extract_count(text, "Failing Tests")
var skipped := _gut_extract_count(text, "Pending Tests")
var failures: Array = _extract_gut_failures(text)
return {"total": total, "passed": passed, "failed": failed, "skipped": skipped, "failures": failures}
# Match either "Label 12" (space-aligned) or "Label: 12" (colon form) on its own line.
static func _gut_extract_count(text: String, label: String) -> int:
var re := RegEx.new()
re.compile("(?m)^\\s*" + label.replace(" ", "\\s+") + "\\s*[:\\s]\\s*(\\d+)\\s*$")
var m := re.search(text)
if m == null:
return 0
return int(m.get_string(1))
# Walk the text top-to-bottom, tracking current test file (file headers are plain
# `res://...` lines), collecting failing tests (`- test_name` with a following
# `[Failed]:` assertion and `at line N`).
static func _extract_gut_failures(text: String) -> Array:
var failures: Array = []
var lines: PackedStringArray = text.split("\n")
var current_file := ""
var current_test := ""
var current_message := ""
var i := 0
while i < lines.size():
var line: String = lines[i].trim_suffix("\r").strip_edges(false, true) # strip only trailing
if line.begins_with("res://") and line.ends_with(".gd"):
current_file = line.strip_edges()
i += 1
continue
var trimmed := line.strip_edges()
if trimmed.begins_with("- "):
current_test = trimmed.substr(2).strip_edges()
current_message = ""
elif trimmed.begins_with("[Failed]:"):
current_message = trimmed.substr("[Failed]:".length()).strip_edges()
elif trimmed.begins_with("at line"):
var n_str := trimmed.substr("at line".length()).strip_edges()
var line_num := int(n_str)
if current_test != "" and current_file != "":
failures.append({
"name": current_test,
"file": current_file,
"line": line_num,
"message": current_message,
})
current_test = ""
current_message = ""
i += 1
return failures
# Remove ANSI terminal escape codes (colors) from GUT's output so regex matches
# don't get tripped up on things like "\x1b[32m".
static func _strip_ansi(text: String) -> String:
var re := RegEx.new()
re.compile("\\x1b\\[[0-9;]*[A-Za-z]")
return re.sub(text, "", true)
# GdUnit4's default output ends with a summary like:
# "| Tests | Executed | 12 | passed | 10 | skipped | 0 | failed | 2 | ... |"
# and per-failure blocks with file:line.
static func _parse_gdunit4_output(text: String) -> Dictionary:
var total := 0
var passed := 0
var failed := 0
var skipped := 0
var summary_re := RegEx.new()
summary_re.compile("Executed\\s*\\|\\s*(\\d+).*?passed\\s*\\|\\s*(\\d+).*?skipped\\s*\\|\\s*(\\d+).*?failed\\s*\\|\\s*(\\d+)")
var m := summary_re.search(text)
if m:
total = int(m.get_string(1))
passed = int(m.get_string(2))
skipped = int(m.get_string(3))
failed = int(m.get_string(4))
var failures: Array = []
var fail_re := RegEx.new()
fail_re.compile("(?m)FAILED\\s+(.+?)\\s*\\(at:\\s*(.+?):(\\d+)")
for fm in fail_re.search_all(text):
failures.append({
"name": fm.get_string(1),
"file": fm.get_string(2),
"line": int(fm.get_string(3)),
"message": "",
})
return {"total": total, "passed": passed, "failed": failed, "skipped": skipped, "failures": failures}
static func _ok(data) -> Dictionary:
return {"data": data}
static func _err(code: int, msg: String) -> Dictionary:
return {"error": {"code": code, "message": msg}}
@@ -0,0 +1 @@
uid://blc15tr1vxiek
+110
View File
@@ -0,0 +1,110 @@
@tool
extends RefCounted
# theme.* — thin wrappers around Godot's awkward Theme API. Theme uses the
# three-argument shape `(item_name, type_name, value)` where type_name is a
# Control subclass like "Button", "Label", "LineEdit". resource.call_method
# works but these helpers save the lookup.
# theme.set_color — {path, item, type, color}
# color accepts [r,g,b(,a)] or '#rrggbb(aa)' (Coerce handles it).
static func set_color(params: Dictionary) -> Dictionary:
return _set_entry(params, "color", "set_color", TYPE_COLOR)
# theme.set_constant — {path, item, type, value: int}
static func set_constant(params: Dictionary) -> Dictionary:
return _set_entry(params, "constant", "set_constant", TYPE_INT)
# theme.set_font_size — {path, item, type, value: int}
static func set_font_size(params: Dictionary) -> Dictionary:
return _set_entry(params, "font_size", "set_font_size", TYPE_INT)
# theme.set_stylebox_flat — {path, item, type, properties: {bg_color, corner_radius_*, border_width_*, ...}}
# Creates (or replaces) a StyleBoxFlat with the given properties and assigns
# it to theme.<item>.<type>. Saves the two-step "create stylebox, set props,
# assign" dance.
static func set_stylebox_flat(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
var item_name: String = params.get("item", "")
var type_name: String = params.get("type", "")
var props: Dictionary = params.get("properties", {})
if path == "" or item_name == "" or type_name == "":
return _err(-32602, "missing 'path', 'item', or 'type'")
var theme := ResourceLoader.load(path) as Theme
if theme == null:
return _err(-32001, "failed to load theme: %s" % path)
var sb := StyleBoxFlat.new()
const Coerce := preload("res://addons/agent_tools/tools/_coerce.gd")
for k in props:
var prop_info := _find_property(sb, k)
if prop_info.is_empty():
return _err(-32602, "StyleBoxFlat has no property '%s'" % k)
var coerced = Coerce.coerce(props[k], prop_info.type)
if coerced is Dictionary and coerced.has("_error"):
return _err(-32602, "property %s: %s" % [k, coerced._error])
sb.set(k, coerced)
theme.set_stylebox(item_name, type_name, sb)
var save_err := ResourceSaver.save(theme, path)
if save_err != OK:
return _err(-32001, "theme save failed: %d" % save_err)
EditorInterface.get_resource_filesystem().update_file(path)
return _ok({
"path": path,
"item": item_name,
"type": type_name,
"applied_properties": props.keys(),
})
static func _set_entry(params: Dictionary, expected_kind: String, setter_name: String, expected_type: int) -> Dictionary:
var path: String = params.get("path", "")
var item_name: String = params.get("item", "")
var type_name: String = params.get("type", "")
if path == "" or item_name == "" or type_name == "":
return _err(-32602, "missing 'path', 'item', or 'type'")
var theme := ResourceLoader.load(path) as Theme
if theme == null:
return _err(-32001, "failed to load theme: %s" % path)
const Coerce := preload("res://addons/agent_tools/tools/_coerce.gd")
var key := "color" if expected_kind == "color" else "value"
var raw = params.get(key)
if raw == null:
return _err(-32602, "missing '%s'" % key)
var coerced = Coerce.coerce(raw, expected_type)
if coerced is Dictionary and coerced.has("_error"):
return _err(-32602, coerced._error)
theme.call(setter_name, item_name, type_name, coerced)
var save_err := ResourceSaver.save(theme, path)
if save_err != OK:
return _err(-32001, "theme save failed: %d" % save_err)
EditorInterface.get_resource_filesystem().update_file(path)
return _ok({
"path": path,
"item": item_name,
"type": type_name,
"kind": expected_kind,
})
static func _find_property(obj: Object, name: String) -> Dictionary:
for p in obj.get_property_list():
if p.name == name:
return p
return {}
static func _ok(data) -> Dictionary:
return {"data": data}
static func _err(code: int, msg: String) -> Dictionary:
return {"error": {"code": code, "message": msg}}
@@ -0,0 +1 @@
uid://b5p1j8mfeeeav
+92
View File
@@ -0,0 +1,92 @@
@tool
extends RefCounted
# Read-side tools for the `user://` data directory — where games write save files,
# custom-level JSON, settings, etc. Agents couldn't peek at these without running
# the editor and eyeballing. Kept separate from fs.list (which is res://-only)
# because the semantics are different: user:// is runtime-written state, res:// is
# project source.
# user_fs.read — return a text file's content from user://.
# Params: {path}
static func read(params: Dictionary) -> Dictionary:
var path: String = params.get("path", "")
if path == "":
return _err(-32602, "missing 'path'")
if not path.begins_with("user://"):
return _err(-32602, "path must begin with 'user://'")
if not _within_sandbox(path, "user://"):
return _err(-32602, "path escapes the user:// sandbox ('..' traversal not allowed): %s" % path)
var f := FileAccess.open(path, FileAccess.READ)
if f == null:
return _err(-32001, "failed to open: %s (error %d)" % [path, FileAccess.get_open_error()])
var size_bytes := f.get_length()
var content := f.get_as_text()
f.close()
return _ok({
"path": path,
"content": content,
"size_bytes": size_bytes,
})
# user_fs.list — list entries under a user:// directory.
# Params: {dir?: "user://", recursive?: false}
static func list(params: Dictionary) -> Dictionary:
var dir_path: String = params.get("dir", "user://")
var recursive: bool = params.get("recursive", false)
if not dir_path.begins_with("user://"):
return _err(-32602, "dir must begin with 'user://'")
if not _within_sandbox(dir_path, "user://"):
return _err(-32602, "dir escapes the user:// sandbox ('..' traversal not allowed): %s" % dir_path)
var files: Array = []
var dirs: Array = []
_walk(dir_path, files, dirs, recursive)
files.sort()
dirs.sort()
return _ok({
"dir": dir_path,
"files": files,
"dirs": dirs,
"count": files.size(),
})
static func _walk(dir_path: String, out_files: Array, out_dirs: Array, recursive: bool) -> void:
var d := DirAccess.open(dir_path)
if d == null:
return
d.list_dir_begin()
while true:
var name := d.get_next()
if name == "":
break
if name.begins_with("."):
continue
var full := dir_path.path_join(name)
if d.current_is_dir():
out_dirs.append(full)
if recursive:
_walk(full, out_files, out_dirs, recursive)
else:
out_files.append(full)
d.list_dir_end()
# Path-traversal guard — see fs_tools._within_sandbox. `begins_with("user://")`
# is not a sandbox; `..` segments resolve against the real filesystem.
static func _within_sandbox(path: String, prefix: String) -> bool:
var root := ProjectSettings.globalize_path(prefix).simplify_path().trim_suffix("/")
var abs := ProjectSettings.globalize_path(path).simplify_path()
return abs == root or abs.begins_with(root + "/")
static func _ok(data) -> Dictionary:
return {"data": data}
static func _err(code: int, msg: String) -> Dictionary:
return {"error": {"code": code, "message": msg}}
@@ -0,0 +1 @@
uid://btej8otgjy0kj