将原 claude-dev-stack 目录拆分为独立的 Windows 和 WSL 部署栈,便于分别维护和使用。 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
370 lines
11 KiB
GDScript
370 lines
11 KiB
GDScript
@tool
|
|
extends "res://addons/godot_mcp/commands/base_command.gd"
|
|
|
|
|
|
func get_commands() -> Dictionary:
|
|
return {
|
|
"list_scripts": _list_scripts,
|
|
"read_script": _read_script,
|
|
"create_script": _create_script,
|
|
"edit_script": _edit_script,
|
|
"attach_script": _attach_script,
|
|
"get_open_scripts": _get_open_scripts,
|
|
"validate_script": _validate_script,
|
|
}
|
|
|
|
|
|
func _guard_script_file_path(path: String, operation: String) -> Dictionary:
|
|
var ext := path.get_extension().to_lower()
|
|
if ext in ["gd", "cs"]:
|
|
return {}
|
|
return error(
|
|
-32602,
|
|
"%s only supports script files (.gd, .cs): %s" % [operation, normalize_project_path(path)],
|
|
{
|
|
"path": normalize_project_path(path),
|
|
"extension": ext,
|
|
"suggestion": "Use scene commands for .tscn/.scn files and shader commands for shader resources.",
|
|
}
|
|
)
|
|
|
|
|
|
func _list_scripts(params: Dictionary) -> Dictionary:
|
|
var path: String = optional_string(params, "path", "res://")
|
|
var recursive: bool = optional_bool(params, "recursive", true)
|
|
|
|
var scripts: Array = []
|
|
_find_scripts(path, recursive, scripts)
|
|
|
|
return success({"scripts": scripts, "count": scripts.size()})
|
|
|
|
|
|
func _find_scripts(path: String, recursive: bool, scripts: Array) -> void:
|
|
var dir := DirAccess.open(path)
|
|
if dir == null:
|
|
return
|
|
|
|
dir.list_dir_begin()
|
|
var file_name := dir.get_next()
|
|
|
|
while not file_name.is_empty():
|
|
if file_name.begins_with("."):
|
|
file_name = dir.get_next()
|
|
continue
|
|
|
|
var full_path := path.path_join(file_name)
|
|
|
|
if dir.current_is_dir():
|
|
if recursive:
|
|
_find_scripts(full_path, recursive, scripts)
|
|
elif file_name.get_extension() in ["gd", "cs", "gdshader"]:
|
|
var info := {"path": full_path, "type": file_name.get_extension()}
|
|
# Get basic file info
|
|
var file := FileAccess.open(full_path, FileAccess.READ)
|
|
if file:
|
|
info["size"] = file.get_length()
|
|
# Read first line for class/extends info
|
|
var first_line := file.get_line().strip_edges()
|
|
if first_line.begins_with("class_name "):
|
|
info["class_name"] = first_line.substr(11).strip_edges()
|
|
elif first_line.begins_with("extends "):
|
|
info["extends"] = first_line.substr(8).strip_edges()
|
|
file.close()
|
|
scripts.append(info)
|
|
|
|
file_name = dir.get_next()
|
|
|
|
dir.list_dir_end()
|
|
|
|
|
|
func _read_script(params: Dictionary) -> Dictionary:
|
|
var result := require_string(params, "path")
|
|
if result[1] != null:
|
|
return result[1]
|
|
var path: String = result[0]
|
|
|
|
if not FileAccess.file_exists(path):
|
|
return error_not_found("Script '%s'" % path)
|
|
|
|
var file := FileAccess.open(path, FileAccess.READ)
|
|
if file == null:
|
|
return error_internal("Cannot read script: %s" % error_string(FileAccess.get_open_error()))
|
|
|
|
var content := file.get_as_text()
|
|
var line_count := content.count("\n") + 1
|
|
file.close()
|
|
|
|
return success({
|
|
"path": path,
|
|
"content": content,
|
|
"line_count": line_count,
|
|
"size": content.length(),
|
|
})
|
|
|
|
|
|
func _create_script(params: Dictionary) -> Dictionary:
|
|
var result := require_string(params, "path")
|
|
if result[1] != null:
|
|
return result[1]
|
|
var path: String = result[0]
|
|
var path_guard := _guard_script_file_path(path, "create_script")
|
|
if not path_guard.is_empty():
|
|
return path_guard
|
|
|
|
var content: String = optional_string(params, "content", "")
|
|
var base_class: String = optional_string(params, "extends", "Node")
|
|
var class_name_str: String = optional_string(params, "class_name", "")
|
|
var force: bool = optional_bool(params, "force", false)
|
|
|
|
var guard := guard_text_resource_write(path, force)
|
|
if not guard.is_empty():
|
|
return guard
|
|
|
|
# Generate template if no content provided
|
|
if content.is_empty():
|
|
var lines: PackedStringArray = []
|
|
if not class_name_str.is_empty():
|
|
lines.append("class_name %s" % class_name_str)
|
|
lines.append("extends %s" % base_class)
|
|
lines.append("")
|
|
lines.append("")
|
|
lines.append("func _ready() -> void:")
|
|
lines.append("\tpass")
|
|
lines.append("")
|
|
content = "\n".join(lines)
|
|
|
|
# Ensure directory exists
|
|
var dir_path := path.get_base_dir()
|
|
if not DirAccess.dir_exists_absolute(dir_path):
|
|
DirAccess.make_dir_recursive_absolute(dir_path)
|
|
|
|
var file := FileAccess.open(path, FileAccess.WRITE)
|
|
if file == null:
|
|
return error_internal("Cannot create script: %s" % error_string(FileAccess.get_open_error()))
|
|
|
|
file.store_string(content)
|
|
file.close()
|
|
|
|
EditorInterface.get_resource_filesystem().scan()
|
|
|
|
# Pre-load so the script is available immediately
|
|
if ResourceLoader.exists(path):
|
|
var script = load(path)
|
|
if script is Script:
|
|
script.reload(true)
|
|
|
|
return success({"path": path, "created": true})
|
|
|
|
|
|
func _edit_script(params: Dictionary) -> Dictionary:
|
|
var result := require_string(params, "path")
|
|
if result[1] != null:
|
|
return result[1]
|
|
var path: String = result[0]
|
|
var path_guard := _guard_script_file_path(path, "edit_script")
|
|
if not path_guard.is_empty():
|
|
return path_guard
|
|
|
|
if not FileAccess.file_exists(path):
|
|
return error_not_found("Script '%s'" % path)
|
|
|
|
var force: bool = optional_bool(params, "force", false)
|
|
var guard := guard_text_resource_write(path, force)
|
|
if not guard.is_empty():
|
|
return guard
|
|
|
|
# Read current content
|
|
var file := FileAccess.open(path, FileAccess.READ)
|
|
if file == null:
|
|
return error_internal("Cannot read script: %s" % error_string(FileAccess.get_open_error()))
|
|
|
|
var content := file.get_as_text()
|
|
file.close()
|
|
|
|
var changes_made := 0
|
|
|
|
# Support search-and-replace
|
|
if params.has("replacements") and params["replacements"] is Array:
|
|
var replacements: Array = params["replacements"]
|
|
for replacement in replacements:
|
|
if replacement is Dictionary:
|
|
var search: String = replacement.get("search", "")
|
|
var replace: String = replacement.get("replace", "")
|
|
if not search.is_empty():
|
|
var use_regex: bool = replacement.get("regex", false)
|
|
if use_regex:
|
|
var regex := RegEx.new()
|
|
var err := regex.compile(search)
|
|
if err == OK:
|
|
var new_content := regex.sub(content, replace, true)
|
|
if new_content != content:
|
|
content = new_content
|
|
changes_made += 1
|
|
else:
|
|
if content.contains(search):
|
|
content = content.replace(search, replace)
|
|
changes_made += 1
|
|
|
|
# Support 1-based inclusive line range replacement
|
|
elif params.has("content") and (params.has("start_line") or params.has("end_line")):
|
|
if not params.has("start_line"):
|
|
return error_invalid_params("start_line is required when end_line is provided")
|
|
var start_line: int = int(params["start_line"])
|
|
var end_line: int = int(params.get("end_line", start_line))
|
|
var lines := content.split("\n")
|
|
if start_line < 1:
|
|
return error_invalid_params("start_line must be >= 1")
|
|
if end_line < start_line:
|
|
return error_invalid_params("end_line must be >= start_line")
|
|
if start_line > lines.size():
|
|
return error_invalid_params("start_line is beyond the end of the file")
|
|
if end_line > lines.size():
|
|
return error_invalid_params("end_line is beyond the end of the file")
|
|
|
|
var replacement_lines := str(params["content"]).split("\n")
|
|
var start_index := start_line - 1
|
|
var remove_count := end_line - start_line + 1
|
|
for _i in range(remove_count):
|
|
lines.remove_at(start_index)
|
|
for i in range(replacement_lines.size()):
|
|
lines.insert(start_index + i, replacement_lines[i])
|
|
content = "\n".join(lines)
|
|
changes_made = 1
|
|
|
|
# Support full content replacement
|
|
elif params.has("content"):
|
|
content = str(params["content"])
|
|
changes_made = 1
|
|
|
|
# Support insert at line
|
|
elif params.has("insert_at_line") and params.has("text"):
|
|
var line_num: int = int(params["insert_at_line"])
|
|
var text: String = str(params["text"])
|
|
var lines := content.split("\n")
|
|
line_num = clampi(line_num, 0, lines.size())
|
|
lines.insert(line_num, text)
|
|
content = "\n".join(lines)
|
|
changes_made = 1
|
|
|
|
if changes_made == 0:
|
|
return success({"path": path, "changes_made": 0, "message": "No changes applied"})
|
|
|
|
# Write back
|
|
file = FileAccess.open(path, FileAccess.WRITE)
|
|
if file == null:
|
|
return error_internal("Cannot write script: %s" % error_string(FileAccess.get_open_error()))
|
|
|
|
file.store_string(content)
|
|
file.close()
|
|
|
|
# Reload the script resource so the editor picks up changes immediately
|
|
_reload_script(path)
|
|
|
|
return success({"path": path, "changes_made": changes_made})
|
|
|
|
|
|
## Force-reload a script so the editor reflects disk changes immediately.
|
|
func _reload_script(path: String) -> void:
|
|
# First, trigger a filesystem scan so Godot knows the file changed
|
|
EditorInterface.get_resource_filesystem().scan()
|
|
|
|
# If the script is already loaded in memory, reload it
|
|
if ResourceLoader.exists(path):
|
|
var script = load(path)
|
|
if script is Script:
|
|
script.reload(true)
|
|
|
|
# If the script is open in the script editor, the reload above updates it.
|
|
# But we also need to notify the editor to refresh its error indicators.
|
|
EditorInterface.get_script_editor().notification(Control.NOTIFICATION_VISIBILITY_CHANGED)
|
|
|
|
|
|
func _attach_script(params: Dictionary) -> Dictionary:
|
|
var result := require_string(params, "node_path")
|
|
if result[1] != null:
|
|
return result[1]
|
|
var node_path: String = result[0]
|
|
|
|
var result2 := require_string(params, "script_path")
|
|
if result2[1] != null:
|
|
return result2[1]
|
|
var script_path: String = result2[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, "Use get_scene_tree to see available nodes")
|
|
|
|
if not FileAccess.file_exists(script_path):
|
|
return error_not_found("Script '%s'" % script_path)
|
|
|
|
var script: Script = load(script_path)
|
|
if script == null:
|
|
return error_internal("Failed to load script: %s" % script_path)
|
|
|
|
var old_script: Variant = node.get_script()
|
|
|
|
var undo_redo := get_undo_redo()
|
|
undo_redo.create_action("MCP: Attach script to %s" % node.name)
|
|
undo_redo.add_do_method(node, "set_script", script)
|
|
undo_redo.add_undo_method(node, "set_script", old_script)
|
|
undo_redo.commit_action()
|
|
|
|
return success({
|
|
"node_path": str(root.get_path_to(node)),
|
|
"script_path": script_path,
|
|
"attached": true,
|
|
})
|
|
|
|
|
|
func _validate_script(params: Dictionary) -> Dictionary:
|
|
var result := require_string(params, "path")
|
|
if result[1] != null:
|
|
return result[1]
|
|
var path: String = result[0]
|
|
var path_guard := _guard_script_file_path(path, "validate_script")
|
|
if not path_guard.is_empty():
|
|
return path_guard
|
|
|
|
if not FileAccess.file_exists(path):
|
|
return error_not_found("Script '%s'" % path)
|
|
|
|
var file := FileAccess.open(path, FileAccess.READ)
|
|
if file == null:
|
|
return error_internal("Cannot read script: %s" % error_string(FileAccess.get_open_error()))
|
|
|
|
var source_code := file.get_as_text()
|
|
file.close()
|
|
|
|
var script := GDScript.new()
|
|
script.source_code = source_code
|
|
var err := script.reload()
|
|
|
|
if err == OK:
|
|
return success({"path": path, "valid": true, "message": "Script compiles successfully"})
|
|
|
|
return success({
|
|
"path": path,
|
|
"valid": false,
|
|
"error_code": err,
|
|
"error_string": error_string(err),
|
|
"message": "Compilation failed. Use get_output_log or get_editor_errors for details.",
|
|
})
|
|
|
|
|
|
func _get_open_scripts(_params: Dictionary) -> Dictionary:
|
|
var script_editor := EditorInterface.get_script_editor()
|
|
var open_scripts: Array = []
|
|
|
|
for script_base in script_editor.get_open_scripts():
|
|
var info := {
|
|
"path": script_base.resource_path,
|
|
"type": script_base.get_class(),
|
|
}
|
|
open_scripts.append(info)
|
|
|
|
return success({"scripts": open_scripts, "count": open_scripts.size()})
|