@punal_manalan/blender-project-harness 0.0.0-stage → 1.0.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.
Files changed (91) hide show
  1. package/.kilo/agent/blender-grader.md +197 -0
  2. package/.kilo/agent/blender-worker.md +127 -0
  3. package/.kilo/blender-project-harness/config.example.json +71 -0
  4. package/.kilo/blender-project-harness/tools/harness/EXPECTED_FLOW.md +656 -0
  5. package/.kilo/blender-project-harness/tools/harness/blender-operations.mjs +882 -0
  6. package/.kilo/blender-project-harness/tools/harness/blender-operations.test.mjs +550 -0
  7. package/.kilo/blender-project-harness/tools/harness/blender.mjs +301 -0
  8. package/.kilo/blender-project-harness/tools/harness/blender.test.mjs +87 -0
  9. package/.kilo/blender-project-harness/tools/harness/cli.mjs +127 -0
  10. package/.kilo/blender-project-harness/tools/harness/documents.mjs +201 -0
  11. package/.kilo/blender-project-harness/tools/harness/download.mjs +94 -0
  12. package/.kilo/blender-project-harness/tools/harness/download.test.mjs +63 -0
  13. package/.kilo/blender-project-harness/tools/harness/engine.mjs +1866 -0
  14. package/.kilo/blender-project-harness/tools/harness/engine.test.mjs +2219 -0
  15. package/.kilo/blender-project-harness/tools/harness/intake.mjs +571 -0
  16. package/.kilo/blender-project-harness/tools/harness/intake.test.mjs +181 -0
  17. package/.kilo/blender-project-harness/tools/harness/package-lock.json +1227 -0
  18. package/.kilo/blender-project-harness/tools/harness/package.json +15 -0
  19. package/.kilo/blender-project-harness/tools/harness/plugin.mjs +550 -0
  20. package/.kilo/blender-project-harness/tools/harness/plugin.test.mjs +526 -0
  21. package/.kilo/blender-project-harness/tools/harness/proxy.mjs +222 -0
  22. package/.kilo/blender-project-harness/tools/harness/proxy.test.mjs +217 -0
  23. package/.kilo/blender-project-harness/tools/harness/scaffold.mjs +83 -0
  24. package/.kilo/blender-project-harness/tools/harness/scaffold.test.mjs +74 -0
  25. package/.kilo/blender-project-harness/tools/harness/server.mjs +24 -0
  26. package/.kilo/blender-project-harness/tools/harness/shell-policy.mjs +196 -0
  27. package/.kilo/blender-project-harness/tools/harness/shell-policy.test.mjs +68 -0
  28. package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/ASSETS.md +29 -0
  29. package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/DECISIONS.md +8 -0
  30. package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/Goals/01-project-brief.md +71 -0
  31. package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/Goals/README.md +17 -0
  32. package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/INTAKE.md +8 -0
  33. package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/README.md +57 -0
  34. package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/REQUIREMENTS.md +64 -0
  35. package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/REVIEW.md +29 -0
  36. package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/References/README.md +18 -0
  37. package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/Reviews/README.md +48 -0
  38. package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/TASKSTRACKER.md +10 -0
  39. package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/Tasks/README.md +4 -0
  40. package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/WORKFLOW.md +36 -0
  41. package/.kilo/blender-project-harness/tools/harness/templates/project/README.md +130 -0
  42. package/.kilo/blender-project-harness/tools/harness/templates/project/assets/README.md +31 -0
  43. package/.kilo/blender-project-harness/tools/harness/templates/project/assets/audio/.gitkeep +0 -0
  44. package/.kilo/blender-project-harness/tools/harness/templates/project/assets/external/README.md +43 -0
  45. package/.kilo/blender-project-harness/tools/harness/templates/project/assets/external/SOURCE_TEMPLATE.md +18 -0
  46. package/.kilo/blender-project-harness/tools/harness/templates/project/assets/hdri/.gitkeep +0 -0
  47. package/.kilo/blender-project-harness/tools/harness/templates/project/assets/models/.gitkeep +0 -0
  48. package/.kilo/blender-project-harness/tools/harness/templates/project/assets/textures/.gitkeep +0 -0
  49. package/.kilo/blender-project-harness/tools/harness/templates/project/backups/.gitkeep +0 -0
  50. package/.kilo/blender-project-harness/tools/harness/templates/project/backups/README.md +5 -0
  51. package/.kilo/blender-project-harness/tools/harness/templates/project/exports/.gitkeep +0 -0
  52. package/.kilo/blender-project-harness/tools/harness/templates/project/exports/README.md +11 -0
  53. package/.kilo/blender-project-harness/tools/harness/templates/project/renders/README.md +7 -0
  54. package/.kilo/blender-project-harness/tools/harness/templates/project/renders/final/.gitkeep +0 -0
  55. package/.kilo/blender-project-harness/tools/harness/templates/project/renders/wip/.gitkeep +0 -0
  56. package/.kilo/blender-project-harness/tools/harness/templates/project/scenes/README.md +10 -0
  57. package/.kilo/blender-project-harness/tools/harness/templates/project/scripts/README.md +16 -0
  58. package/.kilo/blender-project-harness/tools/harness/test-fixture.mjs +91 -0
  59. package/.kilo/blender-project-harness/tools/harness/workspace.mjs +30 -0
  60. package/.kilo/blender-project-harness/tools/harness/workspace.test.mjs +31 -0
  61. package/.kilo/blender-project-harness/tools/test_blender_tools.py +427 -0
  62. package/.kilo/plugins/blender-workflow.js +3 -0
  63. package/.kilo/skills/blender-assets/SKILL.md +99 -0
  64. package/.kilo/skills/blender-mcp/SKILL.md +147 -0
  65. package/.kilo/skills/blender-mcp/references/README.md +22 -0
  66. package/.kilo/skills/blender-mcp/references/animation.md +83 -0
  67. package/.kilo/skills/blender-mcp/references/bpy.md +97 -0
  68. package/.kilo/skills/blender-mcp/references/level-design.md +53 -0
  69. package/.kilo/skills/blender-mcp/references/materials.md +80 -0
  70. package/.kilo/skills/blender-mcp/references/retopology.md +104 -0
  71. package/.kilo/skills/blender-mcp/references/rigging.md +123 -0
  72. package/.kilo/skills/blender-mcp/references/scene.md +72 -0
  73. package/.kilo/skills/blender-project-goal/SKILL.md +115 -0
  74. package/.kilo/skills/blender-project-grading/SKILL.md +184 -0
  75. package/.kilo/skills/blender-project-readme/SKILL.md +46 -0
  76. package/.kilo/skills/blender-project-requirements/SKILL.md +84 -0
  77. package/.kilo/skills/blender-project-scaffold/SKILL.md +59 -0
  78. package/.kilo/skills/blender-project-workflow/SKILL.md +96 -0
  79. package/.kilo/skills/blender-subagents/SKILL.md +204 -0
  80. package/.kilo/skills/blender-workflow-harness/SKILL.md +187 -0
  81. package/AGENTS.md +234 -0
  82. package/Blender/README.md +30 -0
  83. package/CHANGELOG.md +22 -0
  84. package/LICENSE +21 -0
  85. package/README.md +368 -2
  86. package/THIRD_PARTY_NOTICES.md +51 -0
  87. package/bin/blender-project-harness.mjs +4 -0
  88. package/installer/cli.mjs +116 -0
  89. package/installer/install.mjs +325 -0
  90. package/installer/install.test.mjs +134 -0
  91. package/package.json +58 -6
@@ -0,0 +1,301 @@
1
+ import fs from 'node:fs';
2
+ import net from 'node:net';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { spawn, spawnSync } from 'node:child_process';
6
+ import { setTimeout as delay } from 'node:timers/promises';
7
+
8
+ const BOOTSTRAP = `import bpy
9
+ import os
10
+ import socket
11
+ import traceback
12
+ from bpy.app.handlers import persistent
13
+
14
+ MAX_START_ATTEMPTS = 5
15
+ RETRY_INTERVAL = 0.5
16
+ _start_attempts = 0
17
+
18
+
19
+ def requested_port():
20
+ value = os.environ.get("BLENDER_PORT", "").strip()
21
+ if not value.isdigit() or not 1024 <= int(value) <= 65535:
22
+ raise RuntimeError(
23
+ "BLENDER_PORT is missing or invalid; launch Blender through the harness so the port matches .kilo/kilo.jsonc"
24
+ )
25
+ return int(value)
26
+
27
+
28
+ def apply_port(*_args):
29
+ global _start_attempts
30
+ _start_attempts += 1
31
+ try:
32
+ scene = bpy.context.scene
33
+ if scene is None:
34
+ raise RuntimeError("No active scene available for MCP startup")
35
+ port = requested_port()
36
+
37
+ server = getattr(bpy.types, "blendermcp_server", None)
38
+ if server is not None or getattr(scene, "blendermcp_server_running", False):
39
+ try:
40
+ if "FINISHED" not in bpy.ops.blendermcp.stop_server():
41
+ raise RuntimeError("MCP stop operator did not finish")
42
+ except Exception:
43
+ if server is None:
44
+ raise
45
+ server.stop()
46
+ remaining = getattr(bpy.types, "blendermcp_server", None)
47
+ if remaining is not None:
48
+ if getattr(remaining, "running", False):
49
+ remaining.stop()
50
+ if getattr(remaining, "running", False):
51
+ raise RuntimeError("Existing MCP server did not stop")
52
+ del bpy.types.blendermcp_server
53
+ scene.blendermcp_server_running = False
54
+
55
+ scene.blendermcp_port = port
56
+ result = bpy.ops.blendermcp.start_server()
57
+ server = getattr(bpy.types, "blendermcp_server", None)
58
+ listener = getattr(server, "socket", None)
59
+ if ("FINISHED" not in result or not getattr(server, "running", False)
60
+ or not getattr(scene, "blendermcp_server_running", False)
61
+ or getattr(server, "port", None) != port or listener is None
62
+ or listener.getsockname()[1] != port
63
+ or not listener.getsockopt(socket.SOL_SOCKET, socket.SO_ACCEPTCONN)):
64
+ raise RuntimeError(f"MCP server is not listening on requested port {port}")
65
+ print(f"BlenderMCP: listening on port {port}", flush=True)
66
+ except Exception:
67
+ traceback.print_exc()
68
+ if _start_attempts < MAX_START_ATTEMPTS:
69
+ return RETRY_INTERVAL
70
+ print(f"BlenderMCP: startup stopped after {MAX_START_ATTEMPTS} attempts", flush=True)
71
+ return None
72
+
73
+
74
+ def schedule_apply_port(*_args):
75
+ global _start_attempts
76
+ _start_attempts = 0
77
+ if not bpy.app.timers.is_registered(apply_port):
78
+ bpy.app.timers.register(apply_port, first_interval=0.4, persistent=True)
79
+
80
+
81
+ @persistent
82
+ def restart_after_load(*_args):
83
+ schedule_apply_port()
84
+
85
+
86
+ restart_after_load._kilo_blender_mcp_apply_port = apply_port
87
+ for handler in list(bpy.app.handlers.load_post):
88
+ previous_timer = getattr(handler, "_kilo_blender_mcp_apply_port", None)
89
+ if previous_timer is not None:
90
+ if bpy.app.timers.is_registered(previous_timer):
91
+ bpy.app.timers.unregister(previous_timer)
92
+ bpy.app.handlers.load_post.remove(handler)
93
+ bpy.app.handlers.load_post.append(restart_after_load)
94
+ schedule_apply_port()
95
+ `;
96
+
97
+ export const fail = (message) => { throw new Error(`Blender harness: ${message}`); };
98
+
99
+ export const instanceFile = (home) => path.join(home, 'blender-instance.json');
100
+ const launchLockFile = (home) => path.join(home, 'blender-launch.lock');
101
+
102
+ export function readWorkspacePort(root) {
103
+ const file = path.join(root, '.kilo', 'kilo.jsonc');
104
+ if (!fs.existsSync(file)) fail(`workspace MCP config not found: ${file}`);
105
+ const text = fs.readFileSync(file, 'utf8')
106
+ .replace(/"(?:\\.|[^"\\])*"|\/\/[^\r\n]*|\/\*[\s\S]*?\*\//g, (token) => (token.startsWith('"') ? token : ' '));
107
+ const match = /"BLENDER_PORT"\s*:\s*"(\d+)"/.exec(text);
108
+ if (!match) fail('.kilo/kilo.jsonc lacks BLENDER_PORT');
109
+ const port = Number(match[1]);
110
+ if (!Number.isInteger(port) || port < 1024 || port > 65535) fail(`invalid BLENDER_PORT in .kilo/kilo.jsonc: ${match[1]}`);
111
+ return port;
112
+ }
113
+
114
+ function exeFile(candidate) {
115
+ try {
116
+ const full = path.resolve(candidate);
117
+ return fs.statSync(full).isFile() && path.extname(full).toLowerCase() === '.exe' ? full : null;
118
+ } catch { return null; }
119
+ }
120
+
121
+ function walkExecutables(directory, depth = 0) {
122
+ if (depth > 3) return [];
123
+ let entries;
124
+ try { entries = fs.readdirSync(directory, { withFileTypes: true }); } catch { return []; }
125
+ const found = [];
126
+ for (const entry of entries) {
127
+ const full = path.join(directory, entry.name);
128
+ if (entry.isFile() && entry.name.toLowerCase() === 'blender.exe') found.push(full);
129
+ else if (entry.isDirectory() && !entry.isSymbolicLink()) found.push(...walkExecutables(full, depth + 1));
130
+ }
131
+ return found;
132
+ }
133
+
134
+ export function findBlender(env = process.env) {
135
+ const candidates = [];
136
+ if (env.BLENDER_EXE) candidates.push(env.BLENDER_EXE);
137
+ const where = spawnSync('where.exe', ['blender.exe'], { encoding: 'utf8', windowsHide: true, timeout: 15000 });
138
+ if (where.status === 0) candidates.push(...where.stdout.split(/\r?\n/).filter(Boolean));
139
+ const roots = [
140
+ env.ProgramFiles && path.join(env.ProgramFiles, 'Blender Foundation'),
141
+ env['ProgramFiles(x86)'] && path.join(env['ProgramFiles(x86)'], 'Blender Foundation'),
142
+ env.LOCALAPPDATA && path.join(env.LOCALAPPDATA, 'Programs', 'Blender Foundation'),
143
+ env.ProgramFiles && path.join(env.ProgramFiles, 'Steam', 'steamapps', 'common', 'Blender'),
144
+ env.USERPROFILE && path.join(env.USERPROFILE, 'scoop', 'apps', 'blender', 'current'),
145
+ ].filter(Boolean);
146
+ for (const directory of roots) {
147
+ if (fs.existsSync(directory)) candidates.push(...walkExecutables(directory));
148
+ }
149
+ const unique = [...new Set(candidates.map((candidate) => exeFile(candidate)).filter(Boolean))];
150
+ if (!unique.length) fail('Blender not found. Set BLENDER_EXE to the full blender.exe path or add Blender to PATH.');
151
+ return unique
152
+ .map((file) => ({ file, version: spawnSync('powershell.exe', ['-NoLogo', '-NoProfile', '-Command',
153
+ `(Get-Item -LiteralPath '${file.replaceAll("'", "''")}').VersionInfo.ProductVersion`], { encoding: 'utf8', windowsHide: true, timeout: 15000 }).stdout?.trim() ?? '' }))
154
+ .sort((a, b) => b.version.localeCompare(a.version, undefined, { numeric: true }))[0].file;
155
+ }
156
+
157
+ export function isAlive(pid) {
158
+ if (!Number.isInteger(pid) || pid <= 0) return false;
159
+ try { process.kill(pid, 0); return true; }
160
+ catch (error) { return error.code === 'EPERM'; }
161
+ }
162
+
163
+ export function readInstance(home) {
164
+ try { return JSON.parse(fs.readFileSync(instanceFile(home), 'utf8')); }
165
+ catch { return null; }
166
+ }
167
+
168
+ export function instanceStatus(home) {
169
+ const instance = readInstance(home);
170
+ if (!instance) return { running: false };
171
+ if (!isAlive(instance.pid)) {
172
+ try { fs.rmSync(instanceFile(home), { force: true }); } catch { /* recovery */ }
173
+ return { running: false, stalePid: instance.pid };
174
+ }
175
+ return { running: true, ...instance };
176
+ }
177
+
178
+ function writeInstance(home, instance) {
179
+ const file = instanceFile(home);
180
+ const temporary = `${file}.${process.pid}.tmp`;
181
+ fs.writeFileSync(temporary, `${JSON.stringify(instance, null, 2)}\n`);
182
+ fs.renameSync(temporary, file);
183
+ }
184
+
185
+ export function acquireLaunchLock(home) {
186
+ fs.mkdirSync(home, { recursive: true });
187
+ const file = launchLockFile(home);
188
+ for (let attempt = 0; attempt < 2; attempt += 1) {
189
+ try {
190
+ const fd = fs.openSync(file, 'wx');
191
+ fs.writeSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() }));
192
+ fs.closeSync(fd);
193
+ return () => { try { fs.rmSync(file, { force: true }); } catch { /* lock already gone */ } };
194
+ } catch (error) {
195
+ if (error.code !== 'EEXIST') throw error;
196
+ let stale = false;
197
+ try { stale = Date.now() - fs.statSync(file).mtimeMs > 60000; } catch { stale = true; }
198
+ if (stale) { fs.rmSync(file, { force: true }); continue; }
199
+ fail('another launch is in progress; wait for it to finish');
200
+ }
201
+ }
202
+ fail('launch lock could not be acquired');
203
+ }
204
+
205
+ export function portListening(port, timeout = 500) {
206
+ return new Promise((resolve) => {
207
+ const socket = net.connect({ host: '127.0.0.1', port });
208
+ const done = (value) => { socket.destroy(); resolve(value); };
209
+ socket.setTimeout(timeout);
210
+ socket.once('connect', () => done(true));
211
+ socket.once('timeout', () => done(false));
212
+ socket.once('error', () => done(false));
213
+ });
214
+ }
215
+
216
+ export function logTail(file, lines = 12) {
217
+ try { return fs.readFileSync(file, 'utf8').trimEnd().split(/\r?\n/).slice(-lines).join('\n'); }
218
+ catch { return ''; }
219
+ }
220
+
221
+ function killProcess(pid) {
222
+ if (!isAlive(pid)) return;
223
+ if (process.platform === 'win32') spawnSync('taskkill.exe', ['/PID', String(pid), '/T', '/F'], { windowsHide: true, timeout: 15000 });
224
+ else { try { process.kill(-pid, 'SIGTERM'); } catch { try { process.kill(pid, 'SIGTERM'); } catch { /* already gone */ } } }
225
+ }
226
+
227
+ export async function launchProject({ root, home, project, scene, session, env = process.env }) {
228
+ const projectPath = path.resolve(root, project);
229
+ const projectsRoot = path.resolve(root, 'Blender');
230
+ if (path.dirname(projectPath) !== projectsRoot) fail(`project must live under ${projectsRoot}: ${project}`);
231
+ if (!fs.existsSync(projectPath) || !fs.statSync(projectPath).isDirectory()) fail(`project not found: ${project}`);
232
+
233
+ const port = readWorkspacePort(root);
234
+ const scenePath = path.resolve(root, scene);
235
+ if (!scenePath.startsWith(`${projectPath}${path.sep}`)) fail(`scene must stay inside ${project}`);
236
+ const existing = instanceStatus(home);
237
+ if (existing.running) {
238
+ if (existing.project === project && existing.scene === scene && existing.port === port) return { started: false, ...existing };
239
+ fail(`only one Blender instance may run from this workspace; PID ${existing.pid} is using ${existing.project}`);
240
+ }
241
+ if (await portListening(port)) fail(`port ${port} is already in use; another process may be holding the MCP socket`);
242
+ const blender = findBlender(env);
243
+
244
+ const logsRoot = path.join(projectPath, 'logs');
245
+ fs.mkdirSync(logsRoot, { recursive: true });
246
+ const logName = `blender-${new Date().toISOString().replace(/[-:]/g, '').replace(/\..*/, '')}-${process.pid}.log`;
247
+ const logPath = path.join(logsRoot, logName);
248
+ fs.mkdirSync(home, { recursive: true });
249
+ const bootstrapPath = path.join(home, 'blender-bootstrap.py');
250
+ fs.writeFileSync(bootstrapPath, BOOTSTRAP);
251
+
252
+ const release = acquireLaunchLock(home);
253
+ try {
254
+ if (instanceStatus(home).running) fail('another Blender instance started while launching; stop it first');
255
+ const args = [];
256
+ if (fs.existsSync(scenePath)) args.push(scenePath);
257
+ args.push('--log', '*', '--python', bootstrapPath);
258
+ const out = fs.openSync(logPath, 'a');
259
+ let child;
260
+ try {
261
+ child = spawn(blender, args, { cwd: projectPath, env: { ...env, BLENDER_PORT: String(port) }, detached: true, stdio: ['ignore', out, out], windowsHide: false });
262
+ } finally { fs.closeSync(out); }
263
+ child.unref();
264
+ if (!child.pid) fail(`Blender did not start; see ${logPath}`);
265
+
266
+ const instance = {
267
+ pid: child.pid,
268
+ project,
269
+ scene,
270
+ port,
271
+ blender,
272
+ log: path.relative(root, logPath).replaceAll('\\', '/'),
273
+ startedAt: new Date().toISOString(),
274
+ session,
275
+ };
276
+ writeInstance(home, instance);
277
+
278
+ const timeout = Number(env.HARNESS_LAUNCH_TIMEOUT_MS ?? 25000);
279
+ const deadline = Date.now() + timeout;
280
+ while (Date.now() < deadline) {
281
+ if (!isAlive(child.pid)) {
282
+ fs.rmSync(instanceFile(home), { force: true });
283
+ fail(`Blender exited during startup; see ${instance.log}\n${logTail(logPath)}`);
284
+ }
285
+ if (await portListening(port)) return { started: true, ...instance, addonListening: true };
286
+ await delay(400);
287
+ }
288
+ return { started: true, ...instance, addonListening: false, warning: `addon is not listening on port ${port}; check ${instance.log}` };
289
+ } finally {
290
+ release();
291
+ }
292
+ }
293
+
294
+ export function stopProject({ home, project }) {
295
+ const instance = readInstance(home);
296
+ if (!instance) return { running: false };
297
+ if (project && instance.project !== project) fail(`running instance belongs to ${instance.project}, not ${project}; only its registered session may stop it`);
298
+ if (isAlive(instance.pid)) killProcess(instance.pid);
299
+ fs.rmSync(instanceFile(home), { force: true });
300
+ return { running: false, stopped: instance };
301
+ }
@@ -0,0 +1,87 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs';
3
+ import net from 'node:net';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import test from 'node:test';
7
+ import { spawn } from 'node:child_process';
8
+ import { findBlender, instanceFile, instanceStatus, isAlive, launchProject, readWorkspacePort, stopProject } from './blender.mjs';
9
+
10
+ const tempBase = path.join(os.tmpdir(), 'kilo');
11
+ fs.mkdirSync(tempBase, { recursive: true });
12
+
13
+ function workspace(t) {
14
+ const root = fs.mkdtempSync(path.join(tempBase, 'blender-launch-'));
15
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
16
+ fs.mkdirSync(path.join(root, 'Blender', 'Test Project'), { recursive: true });
17
+ fs.mkdirSync(path.join(root, '.kilo'), { recursive: true });
18
+ fs.writeFileSync(path.join(root, '.kilo', 'kilo.jsonc'), '{ "mcp": { "blender": { "environment": { "BLENDER_PORT": "19877" } } } }\n');
19
+ const home = path.join(root, 'state');
20
+ fs.mkdirSync(home, { recursive: true });
21
+ return { root, home, project: 'Blender/Test Project' };
22
+ }
23
+
24
+ function writeInstance(home, instance) {
25
+ fs.writeFileSync(instanceFile(home), JSON.stringify(instance, null, 2));
26
+ }
27
+
28
+ test('workspace port is read from jsonc and validated', (t) => {
29
+ const f = workspace(t);
30
+ assert.equal(readWorkspacePort(f.root), 19877);
31
+ fs.writeFileSync(path.join(f.root, '.kilo', 'kilo.jsonc'), '// "BLENDER_PORT": "1"\n{ "mcp": { "blender": { "environment": { "BLENDER_PORT": "2222" } } } }\n');
32
+ assert.equal(readWorkspacePort(f.root), 2222);
33
+ fs.writeFileSync(path.join(f.root, '.kilo', 'kilo.jsonc'), '{ "environment": { "BLENDER_PORT": "80" } }\n');
34
+ assert.throws(() => readWorkspacePort(f.root), /invalid BLENDER_PORT/);
35
+ fs.writeFileSync(path.join(f.root, '.kilo', 'kilo.jsonc'), '{}\n');
36
+ assert.throws(() => readWorkspacePort(f.root), /lacks BLENDER_PORT/);
37
+ });
38
+
39
+ test('BLENDER_EXE is honoured for discovery', (t) => {
40
+ const f = workspace(t);
41
+ const fake = path.join(f.root, 'fake-blender.exe');
42
+ fs.writeFileSync(fake, 'fixture');
43
+ const found = findBlender({ BLENDER_EXE: fake, PATH: '' });
44
+ assert.equal(found, path.resolve(fake));
45
+ assert.throws(() => findBlender({ PATH: '' }), /Blender not found/);
46
+ });
47
+
48
+ test('stale instance metadata is cleared', (t) => {
49
+ const f = workspace(t);
50
+ writeInstance(f.home, { pid: 2147483647, project: f.project, scene: `${f.project}/x.blend`, port: 19877 });
51
+ const status = instanceStatus(f.home);
52
+ assert.equal(status.running, false);
53
+ assert.equal(fs.existsSync(instanceFile(f.home)), false);
54
+ });
55
+
56
+ test('stop terminates the recorded process and clears metadata', async (t) => {
57
+ const f = workspace(t);
58
+ const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { detached: true, stdio: 'ignore', windowsHide: true });
59
+ child.unref();
60
+ t.after(() => { try { process.kill(child.pid); } catch { /* already stopped */ } });
61
+ writeInstance(f.home, { pid: child.pid, project: f.project, scene: `${f.project}/x.blend`, port: 19877 });
62
+ const result = stopProject({ home: f.home, project: f.project });
63
+ assert.equal(result.running, false);
64
+ assert.equal(fs.existsSync(instanceFile(f.home)), false);
65
+ for (let attempt = 0; attempt < 20 && isAlive(child.pid); attempt += 1) await new Promise((resolve) => setTimeout(resolve, 100));
66
+ assert.equal(isAlive(child.pid), false);
67
+ });
68
+
69
+ test('launch rejects a live instance for another project', async (t) => {
70
+ const f = workspace(t);
71
+ writeInstance(f.home, { pid: process.pid, project: 'Blender/Other', scene: 'Blender/Other/x.blend', port: 19877 });
72
+ await assert.rejects(launchProject({ root: f.root, home: f.home, project: f.project, scene: `${f.project}/x.blend`, session: 'ses_test' }), /only one Blender instance/);
73
+ });
74
+
75
+ test('launch rejects when the MCP port is already in use', async (t) => {
76
+ const f = workspace(t);
77
+ const server = net.createServer();
78
+ await new Promise((resolve) => server.listen(19877, '127.0.0.1', resolve));
79
+ t.after(() => server.close());
80
+ await assert.rejects(launchProject({ root: f.root, home: f.home, project: f.project, scene: `${f.project}/x.blend`, session: 'ses_test' }), /already in use/);
81
+ });
82
+
83
+ test('launch rejects projects outside the collection and scenes outside the project', async (t) => {
84
+ const f = workspace(t);
85
+ await assert.rejects(launchProject({ root: f.root, home: f.home, project: 'Other/Project', scene: 'Other/Project/x.blend', session: 'ses_test' }), /project must live under/);
86
+ await assert.rejects(launchProject({ root: f.root, home: f.home, project: f.project, scene: 'Blender/Other/x.blend', session: 'ses_test' }), /scene must stay inside/);
87
+ });
@@ -0,0 +1,127 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { spawnSync } from 'node:child_process';
7
+ import { findBlender, instanceStatus } from './blender.mjs';
8
+ import { Harness } from './engine.mjs';
9
+ import { scaffold } from './scaffold.mjs';
10
+ import { configPath, workspaceRoot } from './workspace.mjs';
11
+
12
+ const packageRoot = path.dirname(fileURLToPath(import.meta.url));
13
+ const root = workspaceRoot(packageRoot);
14
+
15
+ function fail(message) {
16
+ throw new Error(`Harness: ${message}`);
17
+ }
18
+
19
+ function commandAvailable(command, args = ['--version']) {
20
+ const result = spawnSync(command, args, { cwd: root, encoding: 'utf8', windowsHide: true, timeout: 30000 });
21
+ if (result.error || result.status !== 0) fail(`${command} unavailable: ${result.error?.message ?? result.stderr?.trim() ?? `exit ${result.status}`}`);
22
+ return (result.stdout ?? result.stderr ?? '').trim();
23
+ }
24
+
25
+ function readJson(file) {
26
+ try { return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '')); }
27
+ catch (error) { fail(`invalid JSON ${path.relative(root, file)}: ${error.message}`); }
28
+ }
29
+
30
+ function stateHome() {
31
+ const key = crypto.createHash('sha256').update(root.toLowerCase()).digest('hex');
32
+ return path.join(os.homedir(), '.local', 'state', 'blender-workflow-harness', key);
33
+ }
34
+
35
+ function checkPackage() {
36
+ const pkg = readJson(path.join(packageRoot, 'package.json'));
37
+ const packagePath = path.relative(root, packageRoot).replaceAll('\\', '/') || '.';
38
+ if (pkg.type !== 'module' || pkg.engines?.node !== '>=22') fail('package requires ESM and Node >=22');
39
+ for (const dependency of ['@modelcontextprotocol/sdk', 'zod']) {
40
+ if (!fs.existsSync(path.join(packageRoot, 'node_modules', dependency))) fail(`dependency missing: ${dependency}; run npm ci in ${packagePath}`);
41
+ }
42
+ if (!fs.existsSync(path.join(packageRoot, 'package-lock.json'))) fail(`package-lock.json missing; run npm install in ${packagePath}`);
43
+ }
44
+
45
+ function checkConfig() {
46
+ const kiloConfig = path.join(root, '.kilo', 'kilo.jsonc');
47
+ const text = fs.readFileSync(kiloConfig, 'utf8');
48
+ const command = /"command"\s*:\s*\[([^\]]*)\]/.exec(text);
49
+ const server = command ? /"([^"]*server\.mjs)"/.exec(command[1])?.[1] : null;
50
+ if (!server) fail('.kilo/kilo.jsonc does not route Blender MCP through a harness server');
51
+ if (!fs.existsSync(path.resolve(root, server))) fail(`.kilo/kilo.jsonc points at missing harness server: ${server}`);
52
+ if (!/"BLENDER_PORT"\s*:\s*"\d+"/.test(text)) fail('.kilo/kilo.jsonc lacks BLENDER_PORT');
53
+ if (!fs.existsSync(path.join(root, '.kilo', 'plugins', 'blender-workflow.js'))) fail('auto-loaded Blender workflow plugin missing');
54
+ }
55
+
56
+ function checkUpstream() {
57
+ const version = commandAvailable('uvx', ['--from', 'mcp-for-blender==2.2.6', 'mcp-for-blender', '--help']);
58
+ if (!version.includes('mcp-for-blender')) fail('pinned mcp-for-blender command did not return expected help');
59
+ }
60
+
61
+ function doctor({ project } = {}) {
62
+ const node = process.versions.node;
63
+ if (Number.parseInt(node, 10) < 22) fail(`Node ${node} is too old; Node 22 or newer required`);
64
+ checkPackage();
65
+ checkConfig();
66
+ commandAvailable('git');
67
+ commandAvailable('uvx');
68
+ checkUpstream();
69
+ const blender = findBlender();
70
+ const instance = instanceStatus(stateHome());
71
+ const projectState = project ? new Harness(root).projectDoctor(project) : null;
72
+ process.stdout.write(JSON.stringify({ ok: true, root, node, upstream: 'mcp-for-blender==2.2.6', blender, instance, project: projectState }, null, 2) + '\n');
73
+ }
74
+
75
+ function parseOptions(args) {
76
+ const options = {};
77
+ for (let index = 0; index < args.length; index += 1) {
78
+ const token = args[index];
79
+ if (!token.startsWith('--')) fail(`unexpected argument: ${token}`);
80
+ const key = token.slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
81
+ if (key === 'dryRun') { options.dryRun = true; continue; }
82
+ const value = args[index + 1];
83
+ if (value === undefined || value.startsWith('--')) fail(`missing value for ${token}`);
84
+ options[key] = value;
85
+ index += 1;
86
+ }
87
+ return options;
88
+ }
89
+
90
+ async function main() {
91
+ const command = process.argv[2];
92
+ if (command === 'doctor') return doctor(parseOptions(process.argv.slice(3)));
93
+ if (command === 'scaffold') {
94
+ const options = parseOptions(process.argv.slice(3));
95
+ if (!options.name) fail('usage: node .kilo/blender-project-harness/tools/harness/cli.mjs scaffold --name "Project Name" [--description "..."] [--goal "..."] [--goal-title "..."] [--dry-run]');
96
+ const result = scaffold(root, {
97
+ name: options.name,
98
+ description: options.description,
99
+ goal: options.goal,
100
+ goalTitle: options.goalTitle,
101
+ dryRun: Boolean(options.dryRun),
102
+ });
103
+ if (result.dryRun) process.stdout.write(JSON.stringify(result, null, 2) + '\n');
104
+ else {
105
+ process.stdout.write(`Created Blender project: ${result.destination}\n\n`);
106
+ process.stdout.write('Next:\n');
107
+ process.stdout.write('1. Fill confirmed records through blender_workflow bootstrap-document; use capabilities.documentFormats.\n');
108
+ process.stdout.write('2. Commit the exact scaffold through blender_workflow bootstrap-commit; do not use manual project Git.\n');
109
+ process.stdout.write('3. Call blender_workflow prepare, answer its exact question, then start or resume the goal.\n');
110
+ process.stdout.write(`4. After registration and goal start, call blender_workflow {action:"status",payload:"{\\"project\\":\\"Blender/<Name>\\"}"} and follow nextStep.\n`);
111
+ }
112
+ return undefined;
113
+ }
114
+ if (command === 'development') {
115
+ const state = process.argv[3];
116
+ if (!['on', 'off'].includes(state)) fail('invalid command');
117
+ const file = configPath(root);
118
+ const config = readJson(file);
119
+ config.development_mode = state === 'on';
120
+ fs.writeFileSync(file, JSON.stringify(config, null, 2) + '\n');
121
+ return undefined;
122
+ }
123
+ fail('usage: node .kilo/blender-project-harness/tools/harness/cli.mjs doctor [--project "Blender/Name"] | scaffold --name "Project Name"');
124
+ }
125
+
126
+ try { await main(); }
127
+ catch (error) { process.stderr.write(`${error.message}\n`); process.exitCode = 1; }