@beremaran/godot-agent-loop 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +925 -0
  3. package/addons/godot_agent_loop/LICENSE +23 -0
  4. package/addons/godot_agent_loop/README.md +31 -0
  5. package/addons/godot_agent_loop/plugin.cfg +8 -0
  6. package/addons/godot_agent_loop/plugin.gd +417 -0
  7. package/agent-plugin/.claude-plugin/plugin.json +19 -0
  8. package/agent-plugin/.codex-plugin/plugin.json +37 -0
  9. package/agent-plugin/.mcp.json +14 -0
  10. package/agent-plugin/adapter-manifest.json +45 -0
  11. package/agent-plugin/pi/extension.ts +136 -0
  12. package/agent-plugin/skills/build-godot-game/SKILL.md +41 -0
  13. package/agent-plugin/skills/debug-godot-game/SKILL.md +37 -0
  14. package/agent-plugin/skills/ship-godot-game/SKILL.md +46 -0
  15. package/agent-plugin/skills/ship-godot-game/agents/openai.yaml +4 -0
  16. package/agent-plugin/skills/verify-godot-change/SKILL.md +35 -0
  17. package/build/authoring-session-manager.js +237 -0
  18. package/build/domain-tool-registries.js +207 -0
  19. package/build/editor-bridge-protocol.js +1 -0
  20. package/build/editor-connection.js +112 -0
  21. package/build/editor-mutation-guard.js +30 -0
  22. package/build/editor-plugin-installer.js +220 -0
  23. package/build/engine-api/extension_api-4.7.stable.official.5b4e0cb0f.json +356830 -0
  24. package/build/game-command-service.js +49 -0
  25. package/build/game-connection.js +337 -0
  26. package/build/godot-executable.js +156 -0
  27. package/build/godot-process-manager.js +112 -0
  28. package/build/godot-subprocess.js +23 -0
  29. package/build/headless-operation-runner.js +68 -0
  30. package/build/headless-operation-service.js +65 -0
  31. package/build/index.js +478 -0
  32. package/build/interaction-server-installer.js +234 -0
  33. package/build/opencode-setup.js +199 -0
  34. package/build/project-support.js +219 -0
  35. package/build/runtime-protocol.js +202 -0
  36. package/build/scripts/godot_operations.gd +1534 -0
  37. package/build/scripts/mcp_editor_plugin.gd +417 -0
  38. package/build/scripts/mcp_interaction_server.gd +1255 -0
  39. package/build/scripts/mcp_runtime/audio_animation_domain.gd +568 -0
  40. package/build/scripts/mcp_runtime/command_params.gd +339 -0
  41. package/build/scripts/mcp_runtime/core_domain.gd +591 -0
  42. package/build/scripts/mcp_runtime/input_domain.gd +695 -0
  43. package/build/scripts/mcp_runtime/networking_domain.gd +356 -0
  44. package/build/scripts/mcp_runtime/physics_domain.gd +653 -0
  45. package/build/scripts/mcp_runtime/privileged_command_policy.gd +81 -0
  46. package/build/scripts/mcp_runtime/rendering_domain.gd +807 -0
  47. package/build/scripts/mcp_runtime/runtime_domain.gd +108 -0
  48. package/build/scripts/mcp_runtime/scene_2d_domain.gd +527 -0
  49. package/build/scripts/mcp_runtime/scene_3d_domain.gd +573 -0
  50. package/build/scripts/mcp_runtime/system_domain.gd +277 -0
  51. package/build/scripts/mcp_runtime/ui_domain.gd +619 -0
  52. package/build/scripts/mcp_runtime/variant_codec.gd +335 -0
  53. package/build/scripts/validate_script.gd +28 -0
  54. package/build/server-instructions.js +11 -0
  55. package/build/session-timing.js +20 -0
  56. package/build/tool-argument-validation.js +120 -0
  57. package/build/tool-definitions.js +2727 -0
  58. package/build/tool-handlers/game-tool-handlers.js +1345 -0
  59. package/build/tool-handlers/lifecycle-tool-handlers.js +346 -0
  60. package/build/tool-handlers/project-handler-services.js +1458 -0
  61. package/build/tool-handlers/project-tool-handlers.js +1506 -0
  62. package/build/tool-handlers/visual-regression-service.js +139 -0
  63. package/build/tool-manifest.js +1193 -0
  64. package/build/tool-mutation-policy.js +122 -0
  65. package/build/tool-registry.js +34 -0
  66. package/build/tool-surface.js +70 -0
  67. package/build/utils.js +273 -0
  68. package/package.json +110 -0
  69. package/product.json +52 -0
  70. package/scripts/prepare-package.js +42 -0
@@ -0,0 +1,1506 @@
1
+ import { join, dirname, basename } from 'path';
2
+ import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
3
+ import { execFile } from 'child_process';
4
+ import { promisify } from 'util';
5
+ import { normalizeParameters, validatePath, createErrorResponse, errorMessage, isGodot44OrLater, generateGodotProjectFeatures, generateCsprojContent, generateCsharpScriptSource, toDotnetIdentifier, isValidCsharpIdentifier, PathSecurity, } from '../utils.js';
6
+ import { GODOT_VERSION_OPTIONS } from '../godot-subprocess.js';
7
+ import { ProjectConfigurationService, ProjectExportService, ProjectFileIOService, ImportPipelineService, ProjectIntegrityService, ExportReadinessService, DotnetWorkflowService, AddonManagementService, ProjectTestService, SceneOperationService, ScriptValidationService, } from './project-handler-services.js';
8
+ const execFileAsync = promisify(execFile);
9
+ const CI_EXPORT_PLATFORMS = new Set(['windows', 'linux', 'macos', 'web']);
10
+ const DOCKER_BASE_IMAGES = new Set(['ubuntu:22.04', 'ubuntu:24.04']);
11
+ const GODOT_EXPORT_VERSION = /^4\.\d+(?:\.\d+)?(?:-stable)?$/;
12
+ const EXPORT_PRESET_NAME = /^[A-Za-z0-9][A-Za-z0-9 _./-]{0,127}$/;
13
+ const INPUT_EVENT_KEY_PREFIX = 'Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0';
14
+ const INPUT_EVENT_KEY_SUFFIX = ',"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)';
15
+ /** Merge one physical key into an existing one-line project.godot input entry. */
16
+ export function mergeInputMapAction(content, actionName, deadzone, physicalKeycode) {
17
+ const escapedName = actionName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
18
+ const inputSection = /(^|\n)\[input\][^\n]*(?:\n|$)([\s\S]*?)(?=\n\[[^\n]+\](?:\n|$)|$)/.exec(content);
19
+ const newEvent = physicalKeycode === undefined
20
+ ? ''
21
+ : `${INPUT_EVENT_KEY_PREFIX},"physical_keycode":${physicalKeycode}${INPUT_EVENT_KEY_SUFFIX}`;
22
+ if (inputSection) {
23
+ const sectionBody = inputSection[2];
24
+ const actionPattern = new RegExp(`^${escapedName}\\s*=\\s*(\\{[^\\r\\n]*\\})[ \\t]*$`, 'm');
25
+ const existing = actionPattern.exec(sectionBody);
26
+ if (existing) {
27
+ const block = existing[1];
28
+ const existingDeadzone = /"deadzone"\s*:\s*([\d.eE+-]+)/.exec(block)?.[1] ?? String(deadzone);
29
+ const existingEvents = /"events"\s*:\s*\[([\s\S]*?)\]\s*(?:,|\})/.exec(block)?.[1] ?? '';
30
+ const duplicate = physicalKeycode !== undefined
31
+ && new RegExp(`"physical_keycode"\\s*:\\s*${physicalKeycode}\\b`).test(existingEvents);
32
+ if (!newEvent || duplicate) {
33
+ return { content, existed: true, eventAdded: false };
34
+ }
35
+ const mergedEvents = newEvent && !duplicate
36
+ ? (existingEvents.trim() ? `${existingEvents}, ${newEvent}` : newEvent)
37
+ : existingEvents;
38
+ const replacement = mergedEvents.trim()
39
+ ? `${actionName}={"deadzone": ${existingDeadzone}, "events": [${mergedEvents}]}`
40
+ : `${actionName}={"deadzone": ${existingDeadzone}}`;
41
+ const updatedBody = sectionBody.replace(actionPattern, replacement);
42
+ const bodyStart = inputSection.index + inputSection[0].indexOf(sectionBody);
43
+ return {
44
+ content: `${content.slice(0, bodyStart)}${updatedBody}${content.slice(bodyStart + sectionBody.length)}`,
45
+ existed: true,
46
+ eventAdded: Boolean(newEvent) && !duplicate,
47
+ };
48
+ }
49
+ }
50
+ const events = newEvent ? `, "events": [${newEvent}]` : '';
51
+ const inputLine = `${actionName}={"deadzone": ${deadzone}${events}}`;
52
+ const updated = content.includes('[input]')
53
+ ? content.replace('[input]', `[input]\n\n${inputLine}`)
54
+ : `${content}\n[input]\n\n${inputLine}\n`;
55
+ return { content: updated, existed: false, eventAdded: Boolean(newEvent) };
56
+ }
57
+ function validateGodotExportVersion(version) {
58
+ if (typeof version !== 'string' || !GODOT_EXPORT_VERSION.test(version)) {
59
+ return 'godotVersion must be a supported Godot 4 version such as "4.3-stable".';
60
+ }
61
+ return null;
62
+ }
63
+ /** Implements project, scene, file, script, and export tools. */
64
+ export class ProjectToolHandlers {
65
+ context;
66
+ fileIO;
67
+ configuration;
68
+ scriptValidation;
69
+ exportService;
70
+ sceneOperations;
71
+ projectTests;
72
+ importPipeline;
73
+ projectIntegrity;
74
+ exportReadiness;
75
+ dotnetWorkflow;
76
+ addonManagement;
77
+ constructor(context) {
78
+ const pathSecurity = context.pathSecurity ?? new PathSecurity();
79
+ this.context = {
80
+ ...context,
81
+ pathSecurity,
82
+ getGodotPath: () => context.executable.path,
83
+ detectGodotPath: async () => { await context.executable.detect(); },
84
+ executeOperation: (operation, params, projectPath) => context.operations.execute(operation, params, projectPath),
85
+ headlessOp: (operation, args, argsFn) => {
86
+ const { projectPath, params } = argsFn(args);
87
+ return context.operations.run(operation, projectPath, params);
88
+ },
89
+ };
90
+ const serviceContext = {
91
+ executable: context.executable,
92
+ operations: context.operations,
93
+ pathSecurity,
94
+ projectSupport: context.projectSupport,
95
+ };
96
+ this.fileIO = new ProjectFileIOService(serviceContext);
97
+ this.configuration = new ProjectConfigurationService(serviceContext);
98
+ this.scriptValidation = new ScriptValidationService(serviceContext);
99
+ this.exportService = new ProjectExportService(serviceContext);
100
+ this.sceneOperations = new SceneOperationService(serviceContext);
101
+ this.projectTests = new ProjectTestService(serviceContext);
102
+ this.importPipeline = new ImportPipelineService(serviceContext);
103
+ this.projectIntegrity = new ProjectIntegrityService(serviceContext);
104
+ this.exportReadiness = new ExportReadinessService(serviceContext);
105
+ this.dotnetWorkflow = new DotnetWorkflowService(serviceContext);
106
+ this.addonManagement = new AddonManagementService(serviceContext);
107
+ }
108
+ projectRelativePath(projectPath, relativePath) {
109
+ const resolved = this.context.pathSecurity.resolveProjectPath(projectPath, relativePath);
110
+ if (!resolved)
111
+ throw new Error(`Path is outside the project: ${relativePath}`);
112
+ return resolved;
113
+ }
114
+ /** Converts the runner's structured process status into a tool error. */
115
+ headlessFailure(action, result) {
116
+ if (result.exitCode === 0 && result.signal === null)
117
+ return null;
118
+ const status = result.signal
119
+ ? `terminated by signal ${result.signal}`
120
+ : `exited with code ${result.exitCode}`;
121
+ const output = result.stderr || result.stdout;
122
+ return createErrorResponse(`${action} (${status})${output ? `: ${output}` : '.'}`);
123
+ }
124
+ async handleListProjects(args) {
125
+ // Normalize parameters to camelCase
126
+ args = normalizeParameters(args);
127
+ if (!args.directory) {
128
+ return createErrorResponse('Directory is required');
129
+ }
130
+ if (!this.context.pathSecurity.isProjectPathAllowed(args.directory)) {
131
+ return createErrorResponse('Invalid directory path');
132
+ }
133
+ try {
134
+ this.context.logDebug(`Listing Godot projects in directory: ${args.directory}`);
135
+ if (!existsSync(args.directory)) {
136
+ return createErrorResponse(`Directory does not exist: ${args.directory}`);
137
+ }
138
+ const recursive = args.recursive === true;
139
+ const projects = this.context.projectSupport.findGodotProjects(args.directory, recursive);
140
+ return {
141
+ content: [
142
+ {
143
+ type: 'text',
144
+ text: JSON.stringify(projects, null, 2),
145
+ },
146
+ ],
147
+ };
148
+ }
149
+ catch (error) {
150
+ return createErrorResponse(`Failed to list projects: ${errorMessage(error)}`);
151
+ }
152
+ }
153
+ /**
154
+ * Get the structure of a Godot project asynchronously by counting files recursively
155
+ * @param projectPath Path to the Godot project
156
+ * @returns Promise resolving to an object with counts of scenes, scripts, assets, and other files
157
+ */
158
+ async handleGetProjectInfo(args) {
159
+ // Normalize parameters to camelCase
160
+ args = normalizeParameters(args);
161
+ if (!args.projectPath) {
162
+ return createErrorResponse('Project path is required');
163
+ }
164
+ if (!this.context.pathSecurity.isProjectPathAllowed(args.projectPath)) {
165
+ return createErrorResponse('Invalid project path');
166
+ }
167
+ try {
168
+ // Ensure godotPath is set
169
+ if (!this.context.getGodotPath()) {
170
+ await this.context.detectGodotPath();
171
+ if (!this.context.getGodotPath()) {
172
+ return createErrorResponse('Could not find a valid Godot executable path');
173
+ }
174
+ }
175
+ // Check if the project directory exists and contains a project.godot file
176
+ const projectFile = join(args.projectPath, 'project.godot');
177
+ if (!existsSync(projectFile)) {
178
+ return createErrorResponse(`Not a valid Godot project: ${args.projectPath}`);
179
+ }
180
+ this.context.logDebug(`Getting project info for: ${args.projectPath}`);
181
+ // Get Godot version
182
+ const { stdout } = await execFileAsync(this.context.getGodotPath(), ['--version'], GODOT_VERSION_OPTIONS);
183
+ // Get project structure using the recursive method
184
+ const projectStructure = await this.context.projectSupport.getProjectStructureAsync(args.projectPath);
185
+ // Extract project name from project.godot file
186
+ let projectName = basename(args.projectPath);
187
+ try {
188
+ const projectFileContent = readFileSync(projectFile, 'utf8');
189
+ const configNameMatch = /config\/name="([^"]+)"/.exec(projectFileContent);
190
+ if (configNameMatch && configNameMatch[1]) {
191
+ projectName = configNameMatch[1];
192
+ this.context.logDebug(`Found project name in config: ${projectName}`);
193
+ }
194
+ }
195
+ catch (error) {
196
+ this.context.logDebug(`Error reading project file: ${error}`);
197
+ // Continue with default project name if extraction fails
198
+ }
199
+ return {
200
+ content: [
201
+ {
202
+ type: 'text',
203
+ text: JSON.stringify({
204
+ name: projectName,
205
+ path: args.projectPath,
206
+ godotVersion: stdout.trim(),
207
+ isDotnet: this.context.projectSupport.isDotnetProject(args.projectPath),
208
+ structure: projectStructure,
209
+ }, null, 2),
210
+ },
211
+ ],
212
+ };
213
+ }
214
+ catch (error) {
215
+ return createErrorResponse(`Failed to get project info: ${errorMessage(error)}`);
216
+ }
217
+ }
218
+ /**
219
+ * Handle the create_scene tool
220
+ */
221
+ async handleCreateScene(args) {
222
+ // Normalize parameters to camelCase
223
+ args = normalizeParameters(args);
224
+ if (!args.projectPath || !args.scenePath) {
225
+ return createErrorResponse('Project path and scene path are required');
226
+ }
227
+ if (!this.context.pathSecurity.isProjectPathAllowed(args.projectPath) || !validatePath(args.scenePath)) {
228
+ return createErrorResponse('Invalid path');
229
+ }
230
+ try {
231
+ // Check if the project directory exists and contains a project.godot file
232
+ const projectFile = join(args.projectPath, 'project.godot');
233
+ if (!existsSync(projectFile)) {
234
+ return createErrorResponse(`Not a valid Godot project: ${args.projectPath}`);
235
+ }
236
+ // Prepare parameters for the operation (already in camelCase)
237
+ const params = {
238
+ scenePath: args.scenePath,
239
+ rootNodeType: args.rootNodeType || 'Node2D',
240
+ };
241
+ // Execute the operation
242
+ const result = await this.context.executeOperation('create_scene', params, args.projectPath);
243
+ const failure = this.headlessFailure('Failed to create scene', result);
244
+ if (failure)
245
+ return failure;
246
+ const { stdout } = result;
247
+ return {
248
+ content: [
249
+ {
250
+ type: 'text',
251
+ text: `Scene created successfully at: ${args.scenePath}\n\nOutput: ${stdout}`,
252
+ },
253
+ ],
254
+ };
255
+ }
256
+ catch (error) {
257
+ return createErrorResponse(`Failed to create scene: ${errorMessage(error)}`);
258
+ }
259
+ }
260
+ /**
261
+ * Handle the add_node tool
262
+ */
263
+ async handleAddNode(args) {
264
+ // Normalize parameters to camelCase
265
+ args = normalizeParameters(args);
266
+ if (!args.projectPath || !args.scenePath || !args.nodeType || !args.nodeName) {
267
+ return createErrorResponse('Missing required parameters');
268
+ }
269
+ if (!this.context.pathSecurity.isProjectPathAllowed(args.projectPath) || !validatePath(args.scenePath)) {
270
+ return createErrorResponse('Invalid path');
271
+ }
272
+ try {
273
+ // Check if the project directory exists and contains a project.godot file
274
+ const projectFile = join(args.projectPath, 'project.godot');
275
+ if (!existsSync(projectFile)) {
276
+ return createErrorResponse(`Not a valid Godot project: ${args.projectPath}`);
277
+ }
278
+ // Check if the scene file exists
279
+ const scenePath = this.projectRelativePath(args.projectPath, args.scenePath);
280
+ if (!existsSync(scenePath)) {
281
+ return createErrorResponse(`Scene file does not exist: ${args.scenePath}`);
282
+ }
283
+ // Prepare parameters for the operation (already in camelCase)
284
+ const params = {
285
+ scenePath: args.scenePath,
286
+ nodeType: args.nodeType,
287
+ nodeName: args.nodeName,
288
+ };
289
+ // Add optional parameters
290
+ if (args.parentNodePath) {
291
+ params.parentNodePath = args.parentNodePath;
292
+ }
293
+ if (args.properties) {
294
+ params.properties = args.properties;
295
+ }
296
+ // Execute the operation
297
+ const result = await this.context.executeOperation('add_node', params, args.projectPath);
298
+ const failure = this.headlessFailure('Failed to add node', result);
299
+ if (failure)
300
+ return failure;
301
+ const { stdout } = result;
302
+ return {
303
+ content: [
304
+ {
305
+ type: 'text',
306
+ text: `Node '${args.nodeName}' of type '${args.nodeType}' added successfully to '${args.scenePath}'.\n\nOutput: ${stdout}`,
307
+ },
308
+ ],
309
+ };
310
+ }
311
+ catch (error) {
312
+ return createErrorResponse(`Failed to add node: ${errorMessage(error)}`);
313
+ }
314
+ }
315
+ /**
316
+ * Handle the load_sprite tool
317
+ */
318
+ async handleLoadSprite(args) {
319
+ // Normalize parameters to camelCase
320
+ args = normalizeParameters(args);
321
+ if (!args.projectPath || !args.scenePath || !args.nodePath || !args.texturePath) {
322
+ return createErrorResponse('Missing required parameters');
323
+ }
324
+ if (!this.context.pathSecurity.isProjectPathAllowed(args.projectPath) ||
325
+ !validatePath(args.scenePath) ||
326
+ !validatePath(args.nodePath) ||
327
+ !validatePath(args.texturePath)) {
328
+ return createErrorResponse('Invalid path');
329
+ }
330
+ try {
331
+ // Check if the project directory exists and contains a project.godot file
332
+ const projectFile = join(args.projectPath, 'project.godot');
333
+ if (!existsSync(projectFile)) {
334
+ return createErrorResponse(`Not a valid Godot project: ${args.projectPath}`);
335
+ }
336
+ // Check if the scene file exists
337
+ const scenePath = this.projectRelativePath(args.projectPath, args.scenePath);
338
+ if (!existsSync(scenePath)) {
339
+ return createErrorResponse(`Scene file does not exist: ${args.scenePath}`);
340
+ }
341
+ // Check if the texture file exists
342
+ const texturePath = this.projectRelativePath(args.projectPath, args.texturePath);
343
+ if (!existsSync(texturePath)) {
344
+ return createErrorResponse(`Texture file does not exist: ${args.texturePath}`);
345
+ }
346
+ // Prepare parameters for the operation (already in camelCase)
347
+ const params = {
348
+ scenePath: args.scenePath,
349
+ nodePath: args.nodePath,
350
+ texturePath: args.texturePath,
351
+ };
352
+ // Execute the operation
353
+ const result = await this.context.executeOperation('load_sprite', params, args.projectPath);
354
+ const failure = this.headlessFailure('Failed to load sprite', result);
355
+ if (failure)
356
+ return failure;
357
+ const { stdout } = result;
358
+ return {
359
+ content: [
360
+ {
361
+ type: 'text',
362
+ text: `Sprite loaded successfully with texture: ${args.texturePath}\n\nOutput: ${stdout}`,
363
+ },
364
+ ],
365
+ };
366
+ }
367
+ catch (error) {
368
+ return createErrorResponse(`Failed to load sprite: ${errorMessage(error)}`);
369
+ }
370
+ }
371
+ /**
372
+ * Handle the export_mesh_library tool
373
+ */
374
+ async handleExportMeshLibrary(args) {
375
+ // Normalize parameters to camelCase
376
+ args = normalizeParameters(args);
377
+ if (!args.projectPath || !args.scenePath || !args.outputPath) {
378
+ return createErrorResponse('Missing required parameters');
379
+ }
380
+ if (!this.context.pathSecurity.isProjectPathAllowed(args.projectPath) ||
381
+ !validatePath(args.scenePath) ||
382
+ !validatePath(args.outputPath)) {
383
+ return createErrorResponse('Invalid path');
384
+ }
385
+ try {
386
+ // Check if the project directory exists and contains a project.godot file
387
+ const projectFile = join(args.projectPath, 'project.godot');
388
+ if (!existsSync(projectFile)) {
389
+ return createErrorResponse(`Not a valid Godot project: ${args.projectPath}`);
390
+ }
391
+ // Check if the scene file exists
392
+ const scenePath = this.projectRelativePath(args.projectPath, args.scenePath);
393
+ if (!existsSync(scenePath)) {
394
+ return createErrorResponse(`Scene file does not exist: ${args.scenePath}`);
395
+ }
396
+ // Prepare parameters for the operation (already in camelCase)
397
+ const params = {
398
+ scenePath: args.scenePath,
399
+ outputPath: args.outputPath,
400
+ };
401
+ // Add optional parameters
402
+ if (args.meshItemNames && Array.isArray(args.meshItemNames)) {
403
+ params.meshItemNames = args.meshItemNames;
404
+ }
405
+ // Execute the operation
406
+ const result = await this.context.executeOperation('export_mesh_library', params, args.projectPath);
407
+ const failure = this.headlessFailure('Failed to export mesh library', result);
408
+ if (failure)
409
+ return failure;
410
+ const { stdout } = result;
411
+ return {
412
+ content: [
413
+ {
414
+ type: 'text',
415
+ text: `MeshLibrary exported successfully to: ${args.outputPath}\n\nOutput: ${stdout}`,
416
+ },
417
+ ],
418
+ };
419
+ }
420
+ catch (error) {
421
+ return createErrorResponse(`Failed to export mesh library: ${errorMessage(error)}`);
422
+ }
423
+ }
424
+ /**
425
+ * Handle the save_scene tool
426
+ */
427
+ async handleSaveScene(args) {
428
+ // Normalize parameters to camelCase
429
+ args = normalizeParameters(args);
430
+ if (!args.projectPath || !args.scenePath) {
431
+ return createErrorResponse('Missing required parameters');
432
+ }
433
+ if (!this.context.pathSecurity.isProjectPathAllowed(args.projectPath) || !validatePath(args.scenePath)) {
434
+ return createErrorResponse('Invalid path');
435
+ }
436
+ // If newPath is provided, validate it
437
+ if (args.newPath && !validatePath(args.newPath)) {
438
+ return createErrorResponse('Invalid new path');
439
+ }
440
+ try {
441
+ // Check if the project directory exists and contains a project.godot file
442
+ const projectFile = join(args.projectPath, 'project.godot');
443
+ if (!existsSync(projectFile)) {
444
+ return createErrorResponse(`Not a valid Godot project: ${args.projectPath}`);
445
+ }
446
+ // Check if the scene file exists
447
+ const scenePath = this.projectRelativePath(args.projectPath, args.scenePath);
448
+ if (!existsSync(scenePath)) {
449
+ return createErrorResponse(`Scene file does not exist: ${args.scenePath}`);
450
+ }
451
+ // Prepare parameters for the operation (already in camelCase)
452
+ const params = {
453
+ scenePath: args.scenePath,
454
+ };
455
+ // Add optional parameters
456
+ if (args.newPath) {
457
+ params.newPath = args.newPath;
458
+ }
459
+ // Execute the operation
460
+ const result = await this.context.executeOperation('save_scene', params, args.projectPath);
461
+ const failure = this.headlessFailure('Failed to save scene', result);
462
+ if (failure)
463
+ return failure;
464
+ const { stdout } = result;
465
+ const savePath = args.newPath || args.scenePath;
466
+ return {
467
+ content: [
468
+ {
469
+ type: 'text',
470
+ text: `Scene saved successfully to: ${savePath}\n\nOutput: ${stdout}`,
471
+ },
472
+ ],
473
+ };
474
+ }
475
+ catch (error) {
476
+ return createErrorResponse(`Failed to save scene: ${errorMessage(error)}`);
477
+ }
478
+ }
479
+ /**
480
+ * Handle the get_uid tool
481
+ */
482
+ async handleGetUid(args) {
483
+ // Normalize parameters to camelCase
484
+ args = normalizeParameters(args);
485
+ if (!args.projectPath || !args.filePath) {
486
+ return createErrorResponse('Missing required parameters');
487
+ }
488
+ if (!this.context.pathSecurity.isProjectPathAllowed(args.projectPath) || !validatePath(args.filePath)) {
489
+ return createErrorResponse('Invalid path');
490
+ }
491
+ try {
492
+ // Ensure godotPath is set
493
+ if (!this.context.getGodotPath()) {
494
+ await this.context.detectGodotPath();
495
+ if (!this.context.getGodotPath()) {
496
+ return createErrorResponse('Could not find a valid Godot executable path');
497
+ }
498
+ }
499
+ // Check if the project directory exists and contains a project.godot file
500
+ const projectFile = join(args.projectPath, 'project.godot');
501
+ if (!existsSync(projectFile)) {
502
+ return createErrorResponse(`Not a valid Godot project: ${args.projectPath}`);
503
+ }
504
+ // Check if the file exists
505
+ const filePath = this.projectRelativePath(args.projectPath, args.filePath);
506
+ if (!existsSync(filePath)) {
507
+ return createErrorResponse(`File does not exist: ${args.filePath}`);
508
+ }
509
+ // Get Godot version to check if UIDs are supported
510
+ const { stdout: versionOutput } = await execFileAsync(this.context.getGodotPath(), ['--version'], GODOT_VERSION_OPTIONS);
511
+ const version = versionOutput.trim();
512
+ if (!isGodot44OrLater(version)) {
513
+ return createErrorResponse(`UIDs are only supported in Godot 4.4 or later. Current version: ${version}`);
514
+ }
515
+ // Prepare parameters for the operation (already in camelCase)
516
+ const params = {
517
+ filePath: args.filePath,
518
+ };
519
+ // Execute the operation
520
+ const result = await this.context.executeOperation('get_uid', params, args.projectPath);
521
+ const failure = this.headlessFailure('Failed to get UID', result);
522
+ if (failure)
523
+ return failure;
524
+ const { stdout } = result;
525
+ return {
526
+ content: [
527
+ {
528
+ type: 'text',
529
+ text: `UID for ${args.filePath}: ${stdout.trim()}`,
530
+ },
531
+ ],
532
+ };
533
+ }
534
+ catch (error) {
535
+ return createErrorResponse(`Failed to get UID: ${errorMessage(error)}`);
536
+ }
537
+ }
538
+ /**
539
+ * Handle the game_screenshot tool
540
+ */
541
+ async handleReadScene(args) {
542
+ args = normalizeParameters(args || {});
543
+ if (!args.projectPath || !args.scenePath) {
544
+ return createErrorResponse('projectPath and scenePath are required.');
545
+ }
546
+ if (!this.context.pathSecurity.isProjectPathAllowed(args.projectPath) || !validatePath(args.scenePath)) {
547
+ return createErrorResponse('Invalid path.');
548
+ }
549
+ const projectFile = join(args.projectPath, 'project.godot');
550
+ if (!existsSync(projectFile)) {
551
+ return createErrorResponse(`Not a valid Godot project: ${args.projectPath}`);
552
+ }
553
+ const scenePath = this.projectRelativePath(args.projectPath, args.scenePath);
554
+ if (!existsSync(scenePath)) {
555
+ return createErrorResponse(`Scene file does not exist: ${args.scenePath}`);
556
+ }
557
+ try {
558
+ const result = await this.context.executeOperation('read_scene', {
559
+ scenePath: args.scenePath,
560
+ }, args.projectPath);
561
+ const failure = this.headlessFailure('Failed to read scene', result);
562
+ if (failure)
563
+ return failure;
564
+ const { stdout, stderr } = result;
565
+ // Extract JSON from the SCENE_JSON_START/END markers
566
+ const startMarker = 'SCENE_JSON_START';
567
+ const endMarker = 'SCENE_JSON_END';
568
+ const startIdx = stdout.indexOf(startMarker);
569
+ const endIdx = stdout.indexOf(endMarker);
570
+ if (startIdx !== -1 && endIdx !== -1) {
571
+ const jsonStr = stdout.substring(startIdx + startMarker.length, endIdx).trim();
572
+ try {
573
+ const parsed = JSON.parse(jsonStr);
574
+ return {
575
+ content: [{ type: 'text', text: JSON.stringify(parsed, null, 2) }],
576
+ };
577
+ }
578
+ catch {
579
+ return {
580
+ content: [{ type: 'text', text: `Raw scene data:\n${jsonStr}` }],
581
+ };
582
+ }
583
+ }
584
+ return {
585
+ content: [{ type: 'text', text: `Scene read output:\n${stdout}\n${stderr ? 'Errors:\n' + stderr : ''}` }],
586
+ };
587
+ }
588
+ catch (error) {
589
+ return createErrorResponse(`Failed to read scene: ${errorMessage(error)}`);
590
+ }
591
+ }
592
+ /**
593
+ * Handle the modify_scene_node tool
594
+ */
595
+ async handleModifySceneNode(args) {
596
+ args = normalizeParameters(args || {});
597
+ if (!args.projectPath || !args.scenePath || !args.nodePath || !args.properties)
598
+ return createErrorResponse('projectPath, scenePath, nodePath, and properties are required.');
599
+ return this.sceneOperations.run('modify_node', args, { scenePath: args.scenePath, nodePath: args.nodePath, properties: args.properties });
600
+ }
601
+ async handleRemoveSceneNode(args) {
602
+ args = normalizeParameters(args || {});
603
+ if (!args.projectPath || !args.scenePath || !args.nodePath)
604
+ return createErrorResponse('projectPath, scenePath, and nodePath are required.');
605
+ return this.sceneOperations.run('remove_node', args, { scenePath: args.scenePath, nodePath: args.nodePath });
606
+ }
607
+ /**
608
+ * Handle the read_project_settings tool - Parse project.godot as JSON
609
+ */
610
+ async handleReadProjectSettings(args) {
611
+ return this.configuration.read(args);
612
+ }
613
+ /**
614
+ * Handle the modify_project_settings tool - Change a project.godot setting
615
+ */
616
+ async handleModifyProjectSettings(args) {
617
+ return this.configuration.modify(args);
618
+ }
619
+ /**
620
+ * Handle the list_project_files tool - List files with extension filtering
621
+ */
622
+ async handleListProjectFiles(args) {
623
+ args = normalizeParameters(args || {});
624
+ if (!args.projectPath) {
625
+ return createErrorResponse('projectPath is required.');
626
+ }
627
+ if (!this.context.pathSecurity.isProjectPathAllowed(args.projectPath)) {
628
+ return createErrorResponse('Invalid path.');
629
+ }
630
+ if (!existsSync(args.projectPath)) {
631
+ return createErrorResponse(`Directory does not exist: ${args.projectPath}`);
632
+ }
633
+ try {
634
+ const baseDir = args.subdirectory
635
+ ? this.projectRelativePath(args.projectPath, args.subdirectory)
636
+ : args.projectPath;
637
+ if (!existsSync(baseDir)) {
638
+ return createErrorResponse(`Subdirectory does not exist: ${args.subdirectory}`);
639
+ }
640
+ const files = [];
641
+ const extensions = args.extensions;
642
+ const scanDir = (dir, relativeTo) => {
643
+ const entries = readdirSync(dir, { withFileTypes: true });
644
+ for (const entry of entries) {
645
+ if (entry.name.startsWith('.'))
646
+ continue;
647
+ const fullPath = join(dir, entry.name);
648
+ const relativePath = fullPath.substring(relativeTo.length + 1).replace(/\\/g, '/');
649
+ if (entry.isDirectory()) {
650
+ scanDir(fullPath, relativeTo);
651
+ }
652
+ else if (entry.isFile()) {
653
+ if (extensions && extensions.length > 0) {
654
+ const ext = '.' + entry.name.split('.').pop();
655
+ if (extensions.includes(ext)) {
656
+ files.push(relativePath);
657
+ }
658
+ }
659
+ else {
660
+ files.push(relativePath);
661
+ }
662
+ }
663
+ }
664
+ };
665
+ scanDir(baseDir, args.projectPath);
666
+ files.sort((left, right) => left.localeCompare(right, 'en'));
667
+ const cursor = args.cursor ?? 0;
668
+ const limit = args.limit ?? 1000;
669
+ const page = files.slice(cursor, cursor + limit);
670
+ const nextCursor = cursor + page.length < files.length ? cursor + page.length : null;
671
+ return {
672
+ content: [{
673
+ type: 'text',
674
+ text: JSON.stringify({ count: page.length, total: files.length, files: page, nextCursor }, null, 2),
675
+ }],
676
+ };
677
+ }
678
+ catch (error) {
679
+ return createErrorResponse(`Failed to list project files: ${errorMessage(error)}`);
680
+ }
681
+ }
682
+ async handleAttachScript(args) {
683
+ args = normalizeParameters(args || {});
684
+ if (!args.projectPath || !args.scenePath || !args.nodePath || !args.scriptPath)
685
+ return createErrorResponse('projectPath, scenePath, nodePath, and scriptPath are required.');
686
+ return this.context.headlessOp('attach_script', args, a => ({
687
+ projectPath: a.projectPath,
688
+ params: { scenePath: a.scenePath, nodePath: a.nodePath, scriptPath: a.scriptPath },
689
+ }));
690
+ }
691
+ async handleCreateResource(args) {
692
+ args = normalizeParameters(args || {});
693
+ if (!args.projectPath || !args.resourceType || !args.resourcePath)
694
+ return createErrorResponse('projectPath, resourceType, and resourcePath are required.');
695
+ return this.context.headlessOp('create_resource', args, a => ({
696
+ projectPath: a.projectPath,
697
+ params: { resourceType: a.resourceType, resourcePath: a.resourcePath, ...(a.properties ? { properties: a.properties } : {}) },
698
+ }));
699
+ }
700
+ // --- File I/O handlers ---
701
+ async handleReadFile(args) {
702
+ return this.fileIO.read(args);
703
+ }
704
+ async handleWriteFile(args) {
705
+ return this.fileIO.write(args);
706
+ }
707
+ async handleDeleteFile(args) {
708
+ return this.fileIO.delete(args);
709
+ }
710
+ async handleCreateDirectory(args) {
711
+ return this.fileIO.createDirectory(args);
712
+ }
713
+ // --- Error/Log capture handlers ---
714
+ async handleCreateProject(args) {
715
+ args = normalizeParameters(args || {});
716
+ if (!args.projectPath || !args.projectName)
717
+ return createErrorResponse('projectPath and projectName are required.');
718
+ if (!this.context.pathSecurity.isProjectPathAllowed(args.projectPath, true))
719
+ return createErrorResponse('Invalid path.');
720
+ try {
721
+ if (!existsSync(args.projectPath)) {
722
+ mkdirSync(args.projectPath, { recursive: true });
723
+ }
724
+ const projectFile = join(args.projectPath, 'project.godot');
725
+ if (existsSync(projectFile))
726
+ return createErrorResponse('A project.godot already exists at this path.');
727
+ const isDotnet = args.dotnet === true;
728
+ const assemblyName = toDotnetIdentifier(args.projectName);
729
+ const features = generateGodotProjectFeatures(isDotnet);
730
+ let content = `; Engine configuration file.\n; Generated by Godot Agent Loop.\n\nconfig_version=5\n\n[application]\n\nconfig/name=${JSON.stringify(args.projectName)}\nconfig/features=${features}\n`;
731
+ if (isDotnet) {
732
+ content += `\n[dotnet]\n\nproject/assembly_name=${JSON.stringify(assemblyName)}\n`;
733
+ }
734
+ writeFileSync(projectFile, content, 'utf8');
735
+ if (isDotnet) {
736
+ const sdkVersion = (await this.context.projectSupport.detectGodotNetSdkVersion()) ?? undefined;
737
+ writeFileSync(this.projectRelativePath(args.projectPath, `${assemblyName}.csproj`), generateCsprojContent(args.projectName, sdkVersion), 'utf8');
738
+ }
739
+ return { content: [{ type: 'text', text: `Project "${args.projectName}" created at ${args.projectPath}${isDotnet ? ' (Godot .NET / C#)' : ''}` }] };
740
+ }
741
+ catch (error) {
742
+ return createErrorResponse(`Failed to create project: ${errorMessage(error)}`);
743
+ }
744
+ }
745
+ async handleCreateCsharpScript(args) {
746
+ args = normalizeParameters(args || {});
747
+ if (!args.projectPath || !args.scriptPath)
748
+ return createErrorResponse('projectPath and scriptPath are required.');
749
+ if (!this.context.pathSecurity.isProjectPathAllowed(args.projectPath) || !validatePath(args.scriptPath))
750
+ return createErrorResponse('Invalid path.');
751
+ const projectFile = join(args.projectPath, 'project.godot');
752
+ if (!existsSync(projectFile))
753
+ return createErrorResponse(`Not a valid Godot project: ${args.projectPath}`);
754
+ if (!this.context.projectSupport.isDotnetProject(args.projectPath))
755
+ return createErrorResponse('Not a Godot .NET project (no .csproj found). Use create_project with dotnet: true first.');
756
+ if (!/\.cs$/i.test(args.scriptPath))
757
+ return createErrorResponse('scriptPath must end with .cs');
758
+ const fileBase = basename(args.scriptPath).replace(/\.cs$/i, '');
759
+ if (!isValidCsharpIdentifier(fileBase))
760
+ return createErrorResponse(`Invalid C# script file name "${fileBase}.cs": the name must be a valid class name (letters, digits, underscore; not starting with a digit), because Godot requires the class name to match the file name.`);
761
+ if (args.className && args.className !== fileBase)
762
+ return createErrorResponse(`className "${args.className}" must match the script file name "${fileBase}" for Godot to attach the script.`);
763
+ try {
764
+ const fullPath = this.projectRelativePath(args.projectPath, args.scriptPath);
765
+ const dir = dirname(fullPath);
766
+ if (!existsSync(dir))
767
+ mkdirSync(dir, { recursive: true });
768
+ let source = args.source;
769
+ if (!source) {
770
+ source = generateCsharpScriptSource({
771
+ className: fileBase,
772
+ baseClass: args.baseClass,
773
+ namespaceName: args.namespaceName,
774
+ methods: Array.isArray(args.methods) ? args.methods : undefined,
775
+ });
776
+ }
777
+ writeFileSync(fullPath, source, 'utf8');
778
+ return { content: [{ type: 'text', text: `C# script created at ${args.scriptPath}` }] };
779
+ }
780
+ catch (error) {
781
+ return createErrorResponse(`create_csharp_script failed: ${errorMessage(error)}`);
782
+ }
783
+ }
784
+ async handleManageAutoloads(args) {
785
+ args = normalizeParameters(args || {});
786
+ if (!args.projectPath || !args.action)
787
+ return createErrorResponse('projectPath and action are required.');
788
+ if (!this.context.pathSecurity.isProjectPathAllowed(args.projectPath))
789
+ return createErrorResponse('Invalid path.');
790
+ const projectFile = join(args.projectPath, 'project.godot');
791
+ if (!existsSync(projectFile))
792
+ return createErrorResponse(`Not a valid Godot project: ${args.projectPath}`);
793
+ try {
794
+ let content = readFileSync(projectFile, 'utf8');
795
+ if (args.action === 'list') {
796
+ const autoloads = {};
797
+ const autoloadMatch = /\[autoload\]([\s\S]*?)(?=\n\[|$)/.exec(content);
798
+ if (autoloadMatch) {
799
+ for (const line of autoloadMatch[1].split('\n')) {
800
+ const kv = /^([^=]+)=(.*)$/.exec(line.trim());
801
+ if (kv)
802
+ autoloads[kv[1].trim()] = kv[2].trim();
803
+ }
804
+ }
805
+ return { content: [{ type: 'text', text: JSON.stringify(autoloads, null, 2) }] };
806
+ }
807
+ else if (args.action === 'add') {
808
+ if (!args.name || !args.path)
809
+ return createErrorResponse('name and path are required for add action.');
810
+ const autoloadLine = `${args.name}="*${args.path}"`;
811
+ if (content.includes('[autoload]')) {
812
+ content = content.replace('[autoload]', `[autoload]\n\n${autoloadLine}`);
813
+ }
814
+ else {
815
+ content += `\n[autoload]\n\n${autoloadLine}\n`;
816
+ }
817
+ writeFileSync(projectFile, content, 'utf8');
818
+ return { content: [{ type: 'text', text: `Autoload "${args.name}" added: ${args.path}` }] };
819
+ }
820
+ else if (args.action === 'remove') {
821
+ if (!args.name)
822
+ return createErrorResponse('name is required for remove action.');
823
+ const pattern = new RegExp(`\\n?${args.name}\\s*=.*\\n?`, 'g');
824
+ content = content.replace(pattern, '\n');
825
+ writeFileSync(projectFile, content, 'utf8');
826
+ return { content: [{ type: 'text', text: `Autoload "${args.name}" removed.` }] };
827
+ }
828
+ return createErrorResponse('Invalid action. Use "list", "add", or "remove".');
829
+ }
830
+ catch (error) {
831
+ return createErrorResponse(`Failed to manage autoloads: ${errorMessage(error)}`);
832
+ }
833
+ }
834
+ async handleManageInputMap(args) {
835
+ args = normalizeParameters(args || {});
836
+ if (!args.projectPath || !args.action)
837
+ return createErrorResponse('projectPath and action are required.');
838
+ if (!this.context.pathSecurity.isProjectPathAllowed(args.projectPath))
839
+ return createErrorResponse('Invalid path.');
840
+ const projectFile = join(args.projectPath, 'project.godot');
841
+ if (!existsSync(projectFile))
842
+ return createErrorResponse(`Not a valid Godot project: ${args.projectPath}`);
843
+ try {
844
+ let content = readFileSync(projectFile, 'utf8');
845
+ if (args.action === 'list') {
846
+ const actions = {};
847
+ const inputMatch = /\[input\]([\s\S]*?)(?=\n\[|$)/.exec(content);
848
+ if (inputMatch) {
849
+ for (const line of inputMatch[1].split('\n')) {
850
+ const kv = /^([^=]+)=(.*)$/.exec(line.trim());
851
+ if (kv)
852
+ actions[kv[1].trim()] = kv[2].trim();
853
+ }
854
+ }
855
+ return { content: [{ type: 'text', text: JSON.stringify(actions, null, 2) }] };
856
+ }
857
+ else if (args.action === 'add') {
858
+ if (!args.actionName)
859
+ return createErrorResponse('actionName is required for add action.');
860
+ const deadzone = args.deadzone !== undefined ? args.deadzone : 0.5;
861
+ const keycode = args.key ? this.context.projectSupport.keyNameToScancode(args.key) : undefined;
862
+ const merged = mergeInputMapAction(content, args.actionName, deadzone, keycode);
863
+ content = merged.content;
864
+ writeFileSync(projectFile, content, 'utf8');
865
+ const outcome = merged.existed ? (merged.eventAdded ? 'updated with key binding' : 'already configured') : 'added';
866
+ return { content: [{ type: 'text', text: `Input action "${args.actionName}" ${outcome}.` }] };
867
+ }
868
+ else if (args.action === 'remove') {
869
+ if (!args.actionName)
870
+ return createErrorResponse('actionName is required for remove action.');
871
+ const pattern = new RegExp(`\\n?${args.actionName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*=.*\\n?`, 'g');
872
+ content = content.replace(pattern, '\n');
873
+ writeFileSync(projectFile, content, 'utf8');
874
+ return { content: [{ type: 'text', text: `Input action "${args.actionName}" removed.` }] };
875
+ }
876
+ return createErrorResponse('Invalid action. Use "list", "add", or "remove".');
877
+ }
878
+ catch (error) {
879
+ return createErrorResponse(`Failed to manage input map: ${errorMessage(error)}`);
880
+ }
881
+ }
882
+ async handleManageExportPresets(args) {
883
+ args = normalizeParameters(args || {});
884
+ if (!args.projectPath || !args.action)
885
+ return createErrorResponse('projectPath and action are required.');
886
+ if (!this.context.pathSecurity.isProjectPathAllowed(args.projectPath))
887
+ return createErrorResponse('Invalid path.');
888
+ const projectFile = join(args.projectPath, 'project.godot');
889
+ if (!existsSync(projectFile))
890
+ return createErrorResponse(`Not a valid Godot project: ${args.projectPath}`);
891
+ const presetsFile = join(args.projectPath, 'export_presets.cfg');
892
+ try {
893
+ if (args.action === 'list') {
894
+ if (!existsSync(presetsFile))
895
+ return { content: [{ type: 'text', text: JSON.stringify({ presets: [] }, null, 2) }] };
896
+ const content = readFileSync(presetsFile, 'utf8');
897
+ const presets = [];
898
+ const nameMatches = content.matchAll(/name="([^"]+)"/g);
899
+ const platformMatches = content.matchAll(/platform="([^"]+)"/g);
900
+ const names = [...nameMatches].map(m => m[1]);
901
+ const platforms = [...platformMatches].map(m => m[1]);
902
+ for (let i = 0; i < names.length; i++) {
903
+ presets.push({ name: names[i], platform: platforms[i] || 'unknown' });
904
+ }
905
+ return { content: [{ type: 'text', text: JSON.stringify({ presets }, null, 2) }] };
906
+ }
907
+ else if (args.action === 'add') {
908
+ if (!args.name || !args.platform)
909
+ return createErrorResponse('name and platform are required for add action.');
910
+ const runnable = args.runnable ? 'true' : 'false';
911
+ const presetBlock = `\n[preset.${Date.now()}]\n\nname="${args.name}"\nplatform="${args.platform}"\nrunnable=${runnable}\n`;
912
+ let content = existsSync(presetsFile) ? readFileSync(presetsFile, 'utf8') : '';
913
+ content += presetBlock;
914
+ writeFileSync(presetsFile, content, 'utf8');
915
+ return { content: [{ type: 'text', text: `Export preset "${args.name}" added for platform "${args.platform}".` }] };
916
+ }
917
+ else if (args.action === 'remove') {
918
+ if (!args.name)
919
+ return createErrorResponse('name is required for remove action.');
920
+ if (!existsSync(presetsFile))
921
+ return createErrorResponse('No export_presets.cfg file found.');
922
+ let content = readFileSync(presetsFile, 'utf8');
923
+ // Remove the preset section containing the given name
924
+ const pattern = new RegExp(`\\[preset\\.[^\\]]+\\]\\s*\\n[\\s\\S]*?name="${args.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"[\\s\\S]*?(?=\\[preset\\.|$)`, 'g');
925
+ content = content.replace(pattern, '');
926
+ writeFileSync(presetsFile, content, 'utf8');
927
+ return { content: [{ type: 'text', text: `Export preset "${args.name}" removed.` }] };
928
+ }
929
+ return createErrorResponse('Invalid action. Use "list", "add", or "remove".');
930
+ }
931
+ catch (error) {
932
+ return createErrorResponse(`Failed to manage export presets: ${errorMessage(error)}`);
933
+ }
934
+ }
935
+ // --- Advanced runtime handlers ---
936
+ async handleExportProject(args) {
937
+ return this.exportService.export(args);
938
+ }
939
+ async handleRunProjectTests(args) {
940
+ if (args.action !== 'discover' && args.action !== 'run') {
941
+ return createErrorResponse('action must be discover or run.');
942
+ }
943
+ return this.projectTests.execute(args);
944
+ }
945
+ async handleManageImportPipeline(args) {
946
+ if (!['inspect', 'change', 'reimport', 'dependencies'].includes(String(args.action))) {
947
+ return createErrorResponse('action must be inspect, change, reimport, or dependencies.');
948
+ }
949
+ return this.importPipeline.execute(args);
950
+ }
951
+ async handleAnalyzeProjectIntegrity(args) {
952
+ if (!['analyze', 'preview_rename', 'assets', 'localization', 'accessibility', 'extensions', 'leaks'].includes(args.action)) {
953
+ return createErrorResponse('action must be analyze, preview_rename, assets, localization, accessibility, extensions, or leaks.');
954
+ }
955
+ return this.projectIntegrity.execute(args);
956
+ }
957
+ async handleVerifyExportReadiness(args) {
958
+ if (args.action !== 'inspect' && args.action !== 'export_smoke') {
959
+ return createErrorResponse('action must be inspect or export_smoke.');
960
+ }
961
+ return this.exportReadiness.execute(args);
962
+ }
963
+ async handleVerifyDotnetProject(args) {
964
+ if (!['inspect', 'restore', 'build', 'run'].includes(String(args.action))) {
965
+ return createErrorResponse('action must be inspect, restore, build, or run.');
966
+ }
967
+ return this.dotnetWorkflow.execute(args);
968
+ }
969
+ async handleManageAddon(args) {
970
+ if (!['inspect', 'install', 'update', 'remove', 'enable', 'disable'].includes(String(args.action))) {
971
+ return createErrorResponse('action must be inspect, install, update, remove, enable, or disable.');
972
+ }
973
+ return this.addonManagement.execute(args);
974
+ }
975
+ async handleRenameFile(args) {
976
+ return this.fileIO.rename(args);
977
+ }
978
+ async handleManageResource(args) {
979
+ args = normalizeParameters(args || {});
980
+ if (!args.projectPath || !args.resourcePath || !args.action)
981
+ return createErrorResponse('projectPath, resourcePath, and action are required.');
982
+ return this.context.headlessOp('manage_resource', args, a => ({
983
+ projectPath: a.projectPath,
984
+ params: { resourcePath: a.resourcePath, action: a.action, ...(a.properties ? { properties: a.properties } : {}) },
985
+ }));
986
+ }
987
+ async handleValidateScript(args) {
988
+ return this.scriptValidation.validate(args);
989
+ }
990
+ async handleValidateScripts(args) {
991
+ args = normalizeParameters(args || {});
992
+ if (!args.projectPath)
993
+ return createErrorResponse('projectPath is required.');
994
+ if (!this.context.pathSecurity.isProjectPathAllowed(args.projectPath))
995
+ return createErrorResponse('Invalid path.');
996
+ const projectFile = join(args.projectPath, 'project.godot');
997
+ if (!existsSync(projectFile))
998
+ return createErrorResponse(`Not a valid Godot project: ${args.projectPath}`);
999
+ if (!this.context.getGodotPath()) {
1000
+ await this.context.detectGodotPath();
1001
+ if (!this.context.getGodotPath())
1002
+ return createErrorResponse('Could not find a valid Godot executable path');
1003
+ }
1004
+ let scope;
1005
+ let candidates;
1006
+ const explicit = Array.isArray(args.scriptPaths) && args.scriptPaths.length > 0;
1007
+ if (explicit) {
1008
+ scope = 'explicit';
1009
+ candidates = args.scriptPaths.map((p) => String(p));
1010
+ }
1011
+ else if (args.scope === undefined || args.scope === 'changed') {
1012
+ scope = 'changed';
1013
+ const changed = await this.context.projectSupport.listChangedGdFiles(args.projectPath);
1014
+ if (changed.error)
1015
+ return createErrorResponse(changed.error);
1016
+ candidates = changed.files;
1017
+ }
1018
+ else if (args.scope === 'all') {
1019
+ scope = 'all';
1020
+ candidates = this.context.projectSupport.listAllGdFiles(args.projectPath);
1021
+ }
1022
+ else {
1023
+ return createErrorResponse(`Invalid scope "${args.scope}". Use "changed" or "all", or pass scriptPaths.`);
1024
+ }
1025
+ const results = [];
1026
+ let filesWithErrors = 0;
1027
+ const toCheck = [];
1028
+ for (const rel of candidates) {
1029
+ if (!/\.gd$/i.test(rel) || !validatePath(rel)) {
1030
+ if (explicit)
1031
+ results.push({ scriptPath: rel, checked: false, error: 'Not a valid .gd path' });
1032
+ continue;
1033
+ }
1034
+ if (!existsSync(join(args.projectPath, rel))) {
1035
+ if (explicit)
1036
+ results.push({ scriptPath: rel, checked: false, error: 'Script does not exist' });
1037
+ continue;
1038
+ }
1039
+ toCheck.push(rel);
1040
+ }
1041
+ const MAX_BATCH = 60;
1042
+ if (toCheck.length > MAX_BATCH)
1043
+ return createErrorResponse(`Too many scripts to validate (${toCheck.length} > ${MAX_BATCH}). Narrow the scope or pass an explicit scriptPaths list.`);
1044
+ for (const rel of toCheck) {
1045
+ const check = await this.context.projectSupport.runGdScriptCheck(args.projectPath, join(args.projectPath, rel));
1046
+ if (!check.completed) {
1047
+ results.push({ scriptPath: rel, checked: false, error: check.error });
1048
+ }
1049
+ else {
1050
+ if (check.errors.length > 0)
1051
+ filesWithErrors++;
1052
+ results.push({ scriptPath: rel, checked: true, valid: check.errors.length === 0, errorCount: check.errors.length, errors: check.errors });
1053
+ }
1054
+ }
1055
+ return {
1056
+ content: [
1057
+ {
1058
+ type: 'text',
1059
+ text: JSON.stringify({
1060
+ scope,
1061
+ fileCount: results.length,
1062
+ filesWithErrors,
1063
+ allValid: filesWithErrors === 0 && results.every(r => r.checked),
1064
+ results,
1065
+ }, null, 2),
1066
+ },
1067
+ ],
1068
+ };
1069
+ }
1070
+ async handleCreateScript(args) {
1071
+ args = normalizeParameters(args || {});
1072
+ if (!args.projectPath || !args.scriptPath)
1073
+ return createErrorResponse('projectPath and scriptPath are required.');
1074
+ if (!this.context.pathSecurity.isProjectPathAllowed(args.projectPath) || !validatePath(args.scriptPath))
1075
+ return createErrorResponse('Invalid path.');
1076
+ const projectFile = join(args.projectPath, 'project.godot');
1077
+ if (!existsSync(projectFile))
1078
+ return createErrorResponse(`Not a valid Godot project: ${args.projectPath}`);
1079
+ try {
1080
+ const fullPath = this.projectRelativePath(args.projectPath, args.scriptPath);
1081
+ const dir = dirname(fullPath);
1082
+ if (!existsSync(dir))
1083
+ mkdirSync(dir, { recursive: true });
1084
+ let source = args.source;
1085
+ if (!source) {
1086
+ const ext = args.extends || 'Node';
1087
+ const lines = [`extends ${ext}`, ''];
1088
+ if (args.className)
1089
+ lines.splice(1, 0, `class_name ${args.className}`);
1090
+ if (args.methods && Array.isArray(args.methods)) {
1091
+ for (const m of args.methods) {
1092
+ lines.push('', `func ${m}():`);
1093
+ lines.push('\tpass');
1094
+ }
1095
+ }
1096
+ source = lines.join('\n') + '\n';
1097
+ }
1098
+ writeFileSync(fullPath, source, 'utf8');
1099
+ return { content: [{ type: 'text', text: `Script created at ${args.scriptPath}` }] };
1100
+ }
1101
+ catch (error) {
1102
+ return createErrorResponse(`create_script failed: ${errorMessage(error)}`);
1103
+ }
1104
+ }
1105
+ async handleManageSceneSignals(args) {
1106
+ args = normalizeParameters(args || {});
1107
+ if (!args.projectPath || !args.scenePath || !args.action)
1108
+ return createErrorResponse('projectPath, scenePath, and action are required.');
1109
+ return this.sceneOperations.run('manage_scene_signals', args, {
1110
+ scenePath: args.scenePath, action: args.action,
1111
+ ...(args.signalName ? { signalName: args.signalName } : {}),
1112
+ ...(args.sourcePath ? { sourcePath: args.sourcePath } : {}),
1113
+ ...(args.targetPath ? { targetPath: args.targetPath } : {}),
1114
+ ...(args.method ? { method: args.method } : {}),
1115
+ });
1116
+ }
1117
+ async handleManageLayers(args) {
1118
+ args = normalizeParameters(args || {});
1119
+ if (!args.projectPath || !args.action)
1120
+ return createErrorResponse('projectPath and action are required.');
1121
+ if (!this.context.pathSecurity.isProjectPathAllowed(args.projectPath))
1122
+ return createErrorResponse('Invalid path.');
1123
+ const projectFile = join(args.projectPath, 'project.godot');
1124
+ if (!existsSync(projectFile))
1125
+ return createErrorResponse(`Not a valid Godot project: ${args.projectPath}`);
1126
+ try {
1127
+ let content = readFileSync(projectFile, 'utf8');
1128
+ // Keys live inside [layer_names] as `<type>/layer_<n>`, so the section
1129
+ // header supplies the `layer_names/` prefix of the engine setting path.
1130
+ // Older versions wrote the prefix into the key as well, producing
1131
+ // `layer_names/layer_names/...`, which the engine never resolved; those
1132
+ // stale keys are still listed and are repaired by the next `set`.
1133
+ const sectionLayers = () => {
1134
+ const section = /\[layer_names\]([\s\S]*?)(?=\n\[|$)/.exec(content);
1135
+ if (!section)
1136
+ return [];
1137
+ const layers = [];
1138
+ const entry = /^(?:layer_names\/)?([\w_]+)\/layer_(\d+)\s*=\s*"([^"]*)"$/;
1139
+ for (const line of section[1].split('\n')) {
1140
+ const match = entry.exec(line.trim());
1141
+ if (match)
1142
+ layers.push({ type: match[1], layer: parseInt(match[2]), name: match[3] });
1143
+ }
1144
+ return layers;
1145
+ };
1146
+ if (args.action === 'list') {
1147
+ return { content: [{ type: 'text', text: JSON.stringify({ layers: sectionLayers() }, null, 2) }] };
1148
+ }
1149
+ else if (args.action === 'set') {
1150
+ if (!args.layerType || !args.layer || !args.name)
1151
+ return createErrorResponse('layerType, layer, and name are required for set.');
1152
+ const key = `${args.layerType}/layer_${args.layer}`;
1153
+ const settingLine = `${key}="${args.name}"`;
1154
+ const existingRegex = new RegExp(`^(?:layer_names\\/)?${key.replace(/\//g, '\\/')}\\s*=\\s*"[^"]*"$`, 'm');
1155
+ if (existingRegex.test(content)) {
1156
+ content = content.replace(existingRegex, settingLine);
1157
+ }
1158
+ else {
1159
+ if (!content.includes('[layer_names]'))
1160
+ content += '\n[layer_names]\n';
1161
+ content = content.replace('[layer_names]', `[layer_names]\n${settingLine}`);
1162
+ }
1163
+ writeFileSync(projectFile, content, 'utf8');
1164
+ return { content: [{ type: 'text', text: `Layer set: layer_names/${settingLine}` }] };
1165
+ }
1166
+ return createErrorResponse(`Unknown action: ${args.action}`);
1167
+ }
1168
+ catch (error) {
1169
+ return createErrorResponse(`manage_layers failed: ${errorMessage(error)}`);
1170
+ }
1171
+ }
1172
+ async handleManagePlugins(args) {
1173
+ args = normalizeParameters(args || {});
1174
+ if (!args.projectPath || !args.action)
1175
+ return createErrorResponse('projectPath and action are required.');
1176
+ if (!this.context.pathSecurity.isProjectPathAllowed(args.projectPath))
1177
+ return createErrorResponse('Invalid path.');
1178
+ const projectFile = join(args.projectPath, 'project.godot');
1179
+ if (!existsSync(projectFile))
1180
+ return createErrorResponse(`Not a valid Godot project: ${args.projectPath}`);
1181
+ try {
1182
+ let content = readFileSync(projectFile, 'utf8');
1183
+ if (args.action === 'list') {
1184
+ const enabledSetting = /\[editor_plugins\][\s\S]*?^enabled=PackedStringArray\(([^\n]*)\)/m.exec(content)?.[1] ?? '';
1185
+ const plugins = [...enabledSetting.matchAll(/"res:\/\/addons\/([^/]+)\/plugin\.cfg"/g)]
1186
+ .map(match => match[1]);
1187
+ const addonsDir = join(args.projectPath, 'addons');
1188
+ const available = [];
1189
+ if (existsSync(addonsDir)) {
1190
+ const entries = readdirSync(addonsDir, { withFileTypes: true });
1191
+ for (const e of entries) {
1192
+ if (e.isDirectory())
1193
+ available.push(e.name);
1194
+ }
1195
+ }
1196
+ return { content: [{ type: 'text', text: JSON.stringify({ enabled: plugins, available }, null, 2) }] };
1197
+ }
1198
+ else if (args.action === 'enable' || args.action === 'disable') {
1199
+ if (!args.pluginName)
1200
+ return createErrorResponse('pluginName is required.');
1201
+ const pluginPath = `res://addons/${args.pluginName}/plugin.cfg`;
1202
+ const sectionPattern = /\[editor_plugins\][\s\S]*?(?=\n\[|$)/;
1203
+ const section = sectionPattern.exec(content);
1204
+ const current = section
1205
+ ? [...section[0].matchAll(/"(res:\/\/addons\/[^"]+\/plugin\.cfg)"/g)].map(match => match[1])
1206
+ : [];
1207
+ const enabled = current.filter(value => value !== pluginPath);
1208
+ if (args.action === 'enable')
1209
+ enabled.push(pluginPath);
1210
+ const setting = `enabled=PackedStringArray(${[...new Set(enabled)].sort().map(value => JSON.stringify(value)).join(', ')})`;
1211
+ if (!section)
1212
+ content += `\n[editor_plugins]\n\n${setting}\n`;
1213
+ else {
1214
+ const replacement = /^enabled=.*$/m.test(section[0])
1215
+ ? section[0].replace(/^enabled=.*$/m, setting)
1216
+ : `${section[0].trimEnd()}\n${setting}\n`;
1217
+ content = content.slice(0, section.index) + replacement + content.slice(section.index + section[0].length);
1218
+ }
1219
+ writeFileSync(projectFile, content, 'utf8');
1220
+ return { content: [{ type: 'text', text: `Plugin ${args.pluginName} ${args.action}d.` }] };
1221
+ }
1222
+ return createErrorResponse(`Unknown action: ${args.action}`);
1223
+ }
1224
+ catch (error) {
1225
+ return createErrorResponse(`manage_plugins failed: ${errorMessage(error)}`);
1226
+ }
1227
+ }
1228
+ async handleManageShader(args) {
1229
+ args = normalizeParameters(args || {});
1230
+ if (!args.projectPath || !args.shaderPath || !args.action)
1231
+ return createErrorResponse('projectPath, shaderPath, and action are required.');
1232
+ if (!this.context.pathSecurity.isProjectPathAllowed(args.projectPath) || !validatePath(args.shaderPath))
1233
+ return createErrorResponse('Invalid path.');
1234
+ const projectFile = join(args.projectPath, 'project.godot');
1235
+ if (!existsSync(projectFile))
1236
+ return createErrorResponse(`Not a valid Godot project: ${args.projectPath}`);
1237
+ const fullPath = this.projectRelativePath(args.projectPath, args.shaderPath);
1238
+ try {
1239
+ if (args.action === 'read') {
1240
+ if (!existsSync(fullPath))
1241
+ return createErrorResponse(`Shader not found: ${args.shaderPath}`);
1242
+ const source = readFileSync(fullPath, 'utf8');
1243
+ return { content: [{ type: 'text', text: source }] };
1244
+ }
1245
+ else if (args.action === 'create') {
1246
+ const dir = dirname(fullPath);
1247
+ if (!existsSync(dir))
1248
+ mkdirSync(dir, { recursive: true });
1249
+ let source = args.source;
1250
+ if (!source) {
1251
+ const type = args.shaderType || 'spatial';
1252
+ source = `shader_type ${type};\n\nvoid fragment() {\n\t// Called for every pixel the material is visible on.\n}\n`;
1253
+ }
1254
+ writeFileSync(fullPath, source, 'utf8');
1255
+ return { content: [{ type: 'text', text: `Shader created at ${args.shaderPath}` }] };
1256
+ }
1257
+ return createErrorResponse(`Unknown action: ${args.action}`);
1258
+ }
1259
+ catch (error) {
1260
+ return createErrorResponse(`manage_shader failed: ${errorMessage(error)}`);
1261
+ }
1262
+ }
1263
+ async handleManageThemeResource(args) {
1264
+ args = normalizeParameters(args || {});
1265
+ if (!args.projectPath || !args.resourcePath || !args.action)
1266
+ return createErrorResponse('projectPath, resourcePath, and action are required.');
1267
+ return this.context.headlessOp('manage_theme_resource', args, a => ({
1268
+ projectPath: a.projectPath,
1269
+ params: { resourcePath: a.resourcePath, action: a.action, ...(a.properties ? { properties: a.properties } : {}) },
1270
+ }));
1271
+ }
1272
+ async handleSetMainScene(args) {
1273
+ args = normalizeParameters(args || {});
1274
+ if (!args.projectPath || !args.scenePath)
1275
+ return createErrorResponse('projectPath and scenePath are required.');
1276
+ if (!this.context.pathSecurity.isProjectPathAllowed(args.projectPath))
1277
+ return createErrorResponse('Invalid path.');
1278
+ const projectFile = join(args.projectPath, 'project.godot');
1279
+ if (!existsSync(projectFile))
1280
+ return createErrorResponse(`Not a valid Godot project: ${args.projectPath}`);
1281
+ try {
1282
+ let content = readFileSync(projectFile, 'utf8');
1283
+ const resPath = args.scenePath.startsWith('res://') ? args.scenePath : `res://${args.scenePath}`;
1284
+ const settingLine = `run/main_scene="${resPath}"`;
1285
+ const existingRegex = /run\/main_scene="[^"]*"/;
1286
+ if (existingRegex.test(content)) {
1287
+ content = content.replace(existingRegex, settingLine);
1288
+ }
1289
+ else {
1290
+ if (content.includes('[application]')) {
1291
+ content = content.replace('[application]', `[application]\n\n${settingLine}`);
1292
+ }
1293
+ else {
1294
+ content += `\n[application]\n\n${settingLine}\n`;
1295
+ }
1296
+ }
1297
+ writeFileSync(projectFile, content, 'utf8');
1298
+ return { content: [{ type: 'text', text: `Main scene set to ${resPath}` }] };
1299
+ }
1300
+ catch (error) {
1301
+ return createErrorResponse(`set_main_scene failed: ${errorMessage(error)}`);
1302
+ }
1303
+ }
1304
+ async handleManageSceneStructure(args) {
1305
+ args = normalizeParameters(args || {});
1306
+ if (!args.projectPath || !args.scenePath || !args.action || !args.nodePath)
1307
+ return createErrorResponse('projectPath, scenePath, action, and nodePath are required.');
1308
+ return this.sceneOperations.run('manage_scene_structure', args, {
1309
+ scenePath: args.scenePath, action: args.action, nodePath: args.nodePath,
1310
+ ...(args.newName ? { newName: args.newName } : {}),
1311
+ ...(args.newParentPath ? { newParentPath: args.newParentPath } : {}),
1312
+ });
1313
+ }
1314
+ async handleManageTranslations(args) {
1315
+ args = normalizeParameters(args || {});
1316
+ if (!args.projectPath || !args.action)
1317
+ return createErrorResponse('projectPath and action are required.');
1318
+ if (!this.context.pathSecurity.isProjectPathAllowed(args.projectPath))
1319
+ return createErrorResponse('Invalid path.');
1320
+ const projectFile = join(args.projectPath, 'project.godot');
1321
+ if (!existsSync(projectFile))
1322
+ return createErrorResponse(`Not a valid Godot project: ${args.projectPath}`);
1323
+ try {
1324
+ let content = readFileSync(projectFile, 'utf8');
1325
+ // The engine setting is `internationalization/locale/translations`, so the
1326
+ // key inside [internationalization] must be `locale/translations`. A bare
1327
+ // `translations=` key is never read by the engine; it is still matched here
1328
+ // so projects written by older versions list correctly and are repaired on
1329
+ // the next add.
1330
+ const listRegex = /^(?:locale\/)?translations=PackedStringArray\(([^)]*)\)$/m;
1331
+ if (args.action === 'list') {
1332
+ const match = listRegex.exec(content);
1333
+ const translations = match ? match[1].split(',').map(s => s.trim().replace(/"/g, '')).filter(Boolean) : [];
1334
+ return { content: [{ type: 'text', text: JSON.stringify({ translations }, null, 2) }] };
1335
+ }
1336
+ else if (args.action === 'add') {
1337
+ if (!args.translationPath)
1338
+ return createErrorResponse('translationPath is required.');
1339
+ const resPath = args.translationPath.startsWith('res://') ? args.translationPath : `res://${args.translationPath}`;
1340
+ const match = listRegex.exec(content);
1341
+ if (match) {
1342
+ const existing = match[1];
1343
+ const newVal = existing ? `${existing}, "${resPath}"` : `"${resPath}"`;
1344
+ content = content.replace(listRegex, `locale/translations=PackedStringArray(${newVal})`);
1345
+ }
1346
+ else {
1347
+ if (!content.includes('[internationalization]'))
1348
+ content += '\n[internationalization]\n';
1349
+ content = content.replace('[internationalization]', `[internationalization]\n\nlocale/translations=PackedStringArray("${resPath}")`);
1350
+ }
1351
+ writeFileSync(projectFile, content, 'utf8');
1352
+ return { content: [{ type: 'text', text: `Translation added: ${resPath}` }] };
1353
+ }
1354
+ else if (args.action === 'remove') {
1355
+ if (!args.translationPath)
1356
+ return createErrorResponse('translationPath is required.');
1357
+ const resPath = args.translationPath.startsWith('res://') ? args.translationPath : `res://${args.translationPath}`;
1358
+ content = content.replace(new RegExp(`,?\\s*"${resPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`), '');
1359
+ writeFileSync(projectFile, content, 'utf8');
1360
+ return { content: [{ type: 'text', text: `Translation removed: ${resPath}` }] };
1361
+ }
1362
+ return createErrorResponse(`Unknown action: ${args.action}`);
1363
+ }
1364
+ catch (error) {
1365
+ return createErrorResponse(`manage_translations failed: ${errorMessage(error)}`);
1366
+ }
1367
+ }
1368
+ async handleManageCiPipeline(args) {
1369
+ args = normalizeParameters(args || {});
1370
+ if (!args.projectPath || !args.action)
1371
+ return createErrorResponse('projectPath and action are required.');
1372
+ if (!this.context.pathSecurity.isProjectPathAllowed(args.projectPath))
1373
+ return createErrorResponse('Invalid path.');
1374
+ const projectFile = join(args.projectPath, 'project.godot');
1375
+ if (!existsSync(projectFile))
1376
+ return createErrorResponse(`Not a valid Godot project: ${args.projectPath}`);
1377
+ const workflowDir = join(args.projectPath, '.github', 'workflows');
1378
+ const workflowPath = join(workflowDir, 'godot-export.yml');
1379
+ try {
1380
+ if (args.action === 'read') {
1381
+ if (!existsSync(workflowPath))
1382
+ return createErrorResponse('No workflow file found at .github/workflows/godot-export.yml');
1383
+ const content = readFileSync(workflowPath, 'utf8');
1384
+ return { content: [{ type: 'text', text: content }] };
1385
+ }
1386
+ else if (args.action === 'create') {
1387
+ const godotVersion = args.godotVersion || '4.3-stable';
1388
+ const versionError = validateGodotExportVersion(godotVersion);
1389
+ if (versionError)
1390
+ return createErrorResponse(versionError);
1391
+ const platforms = args.platforms || ['linux'];
1392
+ if (!Array.isArray(platforms) || platforms.length === 0 || !platforms.every(platform => typeof platform === 'string' && CI_EXPORT_PLATFORMS.has(platform))) {
1393
+ return createErrorResponse('platforms must be a non-empty array containing only: windows, linux, macos, web.');
1394
+ }
1395
+ if (new Set(platforms).size !== platforms.length) {
1396
+ return createErrorResponse('platforms must not contain duplicates.');
1397
+ }
1398
+ if (!existsSync(workflowDir))
1399
+ mkdirSync(workflowDir, { recursive: true });
1400
+ const exportSteps = platforms.map((p) => ` - name: Export ${p}\n run: godot --headless --export-release "${p}" build/${p}/game`).join('\n');
1401
+ const workflow = `name: Godot Export\non:\n push:\n branches: [main]\n pull_request:\n branches: [main]\njobs:\n export:\n runs-on: ubuntu-latest\n container:\n image: barichello/godot-ci:${godotVersion}\n steps:\n - uses: actions/checkout@v4\n - name: Setup export templates\n run: |\n mkdir -p ~/.local/share/godot/export_templates/${godotVersion}\n mv /root/.local/share/godot/export_templates/${godotVersion}/* ~/.local/share/godot/export_templates/${godotVersion}/ || true\n${exportSteps}\n - uses: actions/upload-artifact@v4\n with:\n name: game-builds\n path: build/\n`;
1402
+ writeFileSync(workflowPath, workflow, 'utf8');
1403
+ return { content: [{ type: 'text', text: `CI pipeline created at .github/workflows/godot-export.yml for platforms: ${platforms.join(', ')}` }] };
1404
+ }
1405
+ return createErrorResponse(`Unknown action: ${args.action}`);
1406
+ }
1407
+ catch (error) {
1408
+ return createErrorResponse(`manage_ci_pipeline failed: ${errorMessage(error)}`);
1409
+ }
1410
+ }
1411
+ async handleManageDockerExport(args) {
1412
+ args = normalizeParameters(args || {});
1413
+ if (!args.projectPath || !args.action)
1414
+ return createErrorResponse('projectPath and action are required.');
1415
+ if (!this.context.pathSecurity.isProjectPathAllowed(args.projectPath))
1416
+ return createErrorResponse('Invalid path.');
1417
+ const projectFile = join(args.projectPath, 'project.godot');
1418
+ if (!existsSync(projectFile))
1419
+ return createErrorResponse(`Not a valid Godot project: ${args.projectPath}`);
1420
+ const dockerfilePath = join(args.projectPath, 'Dockerfile');
1421
+ try {
1422
+ if (args.action === 'read') {
1423
+ if (!existsSync(dockerfilePath))
1424
+ return createErrorResponse('No Dockerfile found in project root.');
1425
+ const content = readFileSync(dockerfilePath, 'utf8');
1426
+ return { content: [{ type: 'text', text: content }] };
1427
+ }
1428
+ else if (args.action === 'create') {
1429
+ const godotVersion = args.godotVersion || '4.3-stable';
1430
+ const baseImage = args.baseImage || 'ubuntu:22.04';
1431
+ let exportPreset = args.exportPreset || 'Linux/X11';
1432
+ const versionError = validateGodotExportVersion(godotVersion);
1433
+ if (versionError)
1434
+ return createErrorResponse(versionError);
1435
+ if (typeof baseImage !== 'string' || !DOCKER_BASE_IMAGES.has(baseImage)) {
1436
+ return createErrorResponse('baseImage must be one of: ubuntu:22.04, ubuntu:24.04.');
1437
+ }
1438
+ if (typeof exportPreset !== 'string' || !EXPORT_PRESET_NAME.test(exportPreset)) {
1439
+ return createErrorResponse('exportPreset may contain only letters, digits, spaces, _, ., /, and - (up to 128 characters).');
1440
+ }
1441
+ const exportPresetName = exportPreset;
1442
+ exportPreset = JSON.stringify(exportPreset).slice(1, -1);
1443
+ const dockerfile = `FROM ${baseImage}\n\nARG GODOT_VERSION=${godotVersion}\n\nRUN apt-get update && apt-get install -y \\\n wget unzip ca-certificates \\\n && rm -rf /var/lib/apt/lists/*\n\nRUN wget -q https://github.com/godotengine/godot/releases/download/\${GODOT_VERSION}/Godot_v\${GODOT_VERSION}_linux.x86_64.zip \\\n && unzip Godot_v\${GODOT_VERSION}_linux.x86_64.zip \\\n && mv Godot_v\${GODOT_VERSION}_linux.x86_64 /usr/local/bin/godot \\\n && rm Godot_v\${GODOT_VERSION}_linux.x86_64.zip\n\nRUN wget -q https://github.com/godotengine/godot/releases/download/\${GODOT_VERSION}/Godot_v\${GODOT_VERSION}_export_templates.tpz \\\n && mkdir -p /root/.local/share/godot/export_templates/\${GODOT_VERSION} \\\n && unzip Godot_v\${GODOT_VERSION}_export_templates.tpz \\\n && mv templates/* /root/.local/share/godot/export_templates/\${GODOT_VERSION}/ \\\n && rm -rf templates Godot_v\${GODOT_VERSION}_export_templates.tpz\n\nWORKDIR /game\nCOPY . .\n\nRUN mkdir -p build\nCMD ["godot", "--headless", "--export-release", "${exportPreset}", "build/game"]\n`;
1444
+ writeFileSync(dockerfilePath, dockerfile, 'utf8');
1445
+ return { content: [{ type: 'text', text: `Dockerfile created for headless Godot export (preset: ${exportPresetName})` }] };
1446
+ }
1447
+ return createErrorResponse(`Unknown action: ${args.action}`);
1448
+ }
1449
+ catch (error) {
1450
+ return createErrorResponse(`manage_docker_export failed: ${errorMessage(error)}`);
1451
+ }
1452
+ }
1453
+ /**
1454
+ * Handle the update_project_uids tool
1455
+ */
1456
+ async handleUpdateProjectUids(args) {
1457
+ // Normalize parameters to camelCase
1458
+ args = normalizeParameters(args);
1459
+ if (!args.projectPath) {
1460
+ return createErrorResponse('Project path is required');
1461
+ }
1462
+ if (!this.context.pathSecurity.isProjectPathAllowed(args.projectPath)) {
1463
+ return createErrorResponse('Invalid project path');
1464
+ }
1465
+ try {
1466
+ // Ensure godotPath is set
1467
+ if (!this.context.getGodotPath()) {
1468
+ await this.context.detectGodotPath();
1469
+ if (!this.context.getGodotPath()) {
1470
+ return createErrorResponse('Could not find a valid Godot executable path');
1471
+ }
1472
+ }
1473
+ // Check if the project directory exists and contains a project.godot file
1474
+ const projectFile = join(args.projectPath, 'project.godot');
1475
+ if (!existsSync(projectFile)) {
1476
+ return createErrorResponse(`Not a valid Godot project: ${args.projectPath}`);
1477
+ }
1478
+ // Get Godot version to check if UIDs are supported
1479
+ const { stdout: versionOutput } = await execFileAsync(this.context.getGodotPath(), ['--version'], GODOT_VERSION_OPTIONS);
1480
+ const version = versionOutput.trim();
1481
+ if (!isGodot44OrLater(version)) {
1482
+ return createErrorResponse(`UIDs are only supported in Godot 4.4 or later. Current version: ${version}`);
1483
+ }
1484
+ // The operation runs with --path at the project root, so res:// already
1485
+ // covers it. Passing the absolute OS path as project_path used to make
1486
+ // the operation scan res://<absolute-path> — nothing — and report
1487
+ // success while generating no UIDs.
1488
+ const result = await this.context.executeOperation('resave_resources', {}, args.projectPath);
1489
+ const failure = this.headlessFailure('Failed to update project UIDs', result);
1490
+ if (failure)
1491
+ return failure;
1492
+ const { stdout } = result;
1493
+ return {
1494
+ content: [
1495
+ {
1496
+ type: 'text',
1497
+ text: `Project UIDs updated successfully.\n\nOutput: ${stdout}`,
1498
+ },
1499
+ ],
1500
+ };
1501
+ }
1502
+ catch (error) {
1503
+ return createErrorResponse(`Failed to update project UIDs: ${errorMessage(error)}`);
1504
+ }
1505
+ }
1506
+ }