scad-gltf 0.1.4 → 0.2.2
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/README.md +50 -3
- package/bevy/README.md +46 -0
- package/bevy/examples/LICENSE +21 -0
- package/bevy/examples/README.md +58 -0
- package/bevy/examples/package.json +5 -0
- package/bevy/examples/packman_3d.js +2415 -0
- package/bin/scad-bevy.js +74 -0
- package/bin/scad-gen.js +25 -0
- package/bin/scad-godot.js +65 -222
- package/bin/scad-mcp.js +641 -2
- package/bin/scad-play.js +172 -0
- package/bin/scad-web.js +14 -166
- package/package.json +7 -2
- package/src/cli-utils.js +792 -0
- package/src/godot-utils.js +179 -0
package/src/cli-utils.js
ADDED
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import url from "node:url";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import readline from "node:readline";
|
|
6
|
+
import { execSync, spawn } from "node:child_process";
|
|
7
|
+
import zlib from "node:zlib";
|
|
8
|
+
import { getGodotBin } from "./godot-utils.js";
|
|
9
|
+
|
|
10
|
+
const __filename = fs.realpathSync(url.fileURLToPath(import.meta.url));
|
|
11
|
+
const __dirname = path.dirname(__filename);
|
|
12
|
+
|
|
13
|
+
export function hasStdinData() {
|
|
14
|
+
try {
|
|
15
|
+
const stat = fs.fstatSync(0); // 0 is the file descriptor for STDIN
|
|
16
|
+
return stat.isFIFO() || stat.isFile();
|
|
17
|
+
} catch (e) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function parseTaskAndOptions() {
|
|
23
|
+
let task = "";
|
|
24
|
+
let optionsStr = "{}";
|
|
25
|
+
|
|
26
|
+
if (hasStdinData()) {
|
|
27
|
+
try {
|
|
28
|
+
task = fs.readFileSync(0, "utf-8").trim();
|
|
29
|
+
} catch (e) {
|
|
30
|
+
console.error("Error reading from STDIN:", e);
|
|
31
|
+
}
|
|
32
|
+
if (process.argv[2]) optionsStr = process.argv[2];
|
|
33
|
+
} else {
|
|
34
|
+
if (process.argv[2]) task = process.argv[2];
|
|
35
|
+
if (process.argv[3]) optionsStr = process.argv[3];
|
|
36
|
+
}
|
|
37
|
+
return { task, optionsStr };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function writeToClipboard(text) {
|
|
41
|
+
const clipboardy = (await import("clipboardy")).default;
|
|
42
|
+
await clipboardy.write(text);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function waitForEnter(message) {
|
|
46
|
+
return new Promise((resolve) => {
|
|
47
|
+
if (!process.stdin.isTTY) {
|
|
48
|
+
try {
|
|
49
|
+
const tty = process.platform === "win32" ? "CONIN$" : "/dev/tty";
|
|
50
|
+
const fd = fs.openSync(tty, "rs");
|
|
51
|
+
process.stdout.write(message);
|
|
52
|
+
const buf = Buffer.alloc(1);
|
|
53
|
+
fs.readSync(fd, buf, 0, 1, null);
|
|
54
|
+
fs.closeSync(fd);
|
|
55
|
+
console.log();
|
|
56
|
+
resolve();
|
|
57
|
+
return;
|
|
58
|
+
} catch (e) {
|
|
59
|
+
console.log(
|
|
60
|
+
message +
|
|
61
|
+
" (Auto-continuing due to non-interactive terminal environment)",
|
|
62
|
+
);
|
|
63
|
+
resolve();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const rl = readline.createInterface({
|
|
69
|
+
input: process.stdin,
|
|
70
|
+
output: process.stdout,
|
|
71
|
+
});
|
|
72
|
+
rl.question(message, () => {
|
|
73
|
+
rl.close();
|
|
74
|
+
resolve();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Helper to ask a question and retrieve a text answer from the user
|
|
80
|
+
export function askQuestion(message) {
|
|
81
|
+
return new Promise((resolve) => {
|
|
82
|
+
if (!process.stdin.isTTY) {
|
|
83
|
+
try {
|
|
84
|
+
const tty = process.platform === "win32" ? "CONIN$" : "/dev/tty";
|
|
85
|
+
const fd = fs.openSync(tty, "rs");
|
|
86
|
+
process.stdout.write(message);
|
|
87
|
+
let answer = "";
|
|
88
|
+
const buf = Buffer.alloc(1);
|
|
89
|
+
while (true) {
|
|
90
|
+
const bytesRead = fs.readSync(fd, buf, 0, 1, null);
|
|
91
|
+
if (bytesRead === 0) break;
|
|
92
|
+
const char = buf.toString("utf-8");
|
|
93
|
+
if (char === "\n" || char === "\r") break;
|
|
94
|
+
answer += char;
|
|
95
|
+
}
|
|
96
|
+
fs.closeSync(fd);
|
|
97
|
+
console.log();
|
|
98
|
+
resolve(answer.trim());
|
|
99
|
+
return;
|
|
100
|
+
} catch (e) {
|
|
101
|
+
console.log(
|
|
102
|
+
message + " y (Auto-accepting due to non-interactive environment)",
|
|
103
|
+
);
|
|
104
|
+
resolve("y");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const rl = readline.createInterface({
|
|
110
|
+
input: process.stdin,
|
|
111
|
+
output: process.stdout,
|
|
112
|
+
});
|
|
113
|
+
rl.question(message, (answer) => {
|
|
114
|
+
rl.close();
|
|
115
|
+
resolve(answer.trim());
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function generateScadPreviewUrl(scadCode) {
|
|
121
|
+
try {
|
|
122
|
+
const deflated = zlib.deflateRawSync(Buffer.from(scadCode, "utf-8"));
|
|
123
|
+
const base64 = deflated
|
|
124
|
+
.toString("base64")
|
|
125
|
+
.replace(/\+/g, "-")
|
|
126
|
+
.replace(/\//g, "_")
|
|
127
|
+
.replace(/=+$/, "");
|
|
128
|
+
return `https://iliagrigorevdev.github.io/scad-gltf/#c${base64}`;
|
|
129
|
+
} catch (e) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function runAutomatedAIFlow(
|
|
135
|
+
apiKey,
|
|
136
|
+
baseUrl,
|
|
137
|
+
modelName,
|
|
138
|
+
systemPrompt,
|
|
139
|
+
inputRequest,
|
|
140
|
+
allowedTools = ["render_scad_model", "test_godot_project"],
|
|
141
|
+
projectType = "godot",
|
|
142
|
+
) {
|
|
143
|
+
if (!baseUrl) {
|
|
144
|
+
throw new Error("Base URL is required to run the automated AI flow.");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const outputFilename = `generate_${projectType}_project.js`;
|
|
148
|
+
const isRawScad = projectType === "gen";
|
|
149
|
+
const modelTag = modelName ? ` (${modelName})` : "";
|
|
150
|
+
|
|
151
|
+
console.log(`\n🚀 Starting automated AI generation${modelTag}...`);
|
|
152
|
+
|
|
153
|
+
let mcpClient = null;
|
|
154
|
+
let mcpTransport = null;
|
|
155
|
+
let openaiTools = [];
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const mcpClientIndex =
|
|
159
|
+
await import("@modelcontextprotocol/sdk/client/index.js");
|
|
160
|
+
const mcpClientStdio =
|
|
161
|
+
await import("@modelcontextprotocol/sdk/client/stdio.js");
|
|
162
|
+
|
|
163
|
+
console.log("🔌 Starting local SCAD MCP server...");
|
|
164
|
+
mcpTransport = new mcpClientStdio.StdioClientTransport({
|
|
165
|
+
command: process.execPath,
|
|
166
|
+
args: [path.resolve(__dirname, "../bin/scad-mcp.js")],
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
mcpClient = new mcpClientIndex.Client(
|
|
170
|
+
{ name: "scad-client", version: "1.0.0" },
|
|
171
|
+
{ capabilities: {} },
|
|
172
|
+
);
|
|
173
|
+
await mcpClient.connect(mcpTransport);
|
|
174
|
+
|
|
175
|
+
const { tools } = await mcpClient.listTools();
|
|
176
|
+
|
|
177
|
+
// Map MCP Tools to OpenAI expected standard function format
|
|
178
|
+
openaiTools = tools
|
|
179
|
+
.filter((t) => allowedTools.includes(t.name))
|
|
180
|
+
.map((t) => ({
|
|
181
|
+
type: "function",
|
|
182
|
+
function: {
|
|
183
|
+
name: t.name,
|
|
184
|
+
description: t.description,
|
|
185
|
+
parameters: t.inputSchema,
|
|
186
|
+
},
|
|
187
|
+
}));
|
|
188
|
+
|
|
189
|
+
console.log(
|
|
190
|
+
`✔️ MCP tools linked: ${openaiTools.map((t) => t.function.name).join(", ")}`,
|
|
191
|
+
);
|
|
192
|
+
} catch (err) {
|
|
193
|
+
console.warn(
|
|
194
|
+
"⚠️ Could not initialize MCP client. Proceeding without visual tool support.",
|
|
195
|
+
err.message,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let messages = [
|
|
200
|
+
{ role: "system", content: systemPrompt },
|
|
201
|
+
{ role: "user", content: inputRequest },
|
|
202
|
+
];
|
|
203
|
+
|
|
204
|
+
let iterations = 0;
|
|
205
|
+
const maxIterations = 30;
|
|
206
|
+
let disableImageSupport = false;
|
|
207
|
+
|
|
208
|
+
// Ensure the baseUrl points to the chat completions path
|
|
209
|
+
const endpoint = baseUrl.replace(/\/+$/, "") + "/chat/completions";
|
|
210
|
+
|
|
211
|
+
while (iterations < maxIterations) {
|
|
212
|
+
iterations++;
|
|
213
|
+
const waitingFrom = modelName ? ` from ${modelName}` : "";
|
|
214
|
+
console.log(`\n🧠 Waiting for response${waitingFrom}...`);
|
|
215
|
+
|
|
216
|
+
const body = {
|
|
217
|
+
messages: messages,
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
if (modelName) {
|
|
221
|
+
body.model = modelName;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (openaiTools.length > 0) {
|
|
225
|
+
body.tools = openaiTools;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
let response;
|
|
229
|
+
try {
|
|
230
|
+
response = await fetch(endpoint, {
|
|
231
|
+
method: "POST",
|
|
232
|
+
headers: {
|
|
233
|
+
"Content-Type": "application/json",
|
|
234
|
+
Authorization: `Bearer ${apiKey || "sk-no-key-required"}`,
|
|
235
|
+
},
|
|
236
|
+
body: JSON.stringify(body),
|
|
237
|
+
});
|
|
238
|
+
} catch (error) {
|
|
239
|
+
throw new Error(`AI API Network Error: ${error.message}`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (!response.ok) {
|
|
243
|
+
const errorText = await response.text();
|
|
244
|
+
|
|
245
|
+
// Auto-fallback if the model complains about image/vision input
|
|
246
|
+
const errLower = errorText.toLowerCase();
|
|
247
|
+
if (
|
|
248
|
+
!disableImageSupport &&
|
|
249
|
+
(errLower.includes("image") ||
|
|
250
|
+
errLower.includes("vision") ||
|
|
251
|
+
errLower.includes("invalid_request"))
|
|
252
|
+
) {
|
|
253
|
+
console.log(
|
|
254
|
+
"\n⚠️ API rejected image input. Falling back to text-only mode and retrying...",
|
|
255
|
+
);
|
|
256
|
+
disableImageSupport = true;
|
|
257
|
+
|
|
258
|
+
// Strip any existing images from previous messages
|
|
259
|
+
for (const msg of messages) {
|
|
260
|
+
if (Array.isArray(msg.content)) {
|
|
261
|
+
msg.content = msg.content.filter((c) => c.type !== "image_url");
|
|
262
|
+
// If only text parts remain, just keep them. If empty, add placeholder.
|
|
263
|
+
if (msg.content.length === 0) {
|
|
264
|
+
msg.content =
|
|
265
|
+
"[Images removed by system fallback due to lack of model vision support]";
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
iterations--;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
throw new Error(
|
|
275
|
+
`AI API Error: ${response.status} ${response.statusText}\n${errorText}`,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const data = await response.json();
|
|
280
|
+
const choice = data.choices[0];
|
|
281
|
+
const message = choice.message;
|
|
282
|
+
|
|
283
|
+
// Append the assistant message exactly as provided (including tool_calls)
|
|
284
|
+
messages.push(message);
|
|
285
|
+
|
|
286
|
+
if (message.tool_calls && message.tool_calls.length > 0) {
|
|
287
|
+
const toolImagesUserContent = [];
|
|
288
|
+
|
|
289
|
+
for (const toolCall of message.tool_calls) {
|
|
290
|
+
console.log(`\n⚙️ AI is using tool: ${toolCall.function.name}`);
|
|
291
|
+
|
|
292
|
+
let argsToLog = {};
|
|
293
|
+
try {
|
|
294
|
+
argsToLog = JSON.parse(toolCall.function.arguments);
|
|
295
|
+
} catch (e) {
|
|
296
|
+
console.error(
|
|
297
|
+
" ❌ Failed to parse tool arguments:",
|
|
298
|
+
toolCall.function.arguments,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Log truncated arguments to avoid flooding the terminal
|
|
303
|
+
for (const key in argsToLog) {
|
|
304
|
+
if (
|
|
305
|
+
typeof argsToLog[key] === "string" &&
|
|
306
|
+
argsToLog[key].length > 300
|
|
307
|
+
) {
|
|
308
|
+
argsToLog[key] =
|
|
309
|
+
argsToLog[key].substring(0, 300) +
|
|
310
|
+
"\n... [truncated for logging]";
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
console.log(
|
|
314
|
+
` Args:`,
|
|
315
|
+
JSON.stringify(argsToLog, null, 2).replace(/\n/g, "\n "),
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
if (!mcpClient) {
|
|
319
|
+
console.error(" ❌ MCP client not available.");
|
|
320
|
+
messages.push({
|
|
321
|
+
role: "tool",
|
|
322
|
+
tool_call_id: toolCall.id,
|
|
323
|
+
content: "MCP client not available.",
|
|
324
|
+
});
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
try {
|
|
329
|
+
const callArgs = JSON.parse(toolCall.function.arguments);
|
|
330
|
+
|
|
331
|
+
if (disableImageSupport) {
|
|
332
|
+
callArgs.return_images = false;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (
|
|
336
|
+
toolCall.function.name === "render_scad_model" &&
|
|
337
|
+
callArgs.scad_code
|
|
338
|
+
) {
|
|
339
|
+
const link = generateScadPreviewUrl(callArgs.scad_code);
|
|
340
|
+
if (link) {
|
|
341
|
+
console.log(
|
|
342
|
+
` 🔗 Intermediate Web Preview Link:\n ${link}`,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const result = await mcpClient.callTool({
|
|
348
|
+
name: toolCall.function.name,
|
|
349
|
+
arguments: callArgs,
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
let responseText = "";
|
|
353
|
+
let imageContents = [];
|
|
354
|
+
for (const item of result.content) {
|
|
355
|
+
if (item.type === "text") responseText += item.text + "\n";
|
|
356
|
+
if (item.type === "image") {
|
|
357
|
+
imageContents.push({
|
|
358
|
+
type: "image_url",
|
|
359
|
+
image_url: {
|
|
360
|
+
url: `data:${item.mimeType};base64,${item.data}`,
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
messages.push({
|
|
367
|
+
role: "tool",
|
|
368
|
+
tool_call_id: toolCall.id,
|
|
369
|
+
content: responseText || "Success",
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
if (imageContents.length > 0) {
|
|
373
|
+
toolImagesUserContent.push({
|
|
374
|
+
type: "text",
|
|
375
|
+
text: `Visual result of ${toolCall.function.name}:`,
|
|
376
|
+
});
|
|
377
|
+
toolImagesUserContent.push(...imageContents);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
console.log(`✔️ Tool ${toolCall.function.name} completed.`);
|
|
381
|
+
|
|
382
|
+
if (responseText) {
|
|
383
|
+
const lines = responseText.trim().split("\n");
|
|
384
|
+
const previewLines = lines.slice(0, 15);
|
|
385
|
+
console.log(` Response:\n | ${previewLines.join("\n | ")}`);
|
|
386
|
+
if (lines.length > 15)
|
|
387
|
+
console.log(` | ... [${lines.length - 15} more lines]`);
|
|
388
|
+
}
|
|
389
|
+
} catch (err) {
|
|
390
|
+
console.error(`⚠️ Tool error: ${err.message}`);
|
|
391
|
+
messages.push({
|
|
392
|
+
role: "tool",
|
|
393
|
+
tool_call_id: toolCall.id,
|
|
394
|
+
content: `Error: ${err.message}`,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Append accumulated tool images as a follow-up user message so the AI can "see" the visual outputs
|
|
400
|
+
if (toolImagesUserContent.length > 0) {
|
|
401
|
+
messages.push({
|
|
402
|
+
role: "user",
|
|
403
|
+
content: toolImagesUserContent,
|
|
404
|
+
});
|
|
405
|
+
const numImages = toolImagesUserContent.filter(
|
|
406
|
+
(c) => c.type === "image_url",
|
|
407
|
+
).length;
|
|
408
|
+
console.log(` 🖼️ Appended ${numImages} image(s) to AI context.`);
|
|
409
|
+
}
|
|
410
|
+
} else {
|
|
411
|
+
const finalText = message.content || "";
|
|
412
|
+
console.log("\n✅ AI proposed a final code version.");
|
|
413
|
+
|
|
414
|
+
let finalCode = "";
|
|
415
|
+
let extractedFilename = outputFilename;
|
|
416
|
+
|
|
417
|
+
// Extract the code payload based on the project type target
|
|
418
|
+
if (isRawScad) {
|
|
419
|
+
const match = finalText.match(/```(?:openscad|scad)?\n([\s\S]*?)```/i);
|
|
420
|
+
finalCode = match ? match[1].trim() : finalText.trim();
|
|
421
|
+
|
|
422
|
+
extractedFilename = "generated_model.scad";
|
|
423
|
+
const nameMatch = finalCode.match(
|
|
424
|
+
/\/\*\s*Model Name:\s*([^*]+)\s*\*\//i,
|
|
425
|
+
);
|
|
426
|
+
if (nameMatch) {
|
|
427
|
+
let safeName = nameMatch[1]
|
|
428
|
+
.trim()
|
|
429
|
+
.toLowerCase()
|
|
430
|
+
.replace(/[^a-z0-9_]/g, "_");
|
|
431
|
+
if (!safeName.endsWith(".scad")) safeName += ".scad";
|
|
432
|
+
extractedFilename = safeName;
|
|
433
|
+
}
|
|
434
|
+
} else {
|
|
435
|
+
const match = finalText.match(
|
|
436
|
+
/```(?:javascript|js|node)?\n([\s\S]*?)```/i,
|
|
437
|
+
);
|
|
438
|
+
finalCode = match ? match[1].trim() : finalText.trim();
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "scad-preview-"));
|
|
442
|
+
const tempFilePath = path.join(tempDir, extractedFilename);
|
|
443
|
+
let runProc = null;
|
|
444
|
+
|
|
445
|
+
try {
|
|
446
|
+
fs.writeFileSync(tempFilePath, finalCode, "utf-8");
|
|
447
|
+
|
|
448
|
+
if (isRawScad) {
|
|
449
|
+
console.log(
|
|
450
|
+
` Generating OpenSCAD file to preview in a temporary folder...`,
|
|
451
|
+
);
|
|
452
|
+
console.log(` Temp file: ${tempFilePath}`);
|
|
453
|
+
|
|
454
|
+
const link = generateScadPreviewUrl(finalCode);
|
|
455
|
+
if (link) {
|
|
456
|
+
console.log(`\n🔗 Direct Web Preview Link:\n ${link}\n`);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
console.log(
|
|
460
|
+
`\n🌐 Starting local viewer to preview ${extractedFilename}...`,
|
|
461
|
+
);
|
|
462
|
+
|
|
463
|
+
const scadServePath = path.resolve(__dirname, "../bin/scad-serve.js");
|
|
464
|
+
runProc = spawn(process.execPath, [scadServePath], {
|
|
465
|
+
cwd: tempDir,
|
|
466
|
+
stdio: "inherit",
|
|
467
|
+
detached: false,
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
471
|
+
} else {
|
|
472
|
+
console.log(
|
|
473
|
+
` Generating project files to preview in a temporary folder...`,
|
|
474
|
+
);
|
|
475
|
+
console.log(` Temp script: ${tempFilePath}`);
|
|
476
|
+
|
|
477
|
+
execSync(`node "${tempFilePath}"`, {
|
|
478
|
+
cwd: tempDir,
|
|
479
|
+
stdio: "pipe",
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
let projectDir = null;
|
|
483
|
+
let latestTime = 0;
|
|
484
|
+
const items = fs.readdirSync(tempDir);
|
|
485
|
+
|
|
486
|
+
for (const item of items) {
|
|
487
|
+
const itemPath = path.join(tempDir, item);
|
|
488
|
+
if (fs.statSync(itemPath).isDirectory()) {
|
|
489
|
+
const isGodot =
|
|
490
|
+
projectType === "godot" &&
|
|
491
|
+
fs.existsSync(path.join(itemPath, "project.godot"));
|
|
492
|
+
const isWeb =
|
|
493
|
+
projectType === "web" &&
|
|
494
|
+
fs.existsSync(path.join(itemPath, "package.json"));
|
|
495
|
+
const isBevy =
|
|
496
|
+
projectType === "bevy" &&
|
|
497
|
+
fs.existsSync(path.join(itemPath, "Cargo.toml"));
|
|
498
|
+
|
|
499
|
+
if (isGodot || isWeb || isBevy) {
|
|
500
|
+
const mtime = fs.statSync(itemPath).mtimeMs;
|
|
501
|
+
if (mtime > latestTime) {
|
|
502
|
+
latestTime = mtime;
|
|
503
|
+
projectDir = itemPath;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (!projectDir) {
|
|
510
|
+
console.log(` ❌ Could not find the generated project folder.`);
|
|
511
|
+
const expectedFile =
|
|
512
|
+
projectType === "godot"
|
|
513
|
+
? "project.godot"
|
|
514
|
+
: projectType === "bevy"
|
|
515
|
+
? "Cargo.toml"
|
|
516
|
+
: "package.json";
|
|
517
|
+
messages.push({
|
|
518
|
+
role: "user",
|
|
519
|
+
content: `Your script successfully executed, but no valid project directory was found. Make sure your script creates a root folder and generates the correct required files (like ${expectedFile}) inside it.`,
|
|
520
|
+
});
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
console.log(` Project folder: ${projectDir}`);
|
|
525
|
+
|
|
526
|
+
if (projectType === "godot") {
|
|
527
|
+
console.log(
|
|
528
|
+
`\n🎮 Launching project visually: ${path.basename(projectDir)}`,
|
|
529
|
+
);
|
|
530
|
+
const godotBin = getGodotBin();
|
|
531
|
+
runProc = spawn(godotBin, ["--path", projectDir], {
|
|
532
|
+
stdio: "ignore",
|
|
533
|
+
detached: true,
|
|
534
|
+
});
|
|
535
|
+
runProc.unref();
|
|
536
|
+
} else if (projectType === "web") {
|
|
537
|
+
console.log(
|
|
538
|
+
`\n🌐 Installing dependencies for: ${path.basename(projectDir)}...`,
|
|
539
|
+
);
|
|
540
|
+
try {
|
|
541
|
+
execSync("npm install", { cwd: projectDir, stdio: "inherit" });
|
|
542
|
+
} catch (e) {
|
|
543
|
+
console.log(
|
|
544
|
+
"⚠️ npm install failed, trying to continue anyway...",
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
console.log(`\n🌐 Starting dev server...`);
|
|
549
|
+
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
550
|
+
runProc = spawn(npmCmd, ["run", "dev"], {
|
|
551
|
+
cwd: projectDir,
|
|
552
|
+
stdio: "inherit",
|
|
553
|
+
detached: false,
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
557
|
+
} else if (projectType === "bevy") {
|
|
558
|
+
console.log(
|
|
559
|
+
`\n🦀 Building and running Bevy project: ${path.basename(projectDir)}...`,
|
|
560
|
+
);
|
|
561
|
+
const cargoCmd =
|
|
562
|
+
process.platform === "win32" ? "cargo.exe" : "cargo";
|
|
563
|
+
runProc = spawn(cargoCmd, ["run"], {
|
|
564
|
+
cwd: projectDir,
|
|
565
|
+
stdio: "inherit",
|
|
566
|
+
detached: false,
|
|
567
|
+
});
|
|
568
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const feedback = await askQuestion(
|
|
573
|
+
"\nAre you happy with this result? (Type 'y' to accept, or type feedback for the AI to fix): ",
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
if (
|
|
577
|
+
feedback.toLowerCase() === "y" ||
|
|
578
|
+
feedback.toLowerCase() === "yes" ||
|
|
579
|
+
feedback === ""
|
|
580
|
+
) {
|
|
581
|
+
fs.copyFileSync(
|
|
582
|
+
tempFilePath,
|
|
583
|
+
path.resolve(process.cwd(), extractedFilename),
|
|
584
|
+
);
|
|
585
|
+
|
|
586
|
+
console.log(
|
|
587
|
+
`\n🎉 Success! Final file finalized and written to: ${extractedFilename}`,
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
if (!isRawScad) {
|
|
591
|
+
console.log(
|
|
592
|
+
`▶️ Run it again later to unpack the final project with: node ${extractedFilename}`,
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (mcpTransport) {
|
|
597
|
+
try {
|
|
598
|
+
await mcpTransport.close();
|
|
599
|
+
} catch (e) {}
|
|
600
|
+
}
|
|
601
|
+
break;
|
|
602
|
+
} else {
|
|
603
|
+
console.log("\n🔄 Sending your feedback back to the LLM...");
|
|
604
|
+
messages.push({
|
|
605
|
+
role: "user",
|
|
606
|
+
content: `I reviewed the current version. Here is my feedback to improve it:\n\n${feedback}\n\nPlease implement these fixes and output an updated ${isRawScad ? "OpenSCAD block" : "Node.js script"}.`,
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
} catch (err) {
|
|
610
|
+
const stderr = err.stderr ? err.stderr.toString() : err.message;
|
|
611
|
+
console.error(
|
|
612
|
+
` ❌ Failed to execute/preview generated code:\n${stderr}`,
|
|
613
|
+
);
|
|
614
|
+
messages.push({
|
|
615
|
+
role: "user",
|
|
616
|
+
content: `Your generated code threw an error when I tried to run/preview it locally:\n${stderr}\n\nPlease fix the ${isRawScad ? "OpenSCAD code" : "Node.js script"}.`,
|
|
617
|
+
});
|
|
618
|
+
} finally {
|
|
619
|
+
if (runProc) {
|
|
620
|
+
try {
|
|
621
|
+
runProc.kill();
|
|
622
|
+
} catch (e) {}
|
|
623
|
+
}
|
|
624
|
+
try {
|
|
625
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
626
|
+
} catch (e) {}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
if (iterations >= maxIterations) {
|
|
632
|
+
console.log(
|
|
633
|
+
`\n⚠️ Reached maximum iterations (${maxIterations}). Terminating.`,
|
|
634
|
+
);
|
|
635
|
+
if (mcpTransport) {
|
|
636
|
+
try {
|
|
637
|
+
await mcpTransport.close();
|
|
638
|
+
} catch (e) {}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
export async function runCliApp({
|
|
644
|
+
projectType,
|
|
645
|
+
buildSystemPrompt,
|
|
646
|
+
buildInputRequest,
|
|
647
|
+
allowedTools = ["render_scad_model"],
|
|
648
|
+
}) {
|
|
649
|
+
if (!projectType) {
|
|
650
|
+
throw new Error("projectType is required.");
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const appName = `scad-${projectType}`;
|
|
654
|
+
const isRawScad = projectType === "gen";
|
|
655
|
+
|
|
656
|
+
const { task, optionsStr } = parseTaskAndOptions();
|
|
657
|
+
|
|
658
|
+
if (!task) {
|
|
659
|
+
console.error("Error: Task parameter is required.");
|
|
660
|
+
if (isRawScad) {
|
|
661
|
+
console.error(
|
|
662
|
+
`Usage: ${appName} "<description of the 3D model to generate>" [options_json]`,
|
|
663
|
+
);
|
|
664
|
+
console.error(` or: echo "<description>" | ${appName} [options_json]`);
|
|
665
|
+
console.error("");
|
|
666
|
+
console.error("Examples with JSON options (Automated AI flow):");
|
|
667
|
+
console.error(
|
|
668
|
+
` ${appName} "A modular sci-fi corridor piece" '{"openaiApiKey": "sk-...", "openaiModel": "gpt-4o"}'`,
|
|
669
|
+
);
|
|
670
|
+
console.error(
|
|
671
|
+
` ${appName} "A medieval longsword" '{"openaiBaseUrl": "http://127.0.0.1:8080/v1", "openaiModel": "llama-3"}'`,
|
|
672
|
+
);
|
|
673
|
+
} else {
|
|
674
|
+
console.error(
|
|
675
|
+
`Usage: ${appName} "<description of the game to generate>" [options_json]`,
|
|
676
|
+
);
|
|
677
|
+
console.error(` or: echo "<description>" | ${appName} [options_json]`);
|
|
678
|
+
console.error("");
|
|
679
|
+
console.error("Examples with JSON options (Automated AI flow):");
|
|
680
|
+
console.error(
|
|
681
|
+
` ${appName} "A simple 3D game" '{"openaiApiKey": "sk-...", "openaiModel": "gpt-4o"}'`,
|
|
682
|
+
);
|
|
683
|
+
console.error(
|
|
684
|
+
` ${appName} "A simple 3D game" '{"openaiBaseUrl": "http://127.0.0.1:8080/v1", "openaiModel": "llama-3"}'`,
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
process.exit(1);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Parse Options JSON
|
|
691
|
+
// Disable heavy PBR features by default
|
|
692
|
+
let options = {
|
|
693
|
+
transmission: false,
|
|
694
|
+
clearcoat: false,
|
|
695
|
+
sheen: false,
|
|
696
|
+
iridescence: false,
|
|
697
|
+
};
|
|
698
|
+
|
|
699
|
+
if (optionsStr) {
|
|
700
|
+
try {
|
|
701
|
+
const parsed = JSON.parse(optionsStr);
|
|
702
|
+
options = { ...options, ...parsed }; // User provided options override defaults
|
|
703
|
+
} catch (e) {
|
|
704
|
+
console.error(`Invalid JSON options: ${optionsStr}`);
|
|
705
|
+
process.exit(1);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
if (!isRawScad) {
|
|
710
|
+
// Disable the modelName instructions specifically for these wrapper contexts
|
|
711
|
+
options.modelName = false;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
let promptRules = "";
|
|
715
|
+
try {
|
|
716
|
+
const { generatePrompt } = await import("./prompt.js");
|
|
717
|
+
promptRules = generatePrompt(
|
|
718
|
+
isRawScad ? task : "the 3D assets for the game",
|
|
719
|
+
options,
|
|
720
|
+
);
|
|
721
|
+
} catch (e) {
|
|
722
|
+
console.error("Error generating prompt rules from prompt.js:");
|
|
723
|
+
console.error(e);
|
|
724
|
+
process.exit(1);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const systemClipboardOutput = buildSystemPrompt(promptRules, options);
|
|
728
|
+
const inputRequestOutput = buildInputRequest
|
|
729
|
+
? buildInputRequest(task, promptRules)
|
|
730
|
+
: promptRules;
|
|
731
|
+
|
|
732
|
+
// Check if API Key flows should be initialized instead of manual clipboard
|
|
733
|
+
const openaiApiKey = options.openaiApiKey || process.env.OPENAI_API_KEY;
|
|
734
|
+
const openaiBaseUrl = options.openaiBaseUrl || process.env.OPENAI_BASE_URL;
|
|
735
|
+
const openaiModel = options.openaiModel || process.env.OPENAI_MODEL;
|
|
736
|
+
|
|
737
|
+
if (openaiApiKey || openaiBaseUrl) {
|
|
738
|
+
await runAutomatedAIFlow(
|
|
739
|
+
openaiApiKey,
|
|
740
|
+
openaiBaseUrl,
|
|
741
|
+
openaiModel,
|
|
742
|
+
systemClipboardOutput,
|
|
743
|
+
inputRequestOutput,
|
|
744
|
+
allowedTools,
|
|
745
|
+
projectType,
|
|
746
|
+
);
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
if (isRawScad) {
|
|
751
|
+
try {
|
|
752
|
+
await writeToClipboard(inputRequestOutput);
|
|
753
|
+
console.log(
|
|
754
|
+
"✔️ Input request and syntax rules have been copied to the clipboard. You can now paste it into your LLM.",
|
|
755
|
+
);
|
|
756
|
+
} catch (err) {
|
|
757
|
+
console.error("Error: Failed to copy input request to the clipboard.");
|
|
758
|
+
console.error(err.message);
|
|
759
|
+
process.exit(1);
|
|
760
|
+
}
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// Write to System Clipboard (Part 1: System Instructions)
|
|
765
|
+
try {
|
|
766
|
+
await writeToClipboard(systemClipboardOutput);
|
|
767
|
+
console.log("✔️ System instructions have been copied to the clipboard.");
|
|
768
|
+
} catch (err) {
|
|
769
|
+
console.error(
|
|
770
|
+
"Error: Failed to copy system instructions to the clipboard.",
|
|
771
|
+
);
|
|
772
|
+
console.error(err.message);
|
|
773
|
+
process.exit(1);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Await user confirmation
|
|
777
|
+
await waitForEnter(
|
|
778
|
+
"Please paste the system instructions into your LLM, then press ENTER to copy your input request...",
|
|
779
|
+
);
|
|
780
|
+
|
|
781
|
+
// Write to System Clipboard (Part 2: Input Request)
|
|
782
|
+
try {
|
|
783
|
+
await writeToClipboard(inputRequestOutput);
|
|
784
|
+
console.log(
|
|
785
|
+
"✔️ Input request has been copied to the clipboard. You can now paste it into your LLM.",
|
|
786
|
+
);
|
|
787
|
+
} catch (err) {
|
|
788
|
+
console.error("Error: Failed to copy input request to the clipboard.");
|
|
789
|
+
console.error(err.message);
|
|
790
|
+
process.exit(1);
|
|
791
|
+
}
|
|
792
|
+
}
|