@skillcap/gdh 3.0.2 → 3.2.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.
- package/INSTALL-BUNDLE.json +1 -1
- package/README.md +1 -0
- package/RELEASE-SPAN-UPDATE-CONTRACTS.json +166 -0
- package/node_modules/@gdh/adapters/package.json +8 -8
- package/node_modules/@gdh/authoring/package.json +2 -2
- package/node_modules/@gdh/cli/dist/index.d.ts.map +1 -1
- package/node_modules/@gdh/cli/dist/index.js +195 -4
- package/node_modules/@gdh/cli/dist/index.js.map +1 -1
- package/node_modules/@gdh/cli/package.json +11 -10
- package/node_modules/@gdh/core/dist/bridge-substrate.d.ts +20 -0
- package/node_modules/@gdh/core/dist/bridge-substrate.d.ts.map +1 -0
- package/node_modules/@gdh/core/dist/bridge-substrate.js +40 -0
- package/node_modules/@gdh/core/dist/bridge-substrate.js.map +1 -0
- package/node_modules/@gdh/core/dist/index.d.ts +27 -32
- package/node_modules/@gdh/core/dist/index.d.ts.map +1 -1
- package/node_modules/@gdh/core/dist/index.js +15 -14
- package/node_modules/@gdh/core/dist/index.js.map +1 -1
- package/node_modules/@gdh/core/package.json +1 -1
- package/node_modules/@gdh/docs/dist/guidance.d.ts.map +1 -1
- package/node_modules/@gdh/docs/dist/guidance.js +29 -0
- package/node_modules/@gdh/docs/dist/guidance.js.map +1 -1
- package/node_modules/@gdh/docs/dist/templates/guidance/editor-bridge.md.tpl +28 -0
- package/node_modules/@gdh/docs/package.json +2 -2
- package/node_modules/@gdh/editor/dist/index.d.ts +226 -0
- package/node_modules/@gdh/editor/dist/index.d.ts.map +1 -0
- package/node_modules/@gdh/editor/dist/index.js +1380 -0
- package/node_modules/@gdh/editor/dist/index.js.map +1 -0
- package/node_modules/@gdh/editor/package.json +17 -0
- package/node_modules/@gdh/mcp/dist/index.d.ts +1 -1
- package/node_modules/@gdh/mcp/dist/index.d.ts.map +1 -1
- package/node_modules/@gdh/mcp/dist/index.js +392 -7
- package/node_modules/@gdh/mcp/dist/index.js.map +1 -1
- package/node_modules/@gdh/mcp/package.json +10 -8
- package/node_modules/@gdh/observability/package.json +2 -2
- package/node_modules/@gdh/runtime/dist/bridge-broker-contract.d.ts +2 -2
- package/node_modules/@gdh/runtime/dist/bridge-broker-contract.d.ts.map +1 -1
- package/node_modules/@gdh/runtime/dist/bridge-broker-contract.js +2 -37
- package/node_modules/@gdh/runtime/dist/bridge-broker-contract.js.map +1 -1
- package/node_modules/@gdh/runtime/dist/bridge-surface.d.ts +1 -1
- package/node_modules/@gdh/runtime/dist/bridge-surface.d.ts.map +1 -1
- package/node_modules/@gdh/runtime/dist/bridge-surface.js +396 -73
- package/node_modules/@gdh/runtime/dist/bridge-surface.js.map +1 -1
- package/node_modules/@gdh/runtime/dist/index.d.ts +2 -3
- package/node_modules/@gdh/runtime/dist/index.d.ts.map +1 -1
- package/node_modules/@gdh/runtime/dist/index.js +2 -3
- package/node_modules/@gdh/runtime/dist/index.js.map +1 -1
- package/node_modules/@gdh/runtime/package.json +3 -2
- package/node_modules/@gdh/scan/package.json +3 -3
- package/node_modules/@gdh/verify/package.json +7 -7
- package/package.json +13 -11
|
@@ -0,0 +1,1380 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import http from "node:http";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { definePackageBoundary, detectFatalGodotStderr, GDH_RUNTIME_BRIDGE_SURFACE_VERSION, normalizeBridgeTargetIdentity, resolveConfiguredGodotEditorBin, } from "@gdh/core";
|
|
7
|
+
const EDITOR_BRIDGE_STATE_DIRECTORY = "editor-bridge";
|
|
8
|
+
const EDITOR_BRIDGE_OPERATIONS_DIRECTORY = "operations";
|
|
9
|
+
const EDITOR_OPERATION_INPUT_FILE = "input.json";
|
|
10
|
+
const EDITOR_OPERATION_OUTPUT_FILE = "output.json";
|
|
11
|
+
const EDITOR_OPERATION_RUNNER_FILE = "runner.gd";
|
|
12
|
+
const EDITOR_ADOPTION_METADATA_FILE = "adopted-editor.json";
|
|
13
|
+
const EDITOR_ADOPTION_TOKEN_FILE = "adopted-editor.token";
|
|
14
|
+
const EDITOR_ADOPTION_HEARTBEAT_FILE = "adopted-editor.heartbeat";
|
|
15
|
+
const EDITOR_ADOPTION_PROTOCOL_VERSION = 1;
|
|
16
|
+
const EDITOR_ADOPTION_HEARTBEAT_STALE_AFTER_MS = 15_000;
|
|
17
|
+
const DEFAULT_EDITOR_OPERATION_TIMEOUT_MS = 30_000;
|
|
18
|
+
const DEFAULT_RETAIN_OPERATION_ARTIFACTS = 20;
|
|
19
|
+
export const editorPackage = definePackageBoundary({
|
|
20
|
+
name: "@gdh/editor",
|
|
21
|
+
layer: "capability",
|
|
22
|
+
responsibility: "Editor Bridge session coordination and Godot-native editor operation execution.",
|
|
23
|
+
allowedInternalDependencies: ["@gdh/core"],
|
|
24
|
+
});
|
|
25
|
+
export function resolveEditorBridgePaths(input) {
|
|
26
|
+
const targetRootPath = path.resolve(input.targetPath);
|
|
27
|
+
const godotProjectRootPath = path.resolve(targetRootPath, input.godotProjectRootPath ?? input.projectConfig?.primaryGodotProjectPath ?? ".");
|
|
28
|
+
const stateRootPath = path.resolve(input.stateRootPath ?? path.join(targetRootPath, ".gdh-state"));
|
|
29
|
+
const editorBridgeDirectory = path.join(stateRootPath, EDITOR_BRIDGE_STATE_DIRECTORY);
|
|
30
|
+
const operationsDirectory = path.join(editorBridgeDirectory, EDITOR_BRIDGE_OPERATIONS_DIRECTORY);
|
|
31
|
+
const adoptedMetadataPath = path.join(editorBridgeDirectory, EDITOR_ADOPTION_METADATA_FILE);
|
|
32
|
+
const adoptedTokenPath = path.join(editorBridgeDirectory, EDITOR_ADOPTION_TOKEN_FILE);
|
|
33
|
+
const adoptedHeartbeatPath = path.join(editorBridgeDirectory, EDITOR_ADOPTION_HEARTBEAT_FILE);
|
|
34
|
+
const identity = normalizeBridgeTargetIdentity({
|
|
35
|
+
targetRootPath,
|
|
36
|
+
godotProjectRootPath,
|
|
37
|
+
stateRootPath,
|
|
38
|
+
});
|
|
39
|
+
return {
|
|
40
|
+
targetRootPath,
|
|
41
|
+
godotProjectRootPath,
|
|
42
|
+
stateRootPath,
|
|
43
|
+
editorBridgeDirectory,
|
|
44
|
+
operationsDirectory,
|
|
45
|
+
adoptedMetadataPath,
|
|
46
|
+
adoptedTokenPath,
|
|
47
|
+
adoptedHeartbeatPath,
|
|
48
|
+
identity,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export async function inspectEditorBridgeSession(input) {
|
|
52
|
+
const paths = resolveEditorBridgePaths(input);
|
|
53
|
+
const mode = input.mode ?? "auto";
|
|
54
|
+
if (mode === "disabled") {
|
|
55
|
+
return {
|
|
56
|
+
targetPath: paths.targetRootPath,
|
|
57
|
+
godotProjectRootPath: paths.godotProjectRootPath,
|
|
58
|
+
mode,
|
|
59
|
+
state: "blocked",
|
|
60
|
+
kind: null,
|
|
61
|
+
summary: "Editor Bridge is disabled by session mode.",
|
|
62
|
+
reasons: ["editor_bridge_disabled"],
|
|
63
|
+
identity: paths.identity,
|
|
64
|
+
adoptedEditor: null,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const adopted = await inspectAdoptedEditor(paths);
|
|
68
|
+
if (adopted.state === "ready") {
|
|
69
|
+
return {
|
|
70
|
+
targetPath: paths.targetRootPath,
|
|
71
|
+
godotProjectRootPath: paths.godotProjectRootPath,
|
|
72
|
+
mode,
|
|
73
|
+
state: "ready",
|
|
74
|
+
kind: "adopted",
|
|
75
|
+
summary: "Adopted editor session is available for the exact target/worktree.",
|
|
76
|
+
reasons: ["adopted_editor_available"],
|
|
77
|
+
identity: paths.identity,
|
|
78
|
+
adoptedEditor: adopted.summary,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
if (mode === "adopt_only") {
|
|
82
|
+
return {
|
|
83
|
+
targetPath: paths.targetRootPath,
|
|
84
|
+
godotProjectRootPath: paths.godotProjectRootPath,
|
|
85
|
+
mode,
|
|
86
|
+
state: "unavailable",
|
|
87
|
+
kind: null,
|
|
88
|
+
summary: adopted.summaryText,
|
|
89
|
+
reasons: adopted.reasons,
|
|
90
|
+
identity: paths.identity,
|
|
91
|
+
adoptedEditor: null,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const godotEditorBin = await resolveGodotEditorBin(input);
|
|
95
|
+
if (godotEditorBin === null) {
|
|
96
|
+
return {
|
|
97
|
+
targetPath: paths.targetRootPath,
|
|
98
|
+
godotProjectRootPath: paths.godotProjectRootPath,
|
|
99
|
+
mode,
|
|
100
|
+
state: "blocked",
|
|
101
|
+
kind: null,
|
|
102
|
+
summary: "Managed headless editor is unavailable because no Godot editor binary is configured.",
|
|
103
|
+
reasons: [...adopted.reasons, "godot_editor_bin_unavailable"],
|
|
104
|
+
identity: paths.identity,
|
|
105
|
+
adoptedEditor: null,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
targetPath: paths.targetRootPath,
|
|
110
|
+
godotProjectRootPath: paths.godotProjectRootPath,
|
|
111
|
+
mode,
|
|
112
|
+
state: "ready",
|
|
113
|
+
kind: "managed_headless",
|
|
114
|
+
summary: "Managed headless editor session is available for editor operations.",
|
|
115
|
+
reasons: [...adopted.reasons, "managed_headless_editor_available"],
|
|
116
|
+
identity: paths.identity,
|
|
117
|
+
adoptedEditor: null,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function validateEditorOperation(operation) {
|
|
121
|
+
const kind = readPayloadString(operation["kind"]);
|
|
122
|
+
const help = createEditorOperationHelp(kind);
|
|
123
|
+
if (kind === "") {
|
|
124
|
+
const typeValue = readPayloadString(operation["type"]);
|
|
125
|
+
const operationHelp = typeValue === "" ? help : createEditorOperationHelp(typeValue);
|
|
126
|
+
return {
|
|
127
|
+
summary: typeValue === ""
|
|
128
|
+
? `Editor operation payload is missing "kind". Use operation.kind, for example ${JSON.stringify(help.examples[0])}.`
|
|
129
|
+
: `Editor operation payload used "type": "${typeValue}", but GDH dispatches editor operations with "kind". Use ${JSON.stringify(operationHelp.examples[0])}.`,
|
|
130
|
+
reasons: typeValue === ""
|
|
131
|
+
? ["operation_kind_missing"]
|
|
132
|
+
: ["operation_kind_missing", "operation_type_unsupported"],
|
|
133
|
+
help: operationHelp,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
if (!KNOWN_EDITOR_OPERATION_KINDS.has(kind)) {
|
|
137
|
+
return {
|
|
138
|
+
summary: `Unsupported editor operation kind "${kind}". Use one of: ${[
|
|
139
|
+
...KNOWN_EDITOR_OPERATION_KINDS,
|
|
140
|
+
].join(", ")}.`,
|
|
141
|
+
reasons: ["operation_unsupported"],
|
|
142
|
+
help,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
const missingField = requiredFieldForOperation(kind, operation);
|
|
146
|
+
if (missingField !== null) {
|
|
147
|
+
return {
|
|
148
|
+
summary: `Editor operation "${kind}" requires "${missingField}". Example: ${JSON.stringify(createEditorOperationHelp(kind).examples[0])}.`,
|
|
149
|
+
reasons: [`${toSnakeCase(missingField)}_missing`],
|
|
150
|
+
help: createEditorOperationHelp(kind),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
if (kind === "batch.apply") {
|
|
154
|
+
const operations = operation["operations"];
|
|
155
|
+
if (!Array.isArray(operations)) {
|
|
156
|
+
return {
|
|
157
|
+
summary: 'Editor operation "batch.apply" requires "operations" as an array of operation payloads.',
|
|
158
|
+
reasons: ["batch_operations_invalid"],
|
|
159
|
+
help,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
for (const child of operations) {
|
|
163
|
+
if (child === null || Array.isArray(child) || typeof child !== "object") {
|
|
164
|
+
return {
|
|
165
|
+
summary: 'Editor operation "batch.apply" children must be operation objects.',
|
|
166
|
+
reasons: ["batch_child_operation_invalid"],
|
|
167
|
+
help,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
const childValidation = validateEditorOperation(child);
|
|
171
|
+
if (childValidation !== null)
|
|
172
|
+
return childValidation;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
const KNOWN_EDITOR_OPERATION_KINDS = new Set([
|
|
178
|
+
"scene.create",
|
|
179
|
+
"scene.tree",
|
|
180
|
+
"scene.save",
|
|
181
|
+
"node.add",
|
|
182
|
+
"node.remove",
|
|
183
|
+
"node.reparent",
|
|
184
|
+
"node.duplicate",
|
|
185
|
+
"node.set_property",
|
|
186
|
+
"node.get_property",
|
|
187
|
+
"node.inspect",
|
|
188
|
+
"resource.create",
|
|
189
|
+
"resource.read",
|
|
190
|
+
"resource.properties",
|
|
191
|
+
"resource.set_property",
|
|
192
|
+
"script.attach",
|
|
193
|
+
"class.search",
|
|
194
|
+
"class.info",
|
|
195
|
+
"editor.state",
|
|
196
|
+
"batch.apply",
|
|
197
|
+
]);
|
|
198
|
+
function requiredFieldForOperation(kind, operation) {
|
|
199
|
+
const require = (...fields) => fields.find((field) => readPayloadString(operation[field]) === "") ?? null;
|
|
200
|
+
switch (kind) {
|
|
201
|
+
case "scene.create":
|
|
202
|
+
case "scene.tree":
|
|
203
|
+
case "scene.save":
|
|
204
|
+
return require("scenePath");
|
|
205
|
+
case "node.add":
|
|
206
|
+
return require("scenePath", "nodeType", "nodeName");
|
|
207
|
+
case "node.remove":
|
|
208
|
+
case "node.reparent":
|
|
209
|
+
case "node.duplicate":
|
|
210
|
+
return require("scenePath", "nodePath");
|
|
211
|
+
case "node.set_property":
|
|
212
|
+
case "node.get_property":
|
|
213
|
+
return require("scenePath", "nodePath", "propertyName");
|
|
214
|
+
case "node.inspect":
|
|
215
|
+
return require("scenePath");
|
|
216
|
+
case "resource.create":
|
|
217
|
+
return require("resourcePath", "resourceType");
|
|
218
|
+
case "resource.read":
|
|
219
|
+
case "resource.properties":
|
|
220
|
+
return require("resourcePath");
|
|
221
|
+
case "resource.set_property":
|
|
222
|
+
return require("resourcePath", "propertyName");
|
|
223
|
+
case "script.attach":
|
|
224
|
+
return require("scenePath", "nodePath", "scriptPath");
|
|
225
|
+
case "class.info":
|
|
226
|
+
return require("className");
|
|
227
|
+
default:
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function createEditorOperationHelp(kind) {
|
|
232
|
+
const example = editorOperationExample(kind);
|
|
233
|
+
return {
|
|
234
|
+
usage: "Use editor.operation.run with an operation object that contains a kind field, or prefer focused MCP tools such as editor.state, editor.scene.tree, editor.resource.read, editor.resource.properties, editor.node.inspect, editor.class.search, and editor.class.info.",
|
|
235
|
+
helpCommand: 'gdh editor operation run --help; example: gdh editor operation run --input-json \'{"kind":"class.search","query":"Label"}\'',
|
|
236
|
+
examples: [example],
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
function editorOperationExample(kind) {
|
|
240
|
+
switch (kind) {
|
|
241
|
+
case "scene.tree":
|
|
242
|
+
return { kind: "scene.tree", scenePath: "res://path/to/scene.tscn", maxDepth: 3 };
|
|
243
|
+
case "node.inspect":
|
|
244
|
+
return {
|
|
245
|
+
kind: "node.inspect",
|
|
246
|
+
scenePath: "res://path/to/scene.tscn",
|
|
247
|
+
nodePath: ".",
|
|
248
|
+
includePropertyList: true,
|
|
249
|
+
propertyLimit: 40,
|
|
250
|
+
};
|
|
251
|
+
case "resource.read":
|
|
252
|
+
return {
|
|
253
|
+
kind: "resource.read",
|
|
254
|
+
resourcePath: "res://path/to/resource.tres",
|
|
255
|
+
propertyNames: ["name"],
|
|
256
|
+
};
|
|
257
|
+
case "resource.properties":
|
|
258
|
+
return { kind: "resource.properties", resourcePath: "res://path/to/resource.tres" };
|
|
259
|
+
case "class.search":
|
|
260
|
+
return { kind: "class.search", query: "Label" };
|
|
261
|
+
case "class.info":
|
|
262
|
+
return { kind: "class.info", className: "Label", propertyQuery: "text" };
|
|
263
|
+
case "editor.state":
|
|
264
|
+
return { kind: "editor.state" };
|
|
265
|
+
default:
|
|
266
|
+
return { kind: "editor.state" };
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
function readPayloadString(value) {
|
|
270
|
+
return typeof value === "string" ? value : "";
|
|
271
|
+
}
|
|
272
|
+
function toSnakeCase(value) {
|
|
273
|
+
return value.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
|
|
274
|
+
}
|
|
275
|
+
export async function runEditorOperation(input) {
|
|
276
|
+
const paths = resolveEditorBridgePaths(input);
|
|
277
|
+
const operationValidation = validateEditorOperation(input.operation);
|
|
278
|
+
if (operationValidation !== null) {
|
|
279
|
+
return {
|
|
280
|
+
targetPath: paths.targetRootPath,
|
|
281
|
+
state: "failed",
|
|
282
|
+
summary: operationValidation.summary,
|
|
283
|
+
reasons: operationValidation.reasons,
|
|
284
|
+
session: {
|
|
285
|
+
targetPath: paths.targetRootPath,
|
|
286
|
+
godotProjectRootPath: paths.godotProjectRootPath,
|
|
287
|
+
mode: input.mode ?? "auto",
|
|
288
|
+
state: "blocked",
|
|
289
|
+
kind: null,
|
|
290
|
+
summary: "Editor operation was rejected before session startup.",
|
|
291
|
+
reasons: operationValidation.reasons,
|
|
292
|
+
identity: paths.identity,
|
|
293
|
+
adoptedEditor: null,
|
|
294
|
+
},
|
|
295
|
+
operation: input.operation,
|
|
296
|
+
output: null,
|
|
297
|
+
help: operationValidation.help,
|
|
298
|
+
artifacts: null,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
const session = await inspectEditorBridgeSession(input);
|
|
302
|
+
if (session.state !== "ready" || session.kind === null) {
|
|
303
|
+
return {
|
|
304
|
+
targetPath: paths.targetRootPath,
|
|
305
|
+
state: "blocked",
|
|
306
|
+
summary: session.summary,
|
|
307
|
+
reasons: session.reasons,
|
|
308
|
+
session,
|
|
309
|
+
operation: input.operation,
|
|
310
|
+
output: null,
|
|
311
|
+
help: null,
|
|
312
|
+
artifacts: null,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
if (session.kind === "adopted") {
|
|
316
|
+
return await runAdoptedEditorOperation({ input, paths, session });
|
|
317
|
+
}
|
|
318
|
+
const godotEditorBin = await resolveGodotEditorBin(input);
|
|
319
|
+
if (godotEditorBin === null) {
|
|
320
|
+
return {
|
|
321
|
+
targetPath: paths.targetRootPath,
|
|
322
|
+
state: "blocked",
|
|
323
|
+
summary: "Managed headless editor is unavailable because no Godot editor binary is configured.",
|
|
324
|
+
reasons: ["godot_editor_bin_unavailable"],
|
|
325
|
+
session,
|
|
326
|
+
operation: input.operation,
|
|
327
|
+
output: null,
|
|
328
|
+
help: null,
|
|
329
|
+
artifacts: null,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
const artifacts = await createOperationArtifacts({
|
|
333
|
+
paths,
|
|
334
|
+
operation: input.operation,
|
|
335
|
+
retainOperationArtifacts: input.retainOperationArtifacts,
|
|
336
|
+
includeRunner: true,
|
|
337
|
+
});
|
|
338
|
+
if (artifacts.runnerPath === null) {
|
|
339
|
+
throw new Error("Expected headless editor operation artifacts to include a runner path.");
|
|
340
|
+
}
|
|
341
|
+
await fs.writeFile(artifacts.runnerPath, renderEditorOperationRunner(), "utf8");
|
|
342
|
+
const processResult = await (input.spawnEditorProcess ?? spawnEditorProcess)({
|
|
343
|
+
command: godotEditorBin,
|
|
344
|
+
args: [
|
|
345
|
+
"--headless",
|
|
346
|
+
"--editor",
|
|
347
|
+
"--path",
|
|
348
|
+
paths.godotProjectRootPath,
|
|
349
|
+
"--script",
|
|
350
|
+
artifacts.runnerPath,
|
|
351
|
+
],
|
|
352
|
+
cwd: paths.godotProjectRootPath,
|
|
353
|
+
env: {
|
|
354
|
+
...process.env,
|
|
355
|
+
GDH_EDITOR_OPERATION_INPUT: artifacts.inputPath,
|
|
356
|
+
GDH_EDITOR_OPERATION_OUTPUT: artifacts.outputPath,
|
|
357
|
+
},
|
|
358
|
+
timeoutMs: input.timeoutMs ?? DEFAULT_EDITOR_OPERATION_TIMEOUT_MS,
|
|
359
|
+
});
|
|
360
|
+
await fs.writeFile(artifacts.stdoutPath, processResult.stdout, "utf8");
|
|
361
|
+
await fs.writeFile(artifacts.stderrPath, processResult.stderr, "utf8");
|
|
362
|
+
const runnerOutput = await readRunnerOutput(artifacts.outputPath);
|
|
363
|
+
if (runnerOutput === null) {
|
|
364
|
+
const fatality = detectFatalGodotStderr({
|
|
365
|
+
exitCode: processResult.exitCode ?? 1,
|
|
366
|
+
stderr: processResult.stderr,
|
|
367
|
+
});
|
|
368
|
+
return {
|
|
369
|
+
targetPath: paths.targetRootPath,
|
|
370
|
+
state: "failed",
|
|
371
|
+
summary: fatality.detected || processResult.exitCode !== 0
|
|
372
|
+
? "Editor operation failed before producing structured output."
|
|
373
|
+
: "Editor operation completed without producing structured output.",
|
|
374
|
+
reasons: fatality.detected
|
|
375
|
+
? ["godot_editor_stderr_fatal"]
|
|
376
|
+
: processResult.exitCode !== 0
|
|
377
|
+
? ["godot_editor_process_failed"]
|
|
378
|
+
: ["editor_operation_output_missing"],
|
|
379
|
+
session,
|
|
380
|
+
operation: input.operation,
|
|
381
|
+
output: null,
|
|
382
|
+
help: null,
|
|
383
|
+
artifacts,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
const ok = runnerOutput.ok === true;
|
|
387
|
+
return {
|
|
388
|
+
targetPath: paths.targetRootPath,
|
|
389
|
+
state: ok ? "ok" : "failed",
|
|
390
|
+
summary: runnerOutput.summary ?? (ok ? "Editor operation completed." : "Editor operation failed."),
|
|
391
|
+
reasons: runnerOutput.reasons ?? [],
|
|
392
|
+
session,
|
|
393
|
+
operation: input.operation,
|
|
394
|
+
output: runnerOutput.output ?? null,
|
|
395
|
+
help: runnerOutput.help ?? null,
|
|
396
|
+
artifacts,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
async function runAdoptedEditorOperation(input) {
|
|
400
|
+
const adopted = await inspectAdoptedEditor(input.paths);
|
|
401
|
+
if (adopted.state !== "ready") {
|
|
402
|
+
return {
|
|
403
|
+
targetPath: input.paths.targetRootPath,
|
|
404
|
+
state: "blocked",
|
|
405
|
+
summary: adopted.summaryText,
|
|
406
|
+
reasons: adopted.reasons,
|
|
407
|
+
session: input.session,
|
|
408
|
+
operation: input.input.operation,
|
|
409
|
+
output: null,
|
|
410
|
+
help: null,
|
|
411
|
+
artifacts: null,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
const artifacts = await createOperationArtifacts({
|
|
415
|
+
paths: input.paths,
|
|
416
|
+
operation: input.input.operation,
|
|
417
|
+
retainOperationArtifacts: input.input.retainOperationArtifacts,
|
|
418
|
+
includeRunner: false,
|
|
419
|
+
});
|
|
420
|
+
const runnerOutput = await (input.input.invokeAdoptedEditor ?? invokeAdoptedEditorOperation)({
|
|
421
|
+
metadata: adopted.metadata,
|
|
422
|
+
token: adopted.token,
|
|
423
|
+
identity: input.paths.identity,
|
|
424
|
+
operation: input.input.operation,
|
|
425
|
+
timeoutMs: input.input.timeoutMs ?? DEFAULT_EDITOR_OPERATION_TIMEOUT_MS,
|
|
426
|
+
}).catch(async (error) => {
|
|
427
|
+
const summary = error instanceof Error ? error.message : "Adopted editor operation request failed.";
|
|
428
|
+
await fs.writeFile(artifacts.stderrPath, `${summary}\n`, "utf8");
|
|
429
|
+
return {
|
|
430
|
+
ok: false,
|
|
431
|
+
summary,
|
|
432
|
+
reasons: ["adopted_editor_request_failed"],
|
|
433
|
+
output: null,
|
|
434
|
+
};
|
|
435
|
+
});
|
|
436
|
+
await fs.writeFile(artifacts.outputPath, `${JSON.stringify(runnerOutput, null, 2)}\n`, "utf8");
|
|
437
|
+
const ok = runnerOutput.ok === true;
|
|
438
|
+
return {
|
|
439
|
+
targetPath: input.paths.targetRootPath,
|
|
440
|
+
state: ok ? "ok" : "failed",
|
|
441
|
+
summary: runnerOutput.summary ?? (ok ? "Editor operation completed." : "Editor operation failed."),
|
|
442
|
+
reasons: runnerOutput.reasons ?? [],
|
|
443
|
+
session: input.session,
|
|
444
|
+
operation: input.input.operation,
|
|
445
|
+
output: runnerOutput.output ?? null,
|
|
446
|
+
help: runnerOutput.help ?? null,
|
|
447
|
+
artifacts,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
async function inspectAdoptedEditor(paths) {
|
|
451
|
+
const metadata = await readAdoptedEditorMetadata(paths.adoptedMetadataPath);
|
|
452
|
+
if (metadata === null) {
|
|
453
|
+
return adoptedEditorUnavailable("No adopted editor metadata is registered.", [
|
|
454
|
+
"adopted_editor_not_found",
|
|
455
|
+
]);
|
|
456
|
+
}
|
|
457
|
+
const reasons = validateAdoptedEditorMetadata(paths, metadata);
|
|
458
|
+
const token = await readTrimmedFile(paths.adoptedTokenPath);
|
|
459
|
+
if (token === null)
|
|
460
|
+
reasons.push("adopted_editor_token_missing");
|
|
461
|
+
if (metadata.pid !== null && !isProcessAlive(metadata.pid)) {
|
|
462
|
+
reasons.push("adopted_editor_process_not_alive");
|
|
463
|
+
}
|
|
464
|
+
const heartbeatMs = Date.parse(metadata.lastHeartbeatAt);
|
|
465
|
+
if (!Number.isFinite(heartbeatMs) ||
|
|
466
|
+
Date.now() - heartbeatMs > EDITOR_ADOPTION_HEARTBEAT_STALE_AFTER_MS) {
|
|
467
|
+
reasons.push("adopted_editor_heartbeat_stale");
|
|
468
|
+
}
|
|
469
|
+
if (reasons.length > 0 || token === null) {
|
|
470
|
+
return adoptedEditorUnavailable("Registered adopted editor session is stale, incompatible, or incomplete.", reasons.length > 0 ? reasons : ["adopted_editor_unavailable"]);
|
|
471
|
+
}
|
|
472
|
+
return {
|
|
473
|
+
state: "ready",
|
|
474
|
+
reasons: ["adopted_editor_available"],
|
|
475
|
+
summaryText: "Adopted editor session is available for the exact target/worktree.",
|
|
476
|
+
metadata,
|
|
477
|
+
token,
|
|
478
|
+
summary: summarizeAdoptedEditor(metadata),
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
function validateAdoptedEditorMetadata(paths, metadata) {
|
|
482
|
+
const reasons = [];
|
|
483
|
+
if (metadata.protocolVersion !== EDITOR_ADOPTION_PROTOCOL_VERSION) {
|
|
484
|
+
reasons.push("adopted_editor_protocol_mismatch");
|
|
485
|
+
}
|
|
486
|
+
if (metadata.bridgeSurfaceVersion !== GDH_RUNTIME_BRIDGE_SURFACE_VERSION) {
|
|
487
|
+
reasons.push("adopted_editor_surface_version_mismatch");
|
|
488
|
+
}
|
|
489
|
+
if (metadata.host !== "127.0.0.1") {
|
|
490
|
+
reasons.push("adopted_editor_host_not_loopback");
|
|
491
|
+
}
|
|
492
|
+
if (!Number.isInteger(metadata.port) || metadata.port <= 0 || metadata.port > 65_535) {
|
|
493
|
+
reasons.push("adopted_editor_port_invalid");
|
|
494
|
+
}
|
|
495
|
+
if (metadata.metadataPath !== paths.adoptedMetadataPath) {
|
|
496
|
+
reasons.push("adopted_editor_metadata_path_mismatch");
|
|
497
|
+
}
|
|
498
|
+
if (metadata.tokenFilePath !== paths.adoptedTokenPath) {
|
|
499
|
+
reasons.push("adopted_editor_token_path_mismatch");
|
|
500
|
+
}
|
|
501
|
+
if (metadata.heartbeatPath !== paths.adoptedHeartbeatPath) {
|
|
502
|
+
reasons.push("adopted_editor_heartbeat_path_mismatch");
|
|
503
|
+
}
|
|
504
|
+
if (metadata.targetIdentity.identityKey !== paths.identity.identityKey) {
|
|
505
|
+
reasons.push("adopted_editor_identity_mismatch");
|
|
506
|
+
}
|
|
507
|
+
return reasons;
|
|
508
|
+
}
|
|
509
|
+
function adoptedEditorUnavailable(summaryText, reasons) {
|
|
510
|
+
return {
|
|
511
|
+
state: "unavailable",
|
|
512
|
+
reasons,
|
|
513
|
+
summaryText,
|
|
514
|
+
metadata: null,
|
|
515
|
+
token: null,
|
|
516
|
+
summary: null,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
function summarizeAdoptedEditor(metadata) {
|
|
520
|
+
return {
|
|
521
|
+
host: metadata.host,
|
|
522
|
+
port: metadata.port,
|
|
523
|
+
pid: metadata.pid,
|
|
524
|
+
metadataPath: metadata.metadataPath,
|
|
525
|
+
tokenFilePath: metadata.tokenFilePath,
|
|
526
|
+
heartbeatPath: metadata.heartbeatPath,
|
|
527
|
+
startedAt: metadata.startedAt,
|
|
528
|
+
lastHeartbeatAt: metadata.lastHeartbeatAt,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
async function createOperationArtifacts(input) {
|
|
532
|
+
await fs.mkdir(input.paths.operationsDirectory, { recursive: true });
|
|
533
|
+
await pruneOperationArtifacts(input.paths.operationsDirectory, input.retainOperationArtifacts);
|
|
534
|
+
const operationId = randomUUID();
|
|
535
|
+
const operationDirectory = path.join(input.paths.operationsDirectory, operationId);
|
|
536
|
+
await fs.mkdir(operationDirectory, { recursive: true });
|
|
537
|
+
const inputPath = path.join(operationDirectory, EDITOR_OPERATION_INPUT_FILE);
|
|
538
|
+
const outputPath = path.join(operationDirectory, EDITOR_OPERATION_OUTPUT_FILE);
|
|
539
|
+
const runnerPath = input.includeRunner
|
|
540
|
+
? path.join(operationDirectory, EDITOR_OPERATION_RUNNER_FILE)
|
|
541
|
+
: null;
|
|
542
|
+
const stdoutPath = path.join(operationDirectory, "stdout.log");
|
|
543
|
+
const stderrPath = path.join(operationDirectory, "stderr.log");
|
|
544
|
+
await fs.writeFile(inputPath, `${JSON.stringify({ operation: input.operation }, null, 2)}\n`, "utf8");
|
|
545
|
+
await fs.writeFile(stdoutPath, "", "utf8");
|
|
546
|
+
await fs.writeFile(stderrPath, "", "utf8");
|
|
547
|
+
return {
|
|
548
|
+
operationId,
|
|
549
|
+
directory: operationDirectory,
|
|
550
|
+
inputPath,
|
|
551
|
+
outputPath,
|
|
552
|
+
runnerPath,
|
|
553
|
+
stdoutPath,
|
|
554
|
+
stderrPath,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
async function invokeAdoptedEditorOperation(input) {
|
|
558
|
+
const body = JSON.stringify({
|
|
559
|
+
id: `editor-operation-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
560
|
+
protocolVersion: EDITOR_ADOPTION_PROTOCOL_VERSION,
|
|
561
|
+
targetIdentity: input.identity,
|
|
562
|
+
operation: input.operation,
|
|
563
|
+
});
|
|
564
|
+
return await new Promise((resolve, reject) => {
|
|
565
|
+
const request = http.request({
|
|
566
|
+
host: input.metadata.host,
|
|
567
|
+
port: input.metadata.port,
|
|
568
|
+
method: "POST",
|
|
569
|
+
path: "/operation",
|
|
570
|
+
timeout: input.timeoutMs,
|
|
571
|
+
headers: {
|
|
572
|
+
authorization: `Bearer ${input.token}`,
|
|
573
|
+
"content-type": "application/json",
|
|
574
|
+
"content-length": Buffer.byteLength(body),
|
|
575
|
+
},
|
|
576
|
+
}, (response) => {
|
|
577
|
+
const chunks = [];
|
|
578
|
+
response.on("data", (chunk) => {
|
|
579
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
580
|
+
});
|
|
581
|
+
response.on("end", () => {
|
|
582
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
583
|
+
if (response.statusCode !== 200) {
|
|
584
|
+
reject(new Error(`Adopted editor request failed with HTTP ${response.statusCode}: ${text}`));
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
resolve(JSON.parse(text));
|
|
588
|
+
});
|
|
589
|
+
});
|
|
590
|
+
request.on("timeout", () => {
|
|
591
|
+
request.destroy(new Error("Adopted editor operation timed out."));
|
|
592
|
+
});
|
|
593
|
+
request.on("error", reject);
|
|
594
|
+
request.end(body);
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
async function readAdoptedEditorMetadata(metadataPath) {
|
|
598
|
+
try {
|
|
599
|
+
return JSON.parse(await fs.readFile(metadataPath, "utf8"));
|
|
600
|
+
}
|
|
601
|
+
catch {
|
|
602
|
+
return null;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
async function readTrimmedFile(filePath) {
|
|
606
|
+
try {
|
|
607
|
+
const value = (await fs.readFile(filePath, "utf8")).trim();
|
|
608
|
+
return value.length > 0 ? value : null;
|
|
609
|
+
}
|
|
610
|
+
catch {
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
function isProcessAlive(pid) {
|
|
615
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
616
|
+
return false;
|
|
617
|
+
try {
|
|
618
|
+
process.kill(pid, 0);
|
|
619
|
+
return true;
|
|
620
|
+
}
|
|
621
|
+
catch {
|
|
622
|
+
return false;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
async function resolveGodotEditorBin(input) {
|
|
626
|
+
if (input.godotEditorBin)
|
|
627
|
+
return input.godotEditorBin;
|
|
628
|
+
return await resolveConfiguredGodotEditorBin({
|
|
629
|
+
targetPath: input.targetPath,
|
|
630
|
+
}).catch(() => null);
|
|
631
|
+
}
|
|
632
|
+
async function spawnEditorProcess(input) {
|
|
633
|
+
return await new Promise((resolve) => {
|
|
634
|
+
const child = spawn(input.command, [...input.args], {
|
|
635
|
+
cwd: input.cwd,
|
|
636
|
+
env: input.env,
|
|
637
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
638
|
+
windowsHide: true,
|
|
639
|
+
shell: false,
|
|
640
|
+
});
|
|
641
|
+
let stdout = "";
|
|
642
|
+
let stderr = "";
|
|
643
|
+
let settled = false;
|
|
644
|
+
const timeout = setTimeout(() => {
|
|
645
|
+
child.kill("SIGTERM");
|
|
646
|
+
}, input.timeoutMs);
|
|
647
|
+
child.stdout?.setEncoding("utf8");
|
|
648
|
+
child.stderr?.setEncoding("utf8");
|
|
649
|
+
child.stdout?.on("data", (chunk) => {
|
|
650
|
+
stdout += chunk;
|
|
651
|
+
});
|
|
652
|
+
child.stderr?.on("data", (chunk) => {
|
|
653
|
+
stderr += chunk;
|
|
654
|
+
});
|
|
655
|
+
child.on("error", (error) => {
|
|
656
|
+
if (settled)
|
|
657
|
+
return;
|
|
658
|
+
settled = true;
|
|
659
|
+
clearTimeout(timeout);
|
|
660
|
+
resolve({ exitCode: 1, signal: null, stdout, stderr: `${stderr}${String(error.message)}\n` });
|
|
661
|
+
});
|
|
662
|
+
child.on("close", (exitCode, signal) => {
|
|
663
|
+
if (settled)
|
|
664
|
+
return;
|
|
665
|
+
settled = true;
|
|
666
|
+
clearTimeout(timeout);
|
|
667
|
+
resolve({ exitCode, signal, stdout, stderr });
|
|
668
|
+
});
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
async function readRunnerOutput(outputPath) {
|
|
672
|
+
const content = await fs.readFile(outputPath, "utf8").catch(() => null);
|
|
673
|
+
if (content === null)
|
|
674
|
+
return null;
|
|
675
|
+
try {
|
|
676
|
+
return JSON.parse(content);
|
|
677
|
+
}
|
|
678
|
+
catch {
|
|
679
|
+
return null;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
async function pruneOperationArtifacts(operationsDirectory, retainCount = DEFAULT_RETAIN_OPERATION_ARTIFACTS) {
|
|
683
|
+
if (retainCount <= 0)
|
|
684
|
+
return;
|
|
685
|
+
const entries = await fs.readdir(operationsDirectory, { withFileTypes: true }).catch(() => []);
|
|
686
|
+
const directories = await Promise.all(entries
|
|
687
|
+
.filter((entry) => entry.isDirectory())
|
|
688
|
+
.map(async (entry) => {
|
|
689
|
+
const absolutePath = path.join(operationsDirectory, entry.name);
|
|
690
|
+
const stat = await fs.stat(absolutePath).catch(() => null);
|
|
691
|
+
return stat === null ? null : { absolutePath, mtimeMs: stat.mtimeMs };
|
|
692
|
+
}));
|
|
693
|
+
const sorted = directories
|
|
694
|
+
.filter((entry) => entry !== null)
|
|
695
|
+
.sort((left, right) => right.mtimeMs - left.mtimeMs);
|
|
696
|
+
await Promise.all(sorted
|
|
697
|
+
.slice(retainCount)
|
|
698
|
+
.map((entry) => fs.rm(entry.absolutePath, { recursive: true, force: true })));
|
|
699
|
+
}
|
|
700
|
+
export function renderEditorOperationRunner() {
|
|
701
|
+
return `extends SceneTree
|
|
702
|
+
|
|
703
|
+
func _init() -> void:
|
|
704
|
+
var input_path := OS.get_environment("GDH_EDITOR_OPERATION_INPUT")
|
|
705
|
+
var output_path := OS.get_environment("GDH_EDITOR_OPERATION_OUTPUT")
|
|
706
|
+
var result := _run_operation(input_path)
|
|
707
|
+
_write_json(output_path, result)
|
|
708
|
+
quit(0)
|
|
709
|
+
|
|
710
|
+
func _run_operation(input_path: String) -> Dictionary:
|
|
711
|
+
var input_text := FileAccess.get_file_as_string(input_path)
|
|
712
|
+
var parsed = JSON.parse_string(input_text)
|
|
713
|
+
if typeof(parsed) != TYPE_DICTIONARY:
|
|
714
|
+
return _failure("Invalid editor operation input JSON.", ["input_json_invalid"])
|
|
715
|
+
var operation = parsed.get("operation", {})
|
|
716
|
+
if typeof(operation) != TYPE_DICTIONARY:
|
|
717
|
+
return _failure("Missing editor operation payload.", ["operation_missing"])
|
|
718
|
+
return run_editor_operation(operation)
|
|
719
|
+
|
|
720
|
+
func run_editor_operation(operation: Dictionary) -> Dictionary:
|
|
721
|
+
return _dispatch_operation(operation)
|
|
722
|
+
|
|
723
|
+
func _dispatch_operation(operation: Dictionary) -> Dictionary:
|
|
724
|
+
var kind := str(operation.get("kind", ""))
|
|
725
|
+
if kind == "":
|
|
726
|
+
var type_value := str(operation.get("type", ""))
|
|
727
|
+
if type_value != "":
|
|
728
|
+
return _failure("Editor operation payload used type='" + type_value + "', but GDH dispatches editor operations with kind. Use operation.kind instead.", ["operation_kind_missing", "operation_type_unsupported"], _operation_help(type_value))
|
|
729
|
+
return _failure("Editor operation payload is missing kind.", ["operation_kind_missing"], _operation_help("editor.state"))
|
|
730
|
+
match kind:
|
|
731
|
+
"scene.create":
|
|
732
|
+
return _scene_create(operation)
|
|
733
|
+
"scene.tree":
|
|
734
|
+
return _scene_tree(operation)
|
|
735
|
+
"scene.save":
|
|
736
|
+
return _scene_save(operation)
|
|
737
|
+
"node.add":
|
|
738
|
+
return _node_add(operation)
|
|
739
|
+
"node.remove":
|
|
740
|
+
return _node_remove(operation)
|
|
741
|
+
"node.reparent":
|
|
742
|
+
return _node_reparent(operation)
|
|
743
|
+
"node.duplicate":
|
|
744
|
+
return _node_duplicate(operation)
|
|
745
|
+
"node.set_property":
|
|
746
|
+
return _node_set_property(operation)
|
|
747
|
+
"node.get_property":
|
|
748
|
+
return _node_get_property(operation)
|
|
749
|
+
"node.inspect":
|
|
750
|
+
return _node_inspect(operation)
|
|
751
|
+
"resource.create":
|
|
752
|
+
return _resource_create(operation)
|
|
753
|
+
"resource.read":
|
|
754
|
+
return _resource_read(operation)
|
|
755
|
+
"resource.properties":
|
|
756
|
+
return _resource_properties(operation)
|
|
757
|
+
"resource.set_property":
|
|
758
|
+
return _resource_set_property(operation)
|
|
759
|
+
"script.attach":
|
|
760
|
+
return _script_attach(operation)
|
|
761
|
+
"class.search":
|
|
762
|
+
return _class_search(operation)
|
|
763
|
+
"class.info":
|
|
764
|
+
return _class_info(operation)
|
|
765
|
+
"editor.state":
|
|
766
|
+
return _editor_state(operation)
|
|
767
|
+
"batch.apply":
|
|
768
|
+
return _batch_apply(operation)
|
|
769
|
+
_:
|
|
770
|
+
return _failure("Unsupported editor operation: " + kind, ["operation_unsupported"], _operation_help(kind))
|
|
771
|
+
|
|
772
|
+
func _scene_create(operation: Dictionary) -> Dictionary:
|
|
773
|
+
var scene_path := _res_path(str(operation.get("scenePath", "")))
|
|
774
|
+
if scene_path == "res://":
|
|
775
|
+
return _failure("scenePath is required.", ["scene_path_missing"])
|
|
776
|
+
var root_type := str(operation.get("rootNodeType", "Node"))
|
|
777
|
+
var root = _instantiate(root_type)
|
|
778
|
+
if root == null or not (root is Node):
|
|
779
|
+
return _failure("Unable to instantiate root node type: " + root_type, ["root_node_type_invalid"])
|
|
780
|
+
root.name = str(operation.get("rootNodeName", root_type))
|
|
781
|
+
_set_properties(root, operation.get("properties", {}))
|
|
782
|
+
var packed := PackedScene.new()
|
|
783
|
+
var pack_err := packed.pack(root)
|
|
784
|
+
if pack_err != OK:
|
|
785
|
+
return _failure("Failed to pack scene.", ["scene_pack_failed"])
|
|
786
|
+
_ensure_parent_dir(scene_path)
|
|
787
|
+
var save_err := ResourceSaver.save(packed, scene_path)
|
|
788
|
+
if save_err != OK:
|
|
789
|
+
return _failure("Failed to save scene: " + scene_path, ["scene_save_failed"])
|
|
790
|
+
return _success("Scene created.", {"scenePath": scene_path, "rootNodeType": root_type, "rootNodeName": root.name})
|
|
791
|
+
|
|
792
|
+
func _scene_tree(operation: Dictionary) -> Dictionary:
|
|
793
|
+
var scene_path := _res_path(str(operation.get("scenePath", "")))
|
|
794
|
+
if scene_path == "res://":
|
|
795
|
+
return _failure("scene.tree requires scenePath.", ["scene_path_missing"], _operation_help("scene.tree"))
|
|
796
|
+
var loaded = load(scene_path)
|
|
797
|
+
if loaded == null or not (loaded is PackedScene):
|
|
798
|
+
return _failure("Scene could not be loaded: " + scene_path, ["scene_load_failed"])
|
|
799
|
+
var root = loaded.instantiate()
|
|
800
|
+
if root == null:
|
|
801
|
+
return _failure("Scene could not be instantiated: " + scene_path, ["scene_instantiate_failed"])
|
|
802
|
+
var max_depth := int(operation.get("maxDepth", -1))
|
|
803
|
+
var tree := _node_summary(root, ".", 0, max_depth)
|
|
804
|
+
root.queue_free()
|
|
805
|
+
return _success("Scene tree loaded.", {"scenePath": scene_path, "tree": tree})
|
|
806
|
+
|
|
807
|
+
func _scene_save(operation: Dictionary) -> Dictionary:
|
|
808
|
+
var scene_path := _res_path(str(operation.get("scenePath", "")))
|
|
809
|
+
var root = _load_scene_root(scene_path)
|
|
810
|
+
if root == null:
|
|
811
|
+
return _failure("Scene could not be loaded: " + scene_path, ["scene_load_failed"])
|
|
812
|
+
return _save_instantiated_scene(root, scene_path)
|
|
813
|
+
|
|
814
|
+
func _node_add(operation: Dictionary) -> Dictionary:
|
|
815
|
+
var scene_path := _res_path(str(operation.get("scenePath", "")))
|
|
816
|
+
var loaded = load(scene_path)
|
|
817
|
+
if loaded == null or not (loaded is PackedScene):
|
|
818
|
+
return _failure("Scene could not be loaded: " + scene_path, ["scene_load_failed"])
|
|
819
|
+
var root = loaded.instantiate()
|
|
820
|
+
var parent_path := str(operation.get("parentNodePath", "."))
|
|
821
|
+
var parent = _find_node(root, parent_path)
|
|
822
|
+
if parent == null:
|
|
823
|
+
return _failure("Parent node not found: " + parent_path, ["parent_node_not_found"])
|
|
824
|
+
var node_type := str(operation.get("nodeType", ""))
|
|
825
|
+
var node = _instantiate(node_type)
|
|
826
|
+
if node == null or not (node is Node):
|
|
827
|
+
return _failure("Unable to instantiate node type: " + node_type, ["node_type_invalid"])
|
|
828
|
+
node.name = str(operation.get("nodeName", node_type))
|
|
829
|
+
_set_properties(node, operation.get("properties", {}))
|
|
830
|
+
parent.add_child(node)
|
|
831
|
+
_set_owner_recursive(node, root)
|
|
832
|
+
var result := _save_instantiated_scene(root, scene_path)
|
|
833
|
+
if not result.get("ok", false):
|
|
834
|
+
return result
|
|
835
|
+
return _success("Node added.", {"scenePath": scene_path, "nodePath": str(root.get_path_to(node)), "nodeType": node_type, "nodeName": node.name})
|
|
836
|
+
|
|
837
|
+
func _node_remove(operation: Dictionary) -> Dictionary:
|
|
838
|
+
var scene_path := _res_path(str(operation.get("scenePath", "")))
|
|
839
|
+
var root = _load_scene_root(scene_path)
|
|
840
|
+
if root == null:
|
|
841
|
+
return _failure("Scene could not be loaded: " + scene_path, ["scene_load_failed"])
|
|
842
|
+
var node_path := str(operation.get("nodePath", ""))
|
|
843
|
+
var node = _find_node(root, node_path)
|
|
844
|
+
if node == null or node == root:
|
|
845
|
+
return _failure("Node not removable: " + node_path, ["node_not_removable"])
|
|
846
|
+
var parent = node.get_parent()
|
|
847
|
+
if parent == null:
|
|
848
|
+
return _failure("Node parent not found: " + node_path, ["node_parent_not_found"])
|
|
849
|
+
parent.remove_child(node)
|
|
850
|
+
node.free()
|
|
851
|
+
var result := _save_instantiated_scene(root, scene_path)
|
|
852
|
+
if not result.get("ok", false):
|
|
853
|
+
return result
|
|
854
|
+
return _success("Node removed.", {"scenePath": scene_path, "nodePath": node_path})
|
|
855
|
+
|
|
856
|
+
func _node_reparent(operation: Dictionary) -> Dictionary:
|
|
857
|
+
var scene_path := _res_path(str(operation.get("scenePath", "")))
|
|
858
|
+
var root = _load_scene_root(scene_path)
|
|
859
|
+
if root == null:
|
|
860
|
+
return _failure("Scene could not be loaded: " + scene_path, ["scene_load_failed"])
|
|
861
|
+
var node_path := str(operation.get("nodePath", ""))
|
|
862
|
+
var new_parent_path := str(operation.get("newParentNodePath", ""))
|
|
863
|
+
var node = _find_node(root, node_path)
|
|
864
|
+
var new_parent = _find_node(root, new_parent_path)
|
|
865
|
+
if node == null or node == root:
|
|
866
|
+
return _failure("Node not reparentable: " + node_path, ["node_not_reparentable"])
|
|
867
|
+
if new_parent == null:
|
|
868
|
+
return _failure("New parent node not found: " + new_parent_path, ["new_parent_node_not_found"])
|
|
869
|
+
if node == new_parent or node.is_ancestor_of(new_parent):
|
|
870
|
+
return _failure("Cannot reparent a node below itself.", ["node_reparent_cycle"])
|
|
871
|
+
var old_parent = node.get_parent()
|
|
872
|
+
if old_parent == null:
|
|
873
|
+
return _failure("Node parent not found: " + node_path, ["node_parent_not_found"])
|
|
874
|
+
old_parent.remove_child(node)
|
|
875
|
+
new_parent.add_child(node)
|
|
876
|
+
_set_owner_recursive(node, root)
|
|
877
|
+
var result := _save_instantiated_scene(root, scene_path)
|
|
878
|
+
if not result.get("ok", false):
|
|
879
|
+
return result
|
|
880
|
+
return _success("Node reparented.", {"scenePath": scene_path, "nodePath": str(root.get_path_to(node)), "newParentNodePath": new_parent_path})
|
|
881
|
+
|
|
882
|
+
func _node_duplicate(operation: Dictionary) -> Dictionary:
|
|
883
|
+
var scene_path := _res_path(str(operation.get("scenePath", "")))
|
|
884
|
+
var root = _load_scene_root(scene_path)
|
|
885
|
+
if root == null:
|
|
886
|
+
return _failure("Scene could not be loaded: " + scene_path, ["scene_load_failed"])
|
|
887
|
+
var node_path := str(operation.get("nodePath", ""))
|
|
888
|
+
var node = _find_node(root, node_path)
|
|
889
|
+
if node == null:
|
|
890
|
+
return _failure("Node not found: " + node_path, ["node_not_found"])
|
|
891
|
+
var parent_path := str(operation.get("parentNodePath", ""))
|
|
892
|
+
var parent = _find_node(root, parent_path) if not parent_path.is_empty() else node.get_parent()
|
|
893
|
+
if parent == null:
|
|
894
|
+
return _failure("Duplicate parent node not found.", ["duplicate_parent_node_not_found"])
|
|
895
|
+
var duplicate = node.duplicate()
|
|
896
|
+
if duplicate == null or not (duplicate is Node):
|
|
897
|
+
return _failure("Node could not be duplicated: " + node_path, ["node_duplicate_failed"])
|
|
898
|
+
var requested_name := str(operation.get("newNodeName", ""))
|
|
899
|
+
if not requested_name.is_empty():
|
|
900
|
+
duplicate.name = requested_name
|
|
901
|
+
parent.add_child(duplicate)
|
|
902
|
+
_set_owner_recursive(duplicate, root)
|
|
903
|
+
var result := _save_instantiated_scene(root, scene_path)
|
|
904
|
+
if not result.get("ok", false):
|
|
905
|
+
return result
|
|
906
|
+
return _success("Node duplicated.", {"scenePath": scene_path, "sourceNodePath": node_path, "nodePath": str(root.get_path_to(duplicate)), "nodeName": duplicate.name})
|
|
907
|
+
|
|
908
|
+
func _node_set_property(operation: Dictionary) -> Dictionary:
|
|
909
|
+
var scene_path := _res_path(str(operation.get("scenePath", "")))
|
|
910
|
+
var root = _load_scene_root(scene_path)
|
|
911
|
+
if root == null:
|
|
912
|
+
return _failure("Scene could not be loaded: " + scene_path, ["scene_load_failed"])
|
|
913
|
+
var node_path := str(operation.get("nodePath", ""))
|
|
914
|
+
var node = _find_node(root, node_path)
|
|
915
|
+
if node == null:
|
|
916
|
+
return _failure("Node not found: " + node_path, ["node_not_found"])
|
|
917
|
+
var property_name := str(operation.get("propertyName", ""))
|
|
918
|
+
node.set(property_name, _decode_value(operation.get("value")))
|
|
919
|
+
var result := _save_instantiated_scene(root, scene_path)
|
|
920
|
+
if not result.get("ok", false):
|
|
921
|
+
return result
|
|
922
|
+
return _success("Node property set.", {"scenePath": scene_path, "nodePath": node_path, "propertyName": property_name})
|
|
923
|
+
|
|
924
|
+
func _node_get_property(operation: Dictionary) -> Dictionary:
|
|
925
|
+
var scene_path := _res_path(str(operation.get("scenePath", "")))
|
|
926
|
+
var root = _load_scene_root(scene_path)
|
|
927
|
+
if root == null:
|
|
928
|
+
return _failure("Scene could not be loaded: " + scene_path, ["scene_load_failed"])
|
|
929
|
+
var node_path := str(operation.get("nodePath", ""))
|
|
930
|
+
var node = _find_node(root, node_path)
|
|
931
|
+
if node == null:
|
|
932
|
+
return _failure("Node not found: " + node_path, ["node_not_found"])
|
|
933
|
+
var property_name := str(operation.get("propertyName", ""))
|
|
934
|
+
var value = _encode_value(node.get(property_name))
|
|
935
|
+
root.queue_free()
|
|
936
|
+
return _success("Node property read.", {"scenePath": scene_path, "nodePath": node_path, "propertyName": property_name, "value": value})
|
|
937
|
+
|
|
938
|
+
func _node_inspect(operation: Dictionary) -> Dictionary:
|
|
939
|
+
var scene_path := _res_path(str(operation.get("scenePath", "")))
|
|
940
|
+
if scene_path == "res://":
|
|
941
|
+
return _failure("node.inspect requires scenePath.", ["scene_path_missing"], _operation_help("node.inspect"))
|
|
942
|
+
var root = _load_scene_root(scene_path)
|
|
943
|
+
if root == null:
|
|
944
|
+
return _failure("Scene could not be loaded: " + scene_path, ["scene_load_failed"])
|
|
945
|
+
var node_path := str(operation.get("nodePath", "."))
|
|
946
|
+
var node = _find_node(root, node_path)
|
|
947
|
+
if node == null:
|
|
948
|
+
root.queue_free()
|
|
949
|
+
return _failure("Node not found: " + node_path, ["node_not_found"])
|
|
950
|
+
var property_names = operation.get("propertyNames", [])
|
|
951
|
+
var properties := {}
|
|
952
|
+
if typeof(property_names) == TYPE_ARRAY:
|
|
953
|
+
for property_name in property_names:
|
|
954
|
+
var key := str(property_name)
|
|
955
|
+
properties[key] = _encode_value(node.get(key))
|
|
956
|
+
var property_list := []
|
|
957
|
+
if bool(operation.get("includePropertyList", false)):
|
|
958
|
+
property_list = _filter_property_list(node.get_property_list(), str(operation.get("propertyQuery", "")), int(operation.get("propertyLimit", 50)))
|
|
959
|
+
var summary := _node_summary(node, node_path, 0, 0)
|
|
960
|
+
root.queue_free()
|
|
961
|
+
return _success("Node inspected.", {"scenePath": scene_path, "nodePath": node_path, "node": summary, "properties": properties, "propertyList": property_list})
|
|
962
|
+
|
|
963
|
+
func _resource_create(operation: Dictionary) -> Dictionary:
|
|
964
|
+
var resource_path := _res_path(str(operation.get("resourcePath", "")))
|
|
965
|
+
var resource_type := str(operation.get("resourceType", ""))
|
|
966
|
+
var resource = _instantiate(resource_type)
|
|
967
|
+
if resource == null or not (resource is Resource):
|
|
968
|
+
return _failure("Unable to instantiate resource type: " + resource_type, ["resource_type_invalid"])
|
|
969
|
+
_set_properties(resource, operation.get("properties", {}))
|
|
970
|
+
_ensure_parent_dir(resource_path)
|
|
971
|
+
var save_err := ResourceSaver.save(resource, resource_path)
|
|
972
|
+
if save_err != OK:
|
|
973
|
+
return _failure("Failed to save resource: " + resource_path, ["resource_save_failed"])
|
|
974
|
+
return _success("Resource created.", {"resourcePath": resource_path, "resourceType": resource_type})
|
|
975
|
+
|
|
976
|
+
func _resource_read(operation: Dictionary) -> Dictionary:
|
|
977
|
+
var resource_path := _res_path(str(operation.get("resourcePath", "")))
|
|
978
|
+
if resource_path == "res://":
|
|
979
|
+
return _failure("resource.read requires resourcePath.", ["resource_path_missing"], _operation_help("resource.read"))
|
|
980
|
+
var resource = load(resource_path)
|
|
981
|
+
if resource == null or not (resource is Resource):
|
|
982
|
+
return _failure("Resource could not be loaded: " + resource_path, ["resource_load_failed"])
|
|
983
|
+
var property_names = operation.get("propertyNames", [])
|
|
984
|
+
var properties := {}
|
|
985
|
+
if typeof(property_names) == TYPE_ARRAY:
|
|
986
|
+
for property_name in property_names:
|
|
987
|
+
var key := str(property_name)
|
|
988
|
+
properties[key] = _encode_value(resource.get(key))
|
|
989
|
+
var script = resource.get_script()
|
|
990
|
+
return _success("Resource read.", {"resourcePath": resource_path, "class": resource.get_class(), "scriptPath": script.resource_path if script else null, "properties": properties})
|
|
991
|
+
|
|
992
|
+
func _resource_properties(operation: Dictionary) -> Dictionary:
|
|
993
|
+
var resource_path := _res_path(str(operation.get("resourcePath", "")))
|
|
994
|
+
if resource_path == "res://":
|
|
995
|
+
return _failure("resource.properties requires resourcePath.", ["resource_path_missing"], _operation_help("resource.properties"))
|
|
996
|
+
var resource = load(resource_path)
|
|
997
|
+
if resource == null or not (resource is Resource):
|
|
998
|
+
return _failure("Resource could not be loaded: " + resource_path, ["resource_load_failed"])
|
|
999
|
+
var script = resource.get_script()
|
|
1000
|
+
var properties := _filter_property_list(resource.get_property_list(), str(operation.get("propertyQuery", "")), int(operation.get("propertyLimit", 80)))
|
|
1001
|
+
return _success("Resource properties loaded.", {"resourcePath": resource_path, "class": resource.get_class(), "scriptPath": script.resource_path if script else null, "properties": properties, "propertiesTruncated": properties.size() >= int(operation.get("propertyLimit", 80))})
|
|
1002
|
+
|
|
1003
|
+
func _resource_set_property(operation: Dictionary) -> Dictionary:
|
|
1004
|
+
var resource_path := _res_path(str(operation.get("resourcePath", "")))
|
|
1005
|
+
var resource = load(resource_path)
|
|
1006
|
+
if resource == null or not (resource is Resource):
|
|
1007
|
+
return _failure("Resource could not be loaded: " + resource_path, ["resource_load_failed"])
|
|
1008
|
+
var property_name := str(operation.get("propertyName", ""))
|
|
1009
|
+
resource.set(property_name, _decode_value(operation.get("value")))
|
|
1010
|
+
var save_err := ResourceSaver.save(resource, resource_path)
|
|
1011
|
+
if save_err != OK:
|
|
1012
|
+
return _failure("Failed to save resource: " + resource_path, ["resource_save_failed"])
|
|
1013
|
+
return _success("Resource property set.", {"resourcePath": resource_path, "propertyName": property_name})
|
|
1014
|
+
|
|
1015
|
+
func _script_attach(operation: Dictionary) -> Dictionary:
|
|
1016
|
+
var scene_path := _res_path(str(operation.get("scenePath", "")))
|
|
1017
|
+
var root = _load_scene_root(scene_path)
|
|
1018
|
+
if root == null:
|
|
1019
|
+
return _failure("Scene could not be loaded: " + scene_path, ["scene_load_failed"])
|
|
1020
|
+
var node_path := str(operation.get("nodePath", ""))
|
|
1021
|
+
var node = _find_node(root, node_path)
|
|
1022
|
+
if node == null:
|
|
1023
|
+
return _failure("Node not found: " + node_path, ["node_not_found"])
|
|
1024
|
+
var script_path := _res_path(str(operation.get("scriptPath", "")))
|
|
1025
|
+
var script = load(script_path)
|
|
1026
|
+
if script == null or not (script is Script):
|
|
1027
|
+
return _failure("Script could not be loaded: " + script_path, ["script_load_failed"])
|
|
1028
|
+
node.set_script(script)
|
|
1029
|
+
var result := _save_instantiated_scene(root, scene_path)
|
|
1030
|
+
if not result.get("ok", false):
|
|
1031
|
+
return result
|
|
1032
|
+
return _success("Script attached.", {"scenePath": scene_path, "nodePath": node_path, "scriptPath": script_path})
|
|
1033
|
+
|
|
1034
|
+
func _class_search(operation: Dictionary) -> Dictionary:
|
|
1035
|
+
var query := str(operation.get("query", "")).to_lower()
|
|
1036
|
+
var base_class := str(operation.get("baseClass", ""))
|
|
1037
|
+
var instantiable_only := bool(operation.get("instantiableOnly", true))
|
|
1038
|
+
var limit := int(operation.get("limit", 50))
|
|
1039
|
+
var classes := []
|
|
1040
|
+
var seen := {}
|
|
1041
|
+
for class_id in ClassDB.get_class_list():
|
|
1042
|
+
if query != "" and not str(class_id).to_lower().contains(query):
|
|
1043
|
+
continue
|
|
1044
|
+
if base_class != "" and class_id != base_class and not ClassDB.is_parent_class(class_id, base_class):
|
|
1045
|
+
continue
|
|
1046
|
+
if instantiable_only and not ClassDB.can_instantiate(class_id):
|
|
1047
|
+
continue
|
|
1048
|
+
classes.append({"name": class_id, "source": "class_db", "baseClass": ClassDB.get_parent_class(class_id), "instantiable": ClassDB.can_instantiate(class_id)})
|
|
1049
|
+
seen[class_id] = true
|
|
1050
|
+
if classes.size() >= limit:
|
|
1051
|
+
break
|
|
1052
|
+
for entry in ProjectSettings.get_global_class_list():
|
|
1053
|
+
var class_id := str(entry.get("class", ""))
|
|
1054
|
+
if seen.has(class_id):
|
|
1055
|
+
continue
|
|
1056
|
+
if query != "" and not class_id.to_lower().contains(query):
|
|
1057
|
+
continue
|
|
1058
|
+
if base_class != "" and str(entry.get("base", "")) != base_class:
|
|
1059
|
+
continue
|
|
1060
|
+
classes.append({"name": class_id, "source": "global_class", "baseClass": str(entry.get("base", "")), "path": str(entry.get("path", "")), "instantiable": true})
|
|
1061
|
+
seen[class_id] = true
|
|
1062
|
+
if classes.size() >= limit:
|
|
1063
|
+
break
|
|
1064
|
+
for entry in _script_class_entries():
|
|
1065
|
+
var class_id := str(entry.get("class", ""))
|
|
1066
|
+
if seen.has(class_id):
|
|
1067
|
+
continue
|
|
1068
|
+
if query != "" and not class_id.to_lower().contains(query):
|
|
1069
|
+
continue
|
|
1070
|
+
if base_class != "" and str(entry.get("base", "")) != base_class:
|
|
1071
|
+
continue
|
|
1072
|
+
classes.append({"name": class_id, "source": "script_scan", "baseClass": str(entry.get("base", "")), "path": str(entry.get("path", "")), "instantiable": true})
|
|
1073
|
+
seen[class_id] = true
|
|
1074
|
+
if classes.size() >= limit:
|
|
1075
|
+
break
|
|
1076
|
+
return _success("Class search completed.", {"classes": classes, "truncated": classes.size() >= limit})
|
|
1077
|
+
|
|
1078
|
+
func _class_info(operation: Dictionary) -> Dictionary:
|
|
1079
|
+
var class_id := str(operation.get("className", ""))
|
|
1080
|
+
var property_query := str(operation.get("propertyQuery", "")).to_lower()
|
|
1081
|
+
var property_limit := int(operation.get("propertyLimit", 50))
|
|
1082
|
+
var properties := []
|
|
1083
|
+
if ClassDB.class_exists(class_id):
|
|
1084
|
+
for property in ClassDB.class_get_property_list(class_id, bool(operation.get("includeInherited", true))):
|
|
1085
|
+
var name := str(property.get("name", ""))
|
|
1086
|
+
if property_query != "" and not name.to_lower().contains(property_query):
|
|
1087
|
+
continue
|
|
1088
|
+
properties.append(_property_summary(property))
|
|
1089
|
+
if properties.size() >= property_limit:
|
|
1090
|
+
break
|
|
1091
|
+
return _success("Class info loaded.", {"className": class_id, "source": "class_db", "parentClass": ClassDB.get_parent_class(class_id), "instantiable": ClassDB.can_instantiate(class_id), "properties": properties, "propertiesTruncated": properties.size() >= property_limit})
|
|
1092
|
+
var script_class := _find_script_class(class_id)
|
|
1093
|
+
if script_class.size() > 0:
|
|
1094
|
+
var script_path := str(script_class.get("path", ""))
|
|
1095
|
+
var script = load(script_path)
|
|
1096
|
+
if script and script.has_method("get_script_property_list"):
|
|
1097
|
+
for property in script.get_script_property_list():
|
|
1098
|
+
var name := str(property.get("name", ""))
|
|
1099
|
+
if property_query != "" and not name.to_lower().contains(property_query):
|
|
1100
|
+
continue
|
|
1101
|
+
properties.append(_property_summary(property))
|
|
1102
|
+
if properties.size() >= property_limit:
|
|
1103
|
+
break
|
|
1104
|
+
return _success("Class info loaded.", {"className": class_id, "source": str(script_class.get("source", "script_scan")), "path": script_path, "parentClass": str(script_class.get("base", "")), "instantiable": true, "properties": properties, "propertiesTruncated": properties.size() >= property_limit})
|
|
1105
|
+
return _failure("Class not found: " + class_id, ["class_not_found"])
|
|
1106
|
+
|
|
1107
|
+
func _editor_state(_operation: Dictionary) -> Dictionary:
|
|
1108
|
+
var editor_interface = _editor_interface()
|
|
1109
|
+
var current_scene_path = null
|
|
1110
|
+
var open_scenes := []
|
|
1111
|
+
var selected_node_paths := []
|
|
1112
|
+
if editor_interface != null:
|
|
1113
|
+
if editor_interface.has_method("get_open_scenes"):
|
|
1114
|
+
for scene_path in editor_interface.get_open_scenes():
|
|
1115
|
+
open_scenes.append(str(scene_path))
|
|
1116
|
+
var edited_root = editor_interface.get_edited_scene_root()
|
|
1117
|
+
if edited_root != null:
|
|
1118
|
+
current_scene_path = edited_root.scene_file_path
|
|
1119
|
+
var selection = editor_interface.get_selection()
|
|
1120
|
+
if selection != null:
|
|
1121
|
+
for selected_node in selection.get_selected_nodes():
|
|
1122
|
+
if edited_root != null and selected_node is Node:
|
|
1123
|
+
selected_node_paths.append(str(edited_root.get_path_to(selected_node)))
|
|
1124
|
+
else:
|
|
1125
|
+
selected_node_paths.append(str(selected_node.get_path()))
|
|
1126
|
+
return _success("Editor state loaded.", {
|
|
1127
|
+
"projectName": ProjectSettings.get_setting("application/config/name", ""),
|
|
1128
|
+
"engineVersion": Engine.get_version_info().get("string", ""),
|
|
1129
|
+
"features": ProjectSettings.get_setting("application/config/features", []),
|
|
1130
|
+
"godotProjectRootPath": ProjectSettings.globalize_path("res://"),
|
|
1131
|
+
"editorContextAvailable": editor_interface != null,
|
|
1132
|
+
"currentScenePath": current_scene_path,
|
|
1133
|
+
"openScenes": open_scenes,
|
|
1134
|
+
"dirtyScenes": [],
|
|
1135
|
+
"selectedNodePaths": selected_node_paths,
|
|
1136
|
+
})
|
|
1137
|
+
|
|
1138
|
+
func _batch_apply(operation: Dictionary) -> Dictionary:
|
|
1139
|
+
var operations = operation.get("operations", [])
|
|
1140
|
+
if typeof(operations) != TYPE_ARRAY:
|
|
1141
|
+
return _failure("batch.apply requires operations array.", ["batch_operations_invalid"])
|
|
1142
|
+
var results := []
|
|
1143
|
+
for child_operation in operations:
|
|
1144
|
+
if typeof(child_operation) != TYPE_DICTIONARY:
|
|
1145
|
+
return _failure("batch.apply child operation must be an object.", ["batch_child_operation_invalid"])
|
|
1146
|
+
var result := _dispatch_operation(child_operation)
|
|
1147
|
+
results.append(result)
|
|
1148
|
+
if not result.get("ok", false):
|
|
1149
|
+
return _failure("Batch operation failed.", ["batch_child_failed", str(child_operation.get("kind", ""))])
|
|
1150
|
+
return _success("Batch applied.", {"results": results})
|
|
1151
|
+
|
|
1152
|
+
func _load_scene_root(scene_path: String):
|
|
1153
|
+
var loaded = load(scene_path)
|
|
1154
|
+
if loaded == null or not (loaded is PackedScene):
|
|
1155
|
+
return null
|
|
1156
|
+
return loaded.instantiate()
|
|
1157
|
+
|
|
1158
|
+
func _save_instantiated_scene(root: Node, scene_path: String) -> Dictionary:
|
|
1159
|
+
var packed := PackedScene.new()
|
|
1160
|
+
var pack_err := packed.pack(root)
|
|
1161
|
+
if pack_err != OK:
|
|
1162
|
+
return _failure("Failed to pack scene.", ["scene_pack_failed"])
|
|
1163
|
+
var save_err := ResourceSaver.save(packed, scene_path)
|
|
1164
|
+
if save_err != OK:
|
|
1165
|
+
return _failure("Failed to save scene: " + scene_path, ["scene_save_failed"])
|
|
1166
|
+
return _success("Scene saved.", {"scenePath": scene_path})
|
|
1167
|
+
|
|
1168
|
+
func _instantiate(class_id: String):
|
|
1169
|
+
if class_id == "":
|
|
1170
|
+
return null
|
|
1171
|
+
if ClassDB.class_exists(class_id) and ClassDB.can_instantiate(class_id):
|
|
1172
|
+
return ClassDB.instantiate(class_id)
|
|
1173
|
+
var script_class := _find_script_class(class_id)
|
|
1174
|
+
if script_class.size() > 0:
|
|
1175
|
+
var script = load(str(script_class.get("path", "")))
|
|
1176
|
+
if script and script.has_method("new"):
|
|
1177
|
+
return script.new()
|
|
1178
|
+
return null
|
|
1179
|
+
|
|
1180
|
+
func _set_properties(object: Object, properties) -> void:
|
|
1181
|
+
if typeof(properties) != TYPE_DICTIONARY:
|
|
1182
|
+
return
|
|
1183
|
+
for property_name in properties.keys():
|
|
1184
|
+
object.set(str(property_name), _decode_value(properties[property_name]))
|
|
1185
|
+
|
|
1186
|
+
func _decode_value(value):
|
|
1187
|
+
if typeof(value) == TYPE_DICTIONARY:
|
|
1188
|
+
if value.has("$resource"):
|
|
1189
|
+
return load(_res_path(str(value["$resource"])))
|
|
1190
|
+
if value.has("$vector2"):
|
|
1191
|
+
var vector = value["$vector2"]
|
|
1192
|
+
return Vector2(float(vector.get("x", 0.0)), float(vector.get("y", 0.0)))
|
|
1193
|
+
if value.has("$color"):
|
|
1194
|
+
var color = value["$color"]
|
|
1195
|
+
return Color(float(color.get("r", 0.0)), float(color.get("g", 0.0)), float(color.get("b", 0.0)), float(color.get("a", 1.0)))
|
|
1196
|
+
return value
|
|
1197
|
+
|
|
1198
|
+
func _encode_value(value, depth := 0):
|
|
1199
|
+
if value == null:
|
|
1200
|
+
return null
|
|
1201
|
+
if depth >= 4:
|
|
1202
|
+
return {"$truncated": "max_depth", "text": str(value)}
|
|
1203
|
+
if value is Vector2:
|
|
1204
|
+
return {"$vector2": {"x": value.x, "y": value.y}}
|
|
1205
|
+
if value is Color:
|
|
1206
|
+
return {"$color": {"r": value.r, "g": value.g, "b": value.b, "a": value.a}}
|
|
1207
|
+
if value is Resource:
|
|
1208
|
+
var resource_path: String = value.resource_path
|
|
1209
|
+
return {"$resource": resource_path if resource_path != "" else null, "class": value.get_class(), "text": str(value)}
|
|
1210
|
+
var value_type := typeof(value)
|
|
1211
|
+
if value_type in [TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_STRING]:
|
|
1212
|
+
return value
|
|
1213
|
+
if value_type == TYPE_ARRAY:
|
|
1214
|
+
var encoded := []
|
|
1215
|
+
var count := 0
|
|
1216
|
+
for item in value:
|
|
1217
|
+
if count >= 100:
|
|
1218
|
+
encoded.append({"$truncated": "max_items"})
|
|
1219
|
+
break
|
|
1220
|
+
encoded.append(_encode_value(item, depth + 1))
|
|
1221
|
+
count += 1
|
|
1222
|
+
return encoded
|
|
1223
|
+
if value_type == TYPE_DICTIONARY:
|
|
1224
|
+
var encoded_dict := {}
|
|
1225
|
+
var count := 0
|
|
1226
|
+
for key in value.keys():
|
|
1227
|
+
if count >= 100:
|
|
1228
|
+
encoded_dict["$truncated"] = "max_items"
|
|
1229
|
+
break
|
|
1230
|
+
encoded_dict[str(key)] = _encode_value(value[key], depth + 1)
|
|
1231
|
+
count += 1
|
|
1232
|
+
return encoded_dict
|
|
1233
|
+
return str(value)
|
|
1234
|
+
|
|
1235
|
+
func _property_summary(property: Dictionary) -> Dictionary:
|
|
1236
|
+
return {"name": str(property.get("name", "")), "type": int(property.get("type", TYPE_NIL)), "hint": int(property.get("hint", 0)), "usage": int(property.get("usage", 0))}
|
|
1237
|
+
|
|
1238
|
+
func _filter_property_list(property_list: Array, query: String, limit: int) -> Array:
|
|
1239
|
+
var lowered_query := query.to_lower()
|
|
1240
|
+
var effective_limit := limit if limit > 0 else 50
|
|
1241
|
+
var properties := []
|
|
1242
|
+
for property in property_list:
|
|
1243
|
+
var name := str(property.get("name", ""))
|
|
1244
|
+
if lowered_query != "" and not name.to_lower().contains(lowered_query):
|
|
1245
|
+
continue
|
|
1246
|
+
properties.append(_property_summary(property))
|
|
1247
|
+
if properties.size() >= effective_limit:
|
|
1248
|
+
break
|
|
1249
|
+
return properties
|
|
1250
|
+
|
|
1251
|
+
func _editor_interface():
|
|
1252
|
+
var plugin = get("_plugin")
|
|
1253
|
+
if plugin == null:
|
|
1254
|
+
return null
|
|
1255
|
+
if plugin.has_method("get_editor_interface"):
|
|
1256
|
+
return plugin.get_editor_interface()
|
|
1257
|
+
return null
|
|
1258
|
+
|
|
1259
|
+
func _find_script_class(class_id: String) -> Dictionary:
|
|
1260
|
+
for entry in ProjectSettings.get_global_class_list():
|
|
1261
|
+
if str(entry.get("class", "")) == class_id:
|
|
1262
|
+
return {"class": class_id, "base": str(entry.get("base", "")), "path": str(entry.get("path", "")), "source": "global_class"}
|
|
1263
|
+
for entry in _script_class_entries():
|
|
1264
|
+
if str(entry.get("class", "")) == class_id:
|
|
1265
|
+
return entry
|
|
1266
|
+
return {}
|
|
1267
|
+
|
|
1268
|
+
func _script_class_entries() -> Array:
|
|
1269
|
+
var entries := []
|
|
1270
|
+
_collect_script_class_entries("res://", entries)
|
|
1271
|
+
return entries
|
|
1272
|
+
|
|
1273
|
+
func _collect_script_class_entries(directory_path: String, entries: Array) -> void:
|
|
1274
|
+
var dir := DirAccess.open(directory_path)
|
|
1275
|
+
if dir == null:
|
|
1276
|
+
return
|
|
1277
|
+
dir.list_dir_begin()
|
|
1278
|
+
var entry_name := dir.get_next()
|
|
1279
|
+
while entry_name != "":
|
|
1280
|
+
if entry_name.begins_with("."):
|
|
1281
|
+
entry_name = dir.get_next()
|
|
1282
|
+
continue
|
|
1283
|
+
var entry_path := directory_path.path_join(entry_name)
|
|
1284
|
+
if dir.current_is_dir():
|
|
1285
|
+
_collect_script_class_entries(entry_path, entries)
|
|
1286
|
+
elif entry_name.ends_with(".gd"):
|
|
1287
|
+
var script_class := _read_script_class_entry(entry_path)
|
|
1288
|
+
if script_class.size() > 0:
|
|
1289
|
+
entries.append(script_class)
|
|
1290
|
+
entry_name = dir.get_next()
|
|
1291
|
+
dir.list_dir_end()
|
|
1292
|
+
|
|
1293
|
+
func _read_script_class_entry(script_path: String) -> Dictionary:
|
|
1294
|
+
var source := FileAccess.get_file_as_string(script_path)
|
|
1295
|
+
var class_id := ""
|
|
1296
|
+
var base_class := ""
|
|
1297
|
+
for raw_line in source.split("\n"):
|
|
1298
|
+
var line := str(raw_line).strip_edges()
|
|
1299
|
+
if line.begins_with("class_name "):
|
|
1300
|
+
var class_tokens := line.split(" ", false)
|
|
1301
|
+
if class_tokens.size() >= 2:
|
|
1302
|
+
class_id = str(class_tokens[1])
|
|
1303
|
+
elif line.begins_with("extends "):
|
|
1304
|
+
var extends_tokens := line.split(" ", false)
|
|
1305
|
+
if extends_tokens.size() >= 2:
|
|
1306
|
+
base_class = str(extends_tokens[1])
|
|
1307
|
+
if class_id == "":
|
|
1308
|
+
return {}
|
|
1309
|
+
return {"class": class_id, "base": base_class, "path": script_path, "source": "script_scan"}
|
|
1310
|
+
|
|
1311
|
+
func _node_summary(node: Node, node_path: String, depth: int, max_depth: int) -> Dictionary:
|
|
1312
|
+
var children := []
|
|
1313
|
+
if max_depth < 0 or depth < max_depth:
|
|
1314
|
+
for child in node.get_children():
|
|
1315
|
+
if child is Node:
|
|
1316
|
+
children.append(_node_summary(child, str(node.get_path_to(child)), depth + 1, max_depth))
|
|
1317
|
+
var script = node.get_script()
|
|
1318
|
+
return {"path": node_path, "name": node.name, "type": node.get_class(), "scriptPath": script.resource_path if script else null, "childCount": node.get_child_count(), "children": children}
|
|
1319
|
+
|
|
1320
|
+
func _find_node(root: Node, node_path: String):
|
|
1321
|
+
if node_path == "" or node_path == ".":
|
|
1322
|
+
return root
|
|
1323
|
+
return root.get_node_or_null(NodePath(node_path))
|
|
1324
|
+
|
|
1325
|
+
func _set_owner_recursive(node: Node, owner: Node) -> void:
|
|
1326
|
+
node.owner = owner
|
|
1327
|
+
for child in node.get_children():
|
|
1328
|
+
if child is Node:
|
|
1329
|
+
_set_owner_recursive(child, owner)
|
|
1330
|
+
|
|
1331
|
+
func _res_path(value: String) -> String:
|
|
1332
|
+
if value.begins_with("res://"):
|
|
1333
|
+
return value
|
|
1334
|
+
return "res://" + value.trim_prefix("/")
|
|
1335
|
+
|
|
1336
|
+
func _ensure_parent_dir(res_path: String) -> void:
|
|
1337
|
+
var absolute := ProjectSettings.globalize_path(res_path)
|
|
1338
|
+
var dir := absolute.get_base_dir()
|
|
1339
|
+
DirAccess.make_dir_recursive_absolute(dir)
|
|
1340
|
+
|
|
1341
|
+
func _write_json(path: String, value: Dictionary) -> void:
|
|
1342
|
+
var file := FileAccess.open(path, FileAccess.WRITE)
|
|
1343
|
+
if file:
|
|
1344
|
+
file.store_string(JSON.stringify(value))
|
|
1345
|
+
file.close()
|
|
1346
|
+
|
|
1347
|
+
func _success(summary: String, output: Dictionary) -> Dictionary:
|
|
1348
|
+
return {"ok": true, "summary": summary, "reasons": [], "output": output}
|
|
1349
|
+
|
|
1350
|
+
func _failure(summary: String, reasons: Array, help := {}) -> Dictionary:
|
|
1351
|
+
return {"ok": false, "summary": summary, "reasons": reasons, "output": null, "help": help}
|
|
1352
|
+
|
|
1353
|
+
func _operation_help(kind: String) -> Dictionary:
|
|
1354
|
+
var examples := {
|
|
1355
|
+
"scene.tree": {"kind": "scene.tree", "scenePath": "res://path/to/scene.tscn", "maxDepth": 3},
|
|
1356
|
+
"node.inspect": {"kind": "node.inspect", "scenePath": "res://path/to/scene.tscn", "nodePath": ".", "includePropertyList": true, "propertyLimit": 40},
|
|
1357
|
+
"resource.read": {"kind": "resource.read", "resourcePath": "res://path/to/resource.tres", "propertyNames": ["name"]},
|
|
1358
|
+
"resource.properties": {"kind": "resource.properties", "resourcePath": "res://path/to/resource.tres"},
|
|
1359
|
+
"class.search": {"kind": "class.search", "query": "Label"},
|
|
1360
|
+
"class.info": {"kind": "class.info", "className": "Label", "propertyQuery": "text"},
|
|
1361
|
+
"editor.state": {"kind": "editor.state"},
|
|
1362
|
+
}
|
|
1363
|
+
var example = examples.get(kind, {"kind": "editor.state"})
|
|
1364
|
+
return {
|
|
1365
|
+
"usage": "Use editor.operation.run with operation.kind, or prefer focused MCP tools: editor.state, editor.scene.tree, editor.node.inspect, editor.resource.read, editor.resource.properties, editor.class.search, editor.class.info.",
|
|
1366
|
+
"helpCommand": "gdh editor operation run --help",
|
|
1367
|
+
"examples": [example],
|
|
1368
|
+
}
|
|
1369
|
+
`;
|
|
1370
|
+
}
|
|
1371
|
+
export function renderEditorOperationCatalogGdscript() {
|
|
1372
|
+
const runner = renderEditorOperationRunner();
|
|
1373
|
+
const marker = "func run_editor_operation(operation: Dictionary) -> Dictionary:";
|
|
1374
|
+
const markerIndex = runner.indexOf(marker);
|
|
1375
|
+
if (markerIndex < 0) {
|
|
1376
|
+
throw new Error("Editor operation runner is missing the catalog marker.");
|
|
1377
|
+
}
|
|
1378
|
+
return runner.slice(markerIndex);
|
|
1379
|
+
}
|
|
1380
|
+
//# sourceMappingURL=index.js.map
|