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

+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