refactor: 拆分 claude-dev-stack 为 windows-dev-stack 和 wsl-dev-stack

将原 claude-dev-stack 目录拆分为独立的 Windows 和 WSL 部署栈,便于分别维护和使用。

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-05-29 01:11:20 +08:00
parent e8693dad2a
commit dd3eb24d0f
488 changed files with 33927 additions and 0 deletions

View File

@@ -0,0 +1,682 @@
@tool
extends "res://addons/godot_mcp/commands/base_command.gd"
func get_commands() -> Dictionary:
return {
"get_editor_errors": _get_editor_errors,
"get_output_log": _get_output_log,
"get_editor_screenshot": _get_editor_screenshot,
"get_game_screenshot": _get_game_screenshot,
"execute_editor_script": _execute_editor_script,
"clear_output": _clear_output,
"reload_plugin": _reload_plugin,
"reload_project": _reload_project,
"get_signals": _get_signals,
"compare_screenshots": _compare_screenshots,
"set_auto_dismiss": _set_auto_dismiss,
"get_editor_camera": _get_editor_camera,
"set_editor_camera": _set_editor_camera,
}
func _get_editor_errors(params: Dictionary) -> Dictionary:
var errors: Array = []
var max_lines: int = optional_int(params, "max_lines", 50)
var base: Control = get_editor().get_base_control()
# 1. Read from the editor's Output panel (EditorLog RichTextLabel)
# This captures runtime errors, warnings, and print output
var editor_log: Node = base.find_child("Output", true, false)
if editor_log:
var rtl: RichTextLabel = _find_rtl(editor_log)
if rtl:
var content: String = rtl.get_parsed_text()
var lines: PackedStringArray = content.split("\n")
var start: int = maxi(0, lines.size() - max_lines)
for i in range(start, lines.size()):
var line: String = lines[i]
if line.contains("ERROR") or line.contains("SCRIPT ERROR") or line.contains("Parse Error") or line.contains("WARNING"):
errors.append(line.strip_edges())
# 2. Check the script editor for compile errors (red background lines)
# These don't appear in the Output panel
var script_errors: Array = []
var script_editor: ScriptEditor = EditorInterface.get_script_editor()
if script_editor:
var current_script: Script = script_editor.get_current_script()
var ce: CodeEdit = _find_code_edit(script_editor)
if ce and current_script:
var script_path: String = current_script.resource_path
for i in range(ce.get_line_count()):
var bg: Color = ce.get_line_background_color(i)
if bg.r > 0.8 and bg.a > 0: # Red-ish background = error
var line_text: String = ce.get_line(i).strip_edges()
script_errors.append("COMPILE ERROR: %s:%d - %s" % [script_path, i + 1, line_text])
# 3. Read from script editor error/warning panels (GDScript analyzer messages)
# Each open script editor has a VSplitContainer with two RichTextLabels:
# child[1] = warnings panel, child[2] = errors panel
var analyzer_errors: Array = []
if script_editor:
var open_editors: Array = script_editor.get_open_script_editors()
var open_scripts: Array = script_editor.get_open_scripts()
for ei in range(open_editors.size()):
var editor_node: Node = open_editors[ei]
var script_path: String = ""
if ei < open_scripts.size() and open_scripts[ei] != null:
script_path = (open_scripts[ei] as Resource).resource_path
var vsplit: VSplitContainer = null
for c in editor_node.get_children():
if c is VSplitContainer:
vsplit = c as VSplitContainer
break
if vsplit == null:
continue
var children: Array = vsplit.get_children()
# child[1] = warnings panel (RichTextLabel)
if children.size() > 1 and children[1] is RichTextLabel:
var text: String = (children[1] as RichTextLabel).get_parsed_text().strip_edges()
if not text.is_empty():
for line in text.split("\n"):
var stripped: String = line.strip_edges()
if stripped.is_empty() or stripped == "[Ignore]":
continue
# Remove leading "[Ignore]" prefix from warning lines
stripped = stripped.trim_prefix("[Ignore]")
var prefix: String = "WARNING: %s:" % script_path if not script_path.is_empty() else "WARNING: "
analyzer_errors.append(prefix + stripped)
# child[2] = errors panel (RichTextLabel)
if children.size() > 2 and children[2] is RichTextLabel:
var text: String = (children[2] as RichTextLabel).get_parsed_text().strip_edges()
if not text.is_empty():
for line in text.split("\n"):
var stripped: String = line.strip_edges()
if stripped.is_empty():
continue
var prefix: String = "SCRIPT ERROR: %s:" % script_path if not script_path.is_empty() else "SCRIPT ERROR: "
analyzer_errors.append(prefix + stripped)
# 4. Read from the debugger Errors tab (runtime errors/warnings)
# Path: ScriptEditorDebugger > TabContainer > "Errors" VBoxContainer > Tree
var debugger_errors: Array = []
var base2: Control = get_editor().get_base_control()
if base2:
var queue: Array[Node] = [base2]
while not queue.is_empty():
var node := queue.pop_front()
if node.get_class() == "ScriptEditorDebugger":
# Find TabContainer inside the debugger
for child in node.get_children():
if child is TabContainer:
var tab_container := child as TabContainer
for tab_idx in range(tab_container.get_tab_count()):
var tab_control: Control = tab_container.get_tab_control(tab_idx)
if tab_control is VBoxContainer and tab_control.name.begins_with("Errors"):
# Find Tree inside the Errors tab
for vchild in tab_control.get_children():
if vchild is Tree:
var tree := vchild as Tree
var root_item: TreeItem = tree.get_root()
if root_item:
var item: TreeItem = root_item.get_first_child()
while item:
var col0: String = item.get_text(0).strip_edges()
var col1: String = item.get_text(1).strip_edges()
if not col0.is_empty() or not col1.is_empty():
var msg: String = col0
if not col1.is_empty():
msg += " " + col1 if not msg.is_empty() else col1
debugger_errors.append("DEBUGGER: " + msg)
# Also check child items (expanded error details)
var sub: TreeItem = item.get_first_child()
while sub:
var sub0: String = sub.get_text(0).strip_edges()
var sub1: String = sub.get_text(1).strip_edges()
if not sub0.is_empty() or not sub1.is_empty():
var sub_msg: String = sub0
if not sub1.is_empty():
sub_msg += " " + sub1 if not sub_msg.is_empty() else sub1
debugger_errors.append("DEBUGGER: " + sub_msg)
sub = sub.get_next()
item = item.get_next()
break # Found Errors tab, stop searching tabs
break # Found TabContainer, stop searching debugger children
break # Found ScriptEditorDebugger, stop BFS
for child in node.get_children():
queue.append(child)
# Fallback: read from log file if Output panel not accessible
if errors.size() == 0 and script_errors.size() == 0 and analyzer_errors.size() == 0 and debugger_errors.size() == 0:
var log_path := "user://logs/godot.log"
if FileAccess.file_exists(log_path):
var file := FileAccess.open(log_path, FileAccess.READ)
if file:
var content := file.get_as_text()
file.close()
var lines := content.split("\n")
var start: int = maxi(0, lines.size() - max_lines)
for i in range(start, lines.size()):
var line: String = lines[i]
if line.contains("ERROR") or line.contains("SCRIPT ERROR"):
errors.append(line.strip_edges())
errors.append_array(script_errors)
errors.append_array(analyzer_errors)
errors.append_array(debugger_errors)
return success({"errors": errors, "count": errors.size()})
func _get_output_log(params: Dictionary) -> Dictionary:
var max_lines: int = optional_int(params, "max_lines", 100)
var filter: String = optional_string(params, "filter", "")
var base: Control = get_editor().get_base_control()
var editor_log: Node = base.find_child("Output", true, false)
if editor_log == null:
# Fallback: read from log file
var log_path := "user://logs/godot.log"
if not FileAccess.file_exists(log_path):
return error_internal("Output panel not found and no log file available")
var file := FileAccess.open(log_path, FileAccess.READ)
if file == null:
return error_internal("Cannot read log file")
var content := file.get_as_text()
file.close()
var lines := content.split("\n")
var start: int = maxi(0, lines.size() - max_lines)
var output_lines: Array = []
for i in range(start, lines.size()):
var line: String = lines[i]
if filter.is_empty() or line.contains(filter):
output_lines.append(line)
return success({"lines": output_lines, "count": output_lines.size(), "source": "log_file"})
var rtl: RichTextLabel = _find_rtl(editor_log)
if rtl == null:
return error_internal("Could not find RichTextLabel in Output panel")
var content: String = rtl.get_parsed_text()
var all_lines: PackedStringArray = content.split("\n")
var start: int = maxi(0, all_lines.size() - max_lines)
var output_lines: Array = []
for i in range(start, all_lines.size()):
var line: String = all_lines[i]
if filter.is_empty() or line.contains(filter):
output_lines.append(line)
return success({"lines": output_lines, "count": output_lines.size(), "source": "output_panel"})
func _find_code_edit(node: Node, depth: int = 0) -> CodeEdit:
if depth > 8:
return null
if node is CodeEdit:
return node as CodeEdit
for child in node.get_children():
var found: CodeEdit = _find_code_edit(child, depth + 1)
if found:
return found
return null
func _find_rtl(node: Node, depth: int = 0) -> RichTextLabel:
if depth > 6:
return null
if node is RichTextLabel:
return node as RichTextLabel
for child in node.get_children():
var found: RichTextLabel = _find_rtl(child, depth + 1)
if found:
return found
return null
func _get_editor_screenshot(params: Dictionary) -> Dictionary:
# Capture the editor's main viewport - no await to avoid timeout
var base_control: Control = get_editor().get_base_control()
if base_control == null:
return error_internal("Could not access editor base control")
var viewport: Viewport = base_control.get_viewport()
if viewport == null:
return error_internal("Could not access editor viewport")
var texture: ViewportTexture = viewport.get_texture()
if texture == null:
return error_internal("Could not get viewport texture")
var image: Image = texture.get_image()
if image == null:
return error_internal("Could not get image from viewport")
var save_path: String = params.get("save_path", "")
if save_path != "":
var abs_path := _resolve_save_path(save_path)
var err := image.save_png(abs_path)
if err != OK:
return error_internal("Failed to save screenshot: %s" % error_string(err))
return success({
"saved_path": save_path,
"width": image.get_width(),
"height": image.get_height(),
"format": "png",
})
var png_buffer := image.save_png_to_buffer()
var base64 := Marshalls.raw_to_base64(png_buffer)
return success({
"image_base64": base64,
"width": image.get_width(),
"height": image.get_height(),
"format": "png",
})
func _get_game_screenshot(params: Dictionary) -> Dictionary:
var ei := get_editor()
if not ei.is_playing_scene():
return error(-32000, "No scene is currently playing", {"suggestion": "Use play_scene first"})
# Communicate with the game process via file system
var user_dir := get_game_user_dir()
var request_path := user_dir + "/mcp_screenshot_request"
var screenshot_path := user_dir + "/mcp_screenshot.png"
# Clean up any stale screenshot file
if FileAccess.file_exists(screenshot_path):
DirAccess.remove_absolute(screenshot_path)
# Create the request file to signal the game process
var req := FileAccess.open(request_path, FileAccess.WRITE)
if req == null:
return error_internal("Could not create screenshot request file")
req.close()
# Poll for the screenshot file (max 3 seconds, 0.1s interval)
var attempts := 30
while attempts > 0:
await get_tree().create_timer(0.1).timeout
if FileAccess.file_exists(screenshot_path):
break
attempts -= 1
if not FileAccess.file_exists(screenshot_path):
# Clean up request file if it still exists
if FileAccess.file_exists(request_path):
DirAccess.remove_absolute(request_path)
return error(-32000, "Screenshot timed out", {
"suggestion": "Ensure the game is running and MCPScreenshot autoload is active",
})
# Load the PNG file
var image := Image.new()
var err := image.load(screenshot_path)
if err != OK:
DirAccess.remove_absolute(screenshot_path)
return error_internal("Failed to load screenshot: %s" % error_string(err))
# Clean up temp file
DirAccess.remove_absolute(screenshot_path)
var save_path_param: String = params.get("save_path", "")
if save_path_param != "":
var abs_path := _resolve_save_path(save_path_param)
var save_err := image.save_png(abs_path)
if save_err != OK:
return error_internal("Failed to save screenshot: %s" % error_string(save_err))
return success({
"saved_path": save_path_param,
"width": image.get_width(),
"height": image.get_height(),
"format": "png",
})
var png_buffer := image.save_png_to_buffer()
var base64 := Marshalls.raw_to_base64(png_buffer)
return success({
"image_base64": base64,
"width": image.get_width(),
"height": image.get_height(),
"format": "png",
})
func _resolve_save_path(path: String) -> String:
if path.begins_with("res://") or path.begins_with("user://"):
return ProjectSettings.globalize_path(path)
return path
func _execute_editor_script(params: Dictionary) -> Dictionary:
var result := require_string(params, "code")
if result[1] != null:
return result[1]
var code: String = result[0]
var allow_unsafe_editor_io: bool = optional_bool(params, "allow_unsafe_editor_io", false)
var unsafe_guard := _guard_editor_script_file_io(code, allow_unsafe_editor_io)
if not unsafe_guard.is_empty():
return unsafe_guard
# Wrap user code in a @tool script
var wrapped_code := """@tool
extends Node
var _mcp_output: Array = []
func _mcp_print(value: Variant) -> void:
_mcp_output.append(str(value))
func run() -> Variant:
# User code begins
%s
# User code ends
return _mcp_output
""" % _indent_code(code)
# Create a temporary script
var script := GDScript.new()
script.source_code = wrapped_code
var err := script.reload()
if err != OK:
return error(-32002, "Script compilation failed", {
"error": error_string(err),
"code": wrapped_code,
})
# Create temp node and execute
var temp_node := Node.new()
temp_node.set_script(script)
add_child(temp_node)
var output: Variant = null
# Execute with error handling
if temp_node.has_method("run"):
output = temp_node.run()
var mcp_output: Array = []
var raw_output: Variant = temp_node.get("_mcp_output")
if raw_output is Array:
mcp_output = raw_output
# Cleanup
temp_node.queue_free()
return success({
"output": mcp_output,
"return_value": str(output) if output != null else null,
})
func _guard_editor_script_file_io(code: String, allow_unsafe_editor_io: bool) -> Dictionary:
if allow_unsafe_editor_io:
return {}
var compact := code.replace(" ", "").replace("\t", "").replace("\n", "")
var unsafe_patterns: Array[String] = []
if compact.contains("ResourceSaver.save("):
unsafe_patterns.append("ResourceSaver.save")
if compact.contains("ProjectSettings.save("):
unsafe_patterns.append("ProjectSettings.save")
if compact.contains("ConfigFile.save("):
unsafe_patterns.append("ConfigFile.save")
if compact.contains("FileAccess.open(") and _contains_any(compact, ["FileAccess.WRITE", "FileAccess.READ_WRITE", "FileAccess.WRITE_READ"]):
unsafe_patterns.append("FileAccess.open WRITE")
if _contains_any(compact, ["DirAccess.remove_absolute(", "DirAccess.rename_absolute(", "DirAccess.copy_absolute(", "DirAccess.make_dir_absolute(", "DirAccess.make_dir_recursive_absolute("]):
unsafe_patterns.append("DirAccess filesystem mutation")
if unsafe_patterns.is_empty():
return {}
return error_conflict(
"Refusing to execute editor script with direct file/resource write APIs",
{
"unsafe_patterns": unsafe_patterns,
"open_scenes": get_open_scene_paths(),
"suggestion": "Use dedicated MCP commands and save_scene for editor-owned resources, or pass allow_unsafe_editor_io=true only when no open editor resource can be overwritten.",
}
)
func _contains_any(value: String, needles: Array[String]) -> bool:
for needle: String in needles:
if value.contains(needle):
return true
return false
func _indent_code(code: String) -> String:
var lines := code.split("\n")
var indented: PackedStringArray = []
for line in lines:
indented.append("\t" + line)
return "\n".join(indented)
func _clear_output(params: Dictionary) -> Dictionary:
print("\n".repeat(50))
return success({"cleared": true})
func _reload_plugin(params: Dictionary) -> Dictionary:
# Disable and re-enable this plugin to reload all scripts
var plugin_name := "godot_mcp"
var ei := get_editor()
# Send success BEFORE reloading (connection will briefly drop)
# Use call_deferred so the response is sent first
_deferred_reload_plugin.call_deferred(ei, plugin_name)
return success({"reloading": true, "message": "Plugin will reload momentarily. Connection will briefly drop and auto-reconnect."})
func _deferred_reload_plugin(ei: EditorInterface, plugin_name: String) -> void:
ei.set_plugin_enabled(plugin_name, false)
ei.set_plugin_enabled(plugin_name, true)
print("[MCP] Plugin reloaded")
func _reload_project(params: Dictionary) -> Dictionary:
# Rescan filesystem and reload changed scripts
var ei := get_editor()
ei.get_resource_filesystem().scan()
return success({"reloaded": true, "message": "Filesystem rescanned."})
func _get_signals(params: Dictionary) -> Dictionary:
var result := require_string(params, "node_path")
if result[1] != null:
return result[1]
var node_path: String = result[0]
var root := get_edited_root()
if root == null:
return error_no_scene()
var node := find_node_by_path(node_path)
if node == null:
return error_not_found("Node '%s'" % node_path)
var signals: Array = []
for sig in node.get_signal_list():
var sig_info: Dictionary = {
"name": sig["name"],
"args": [],
}
for arg in sig["args"]:
sig_info["args"].append({"name": arg["name"], "type": arg["type"]})
# Get connections for this signal
var connections: Array = []
for conn in node.get_signal_connection_list(sig["name"]):
connections.append({
"target": str(root.get_path_to(conn["callable"].get_object())),
"method": conn["callable"].get_method(),
})
sig_info["connections"] = connections
signals.append(sig_info)
return success({
"node_path": str(root.get_path_to(node)),
"type": node.get_class(),
"signals": signals,
"count": signals.size(),
})
func _load_image_from_param(value: String, label: String) -> Array:
## Returns [Image, null] on success or [null, error_dict] on failure.
## Accepts a file path (res://, user://) or raw base64 PNG data.
var img := Image.new()
if value.begins_with("res://") or value.begins_with("user://"):
var err := img.load(value)
if err != OK:
return [null, error_invalid_params("Failed to load %s from path '%s': %s" % [label, value, error_string(err)])]
return [img, null]
# Treat as base64 PNG
var buf := Marshalls.base64_to_raw(value)
var err := img.load_png_from_buffer(buf)
if err != OK:
return [null, error_invalid_params("Failed to decode %s from base64: %s" % [label, error_string(err)])]
return [img, null]
func _compare_screenshots(params: Dictionary) -> Dictionary:
var result := require_string(params, "image_a")
if result[1] != null:
return result[1]
var image_a_value: String = result[0]
var result2 := require_string(params, "image_b")
if result2[1] != null:
return result2[1]
var image_b_value: String = result2[0]
var threshold: int = optional_int(params, "threshold", 10)
# Load images (from path or base64)
var load_a := _load_image_from_param(image_a_value, "image_a")
if load_a[1] != null:
return load_a[1]
var img_a: Image = load_a[0]
var load_b := _load_image_from_param(image_b_value, "image_b")
if load_b[1] != null:
return load_b[1]
var img_b: Image = load_b[0]
if img_a.get_size() != img_b.get_size():
return error_invalid_params("Image sizes differ: %s vs %s" % [str(img_a.get_size()), str(img_b.get_size())])
var width := img_a.get_width()
var height := img_a.get_height()
var diff_image := Image.create(width, height, false, Image.FORMAT_RGBA8)
var changed_pixels: int = 0
var total_pixels: int = width * height
for y in height:
for x in width:
var ca: Color = img_a.get_pixel(x, y)
var cb: Color = img_b.get_pixel(x, y)
var dr := absi(int(ca.r8) - int(cb.r8))
var dg := absi(int(ca.g8) - int(cb.g8))
var db := absi(int(ca.b8) - int(cb.b8))
var max_diff := maxi(dr, maxi(dg, db))
if max_diff > threshold:
changed_pixels += 1
# Red highlight for changed pixels
diff_image.set_pixel(x, y, Color(1, 0, 0, clampf(float(max_diff) / 255.0, 0.3, 1.0)))
else:
# Dim version of original
diff_image.set_pixel(x, y, Color(ca.r * 0.3, ca.g * 0.3, ca.b * 0.3, 1.0))
var diff_percentage: float = (float(changed_pixels) / float(total_pixels)) * 100.0
var identical: bool = changed_pixels == 0
# Encode diff image
var diff_png := diff_image.save_png_to_buffer()
var diff_base64 := Marshalls.raw_to_base64(diff_png)
return success({
"identical": identical,
"changed_pixels": changed_pixels,
"total_pixels": total_pixels,
"diff_percentage": snappedf(diff_percentage, 0.01),
"threshold": threshold,
"width": width,
"height": height,
"diff_image_base64": diff_base64,
})
func _get_editor_camera(_params: Dictionary) -> Dictionary:
var vp3d := EditorInterface.get_editor_viewport_3d()
var cam := vp3d.get_camera_3d() if vp3d else null
if not cam:
return error(-32000, "No 3D editor camera found", {
"suggestion": "Make sure a 3D scene is open in the editor",
})
var pos := cam.global_position
var rot := cam.rotation_degrees
return success({
"position": {"x": pos.x, "y": pos.y, "z": pos.z},
"rotation_degrees": {"x": rot.x, "y": rot.y, "z": rot.z},
"fov": cam.fov,
"near": cam.near,
"far": cam.far,
})
func _set_editor_camera(params: Dictionary) -> Dictionary:
var vp3d := EditorInterface.get_editor_viewport_3d()
var cam := vp3d.get_camera_3d() if vp3d else null
if not cam:
return error(-32000, "No 3D editor camera found", {
"suggestion": "Make sure a 3D scene is open in the editor",
})
# Set position
if params.has("position"):
var p: Dictionary = params["position"]
cam.global_position = Vector3(
float(p.get("x", cam.global_position.x)),
float(p.get("y", cam.global_position.y)),
float(p.get("z", cam.global_position.z)),
)
# Set rotation
if params.has("rotation_degrees"):
var r: Dictionary = params["rotation_degrees"]
cam.rotation_degrees = Vector3(
float(r.get("x", cam.rotation_degrees.x)),
float(r.get("y", cam.rotation_degrees.y)),
float(r.get("z", cam.rotation_degrees.z)),
)
# Look at target (overrides rotation if set)
if params.has("look_at"):
var t: Dictionary = params["look_at"]
cam.look_at(Vector3(float(t.get("x", 0)), float(t.get("y", 0)), float(t.get("z", 0))))
# Set FOV
if params.has("fov"):
cam.fov = float(params["fov"])
var pos := cam.global_position
var rot := cam.rotation_degrees
return success({
"position": {"x": pos.x, "y": pos.y, "z": pos.z},
"rotation_degrees": {"x": rot.x, "y": rot.y, "z": rot.z},
"fov": cam.fov,
})
func _set_auto_dismiss(params: Dictionary) -> Dictionary:
var enabled: bool = params.get("enabled", true)
editor_plugin.auto_dismiss_dialogs = enabled
return success({
"auto_dismiss": enabled,
"message": "Auto-dismiss dialogs %s" % ("enabled" if enabled else "disabled"),
})