@needle-tools/engine 5.0.0-next.28ba01d → 5.0.0-next.3fe5866

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.
@@ -1,5 +1,5 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
- import { dirname, join } from "path";
1
+ import { copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, writeFileSync } from "fs";
2
+ import { dirname, join, relative } from "path";
3
3
  import { fileURLToPath } from "url";
4
4
  import { needleLog } from "./logging.js";
5
5
 
@@ -8,20 +8,49 @@ const __dirname = dirname(__filename);
8
8
 
9
9
  const pluginName = "needle-ai";
10
10
 
11
+ /**
12
+ * Supported AI coding agents.
13
+ * Each entry defines how to detect the agent and how to write its skill file.
14
+ * `detect` is the directory that must exist in the project root.
15
+ * `write(cwd, canonicalDir, content)` installs the skill in the agent's native format.
16
+ *
17
+ * The `.agents/` entry is special: it is the canonical location where SKILL.md
18
+ * and downloaded reference files are written directly. All other agents symlink
19
+ * their skill directory to `.agents/skills/needle-engine/`.
20
+ */
21
+ const agents = [
22
+ {
23
+ name: "Claude Code",
24
+ detect: ".claude",
25
+ write: (cwd, canonicalDir, content) => symlinkSkillDir(join(cwd, ".claude"), canonicalDir),
26
+ },
27
+ {
28
+ name: "GitHub Copilot",
29
+ detect: ".github",
30
+ write: (cwd, canonicalDir, content) => symlinkSkillDir(join(cwd, ".github"), canonicalDir),
31
+ },
32
+ {
33
+ name: "Cursor",
34
+ detect: ".cursor",
35
+ write: (cwd, canonicalDir, content) => writeCursorRule(cwd, canonicalDir, content),
36
+ },
37
+ ];
38
+
11
39
  /**
12
40
  * Needle Engine AI skill installer.
13
41
  *
14
- * Writes a Needle Engine skill to `<dir>/skills/needle-engine/SKILL.md`
15
- * for each supported AI agent directory (`.claude/`, `.github/`, `.agents/`).
16
- * Both Claude Code and GitHub Copilot auto-load skills based on their
17
- * description frontmatter, so the AI agent will automatically have Needle
18
- * Engine context when working in the project.
42
+ * Auto-detects AI coding agents by checking for their config directories
43
+ * in the project root, then writes the Needle Engine skill in each agent's
44
+ * native format.
45
+ *
46
+ * Supported agents: Claude Code, GitHub Copilot, Cursor, Codex / universal.
19
47
  *
20
- * The skill is only written if at least one of the supported directories
21
- * already exists in the project root (i.e. the developer is already using
22
- * an AI coding agent).
23
- * Old skill files are always overwritten so the skill stays up to date with
24
- * the engine version.
48
+ * `.agents/skills/needle-engine/` is always created as the canonical skill
49
+ * location. SKILL.md and downloaded reference files live there. Other agents
50
+ * symlink to it to avoid duplication.
51
+ *
52
+ * Remote reference files (API docs, templates, etc.) linked from SKILL.md are
53
+ * downloaded at install time and stored locally with relative paths.
25
54
  *
26
55
  * @param {"build" | "serve"} command
27
56
  * @param {{} | undefined | null} config
@@ -32,34 +61,279 @@ export function needleAI(command, config, userSettings) {
32
61
  return {
33
62
  name: pluginName,
34
63
  enforce: "pre",
35
- buildStart() {
36
- installClaudeSkill();
64
+ async buildStart() {
65
+ await installSkills();
37
66
  },
38
- configureServer() {
39
- installClaudeSkill();
67
+ async configureServer() {
68
+ await installSkills();
40
69
  },
41
70
  };
42
71
  }
43
72
 
44
- function writeSkill(claudeDir) {
45
- const skillDir = join(claudeDir, "skills", "needle-engine");
46
- if (!existsSync(skillDir)) {
47
- mkdirSync(skillDir, { recursive: true });
48
- }
49
- const skillPath = join(skillDir, "SKILL.md");
73
+ /** Read the SKILL.md template shipped with the engine. */
74
+ function getSkillContent() {
50
75
  const templatePath = join(__dirname, "../../SKILL.md");
51
- const content = readFileSync(templatePath, "utf8");
52
- writeFileSync(skillPath, content, "utf8");
53
- return skillPath;
76
+ return readFileSync(templatePath, "utf8");
77
+ }
78
+
79
+ /**
80
+ * Extract the body of a SKILL.md file (everything after the YAML frontmatter).
81
+ * Returns the full content if no frontmatter is found.
82
+ */
83
+ function stripFrontmatter(content) {
84
+ const match = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n([\s\S]*)$/);
85
+ return match ? match[1].trimStart() : content;
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Remote reference downloading
90
+ // ---------------------------------------------------------------------------
91
+
92
+ /**
93
+ * Extract markdown links to raw.githubusercontent.com files from content.
94
+ * Returns an array of { fullMatch, linkText, url, subdir, filename, localRelPath }.
95
+ */
96
+ function extractRemoteRefs(content) {
97
+ const pattern = /\[([^\]]*)\]\((https:\/\/raw\.githubusercontent\.com\/[^)]+)\)/g;
98
+ const refs = [];
99
+ let match;
100
+ while ((match = pattern.exec(content)) !== null) {
101
+ const url = match[2];
102
+ const urlPath = new URL(url).pathname;
103
+ const segments = urlPath.split("/").filter(Boolean);
104
+ const filename = segments[segments.length - 1];
105
+ const subdir = segments[segments.length - 2] || "";
106
+ refs.push({
107
+ fullMatch: match[0],
108
+ linkText: match[1],
109
+ url,
110
+ subdir,
111
+ filename,
112
+ localRelPath: `./${subdir}/${filename}`,
113
+ });
114
+ }
115
+ return refs;
54
116
  }
55
117
 
56
- function installClaudeSkill() {
118
+ /**
119
+ * Download a single remote file. Returns text on success, null on failure.
120
+ * @param {{ fullMatch: string, linkText: string, url: string, subdir: string, filename: string, localRelPath: string }} ref
121
+ * @param {number} timeoutMs
122
+ * @returns {Promise<string | null>}
123
+ */
124
+ async function downloadRef(ref, timeoutMs = 5000) {
125
+ try {
126
+ const controller = new AbortController();
127
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
128
+ const response = await fetch(ref.url, { signal: controller.signal });
129
+ clearTimeout(timer);
130
+ if (!response.ok) return null;
131
+ return await response.text();
132
+ }
133
+ catch {
134
+ return null;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Download all remote references found in content. Returns rewritten content
140
+ * (absolute URLs → relative paths) and the downloaded file data.
141
+ * Failed downloads keep their original absolute URL.
142
+ */
143
+ async function downloadAndRewriteRefs(content) {
144
+ const refs = extractRemoteRefs(content);
145
+ if (refs.length === 0) return { rewrittenContent: content, downloadedFiles: [] };
146
+
147
+ if (typeof globalThis.fetch !== "function") {
148
+ return { rewrittenContent: content, downloadedFiles: [] };
149
+ }
150
+
151
+ const results = await Promise.allSettled(
152
+ refs.map(async ref => {
153
+ const data = await downloadRef(ref);
154
+ return { ref, data };
155
+ })
156
+ );
157
+
158
+ let rewrittenContent = content;
159
+ const downloadedFiles = [];
160
+
161
+ for (const result of results) {
162
+ if (result.status === "fulfilled" && result.value.data !== null) {
163
+ const { ref, data } = result.value;
164
+ const newLink = `[${ref.linkText}](${ref.localRelPath})`;
165
+ rewrittenContent = rewrittenContent.replace(ref.fullMatch, newLink);
166
+ downloadedFiles.push({
167
+ subdir: ref.subdir,
168
+ filename: ref.filename,
169
+ data,
170
+ });
171
+ }
172
+ }
173
+
174
+ return { rewrittenContent, downloadedFiles };
175
+ }
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // Canonical skill directory (.agents/)
179
+ // ---------------------------------------------------------------------------
180
+
181
+ /**
182
+ * Write SKILL.md and downloaded reference files into the canonical location
183
+ * at `.agents/skills/needle-engine/`. This directory is always created and
184
+ * serves as the symlink target for all other agents.
185
+ * @returns {string} The absolute path to the canonical skill directory.
186
+ */
187
+ function writeCanonicalSkillDir(cwd, content, downloadedFiles) {
188
+ const canonicalDir = join(cwd, ".agents", "skills", "needle-engine");
189
+ if (!existsSync(canonicalDir)) {
190
+ mkdirSync(canonicalDir, { recursive: true });
191
+ }
192
+
193
+ writeFileSync(join(canonicalDir, "SKILL.md"), content, "utf8");
194
+
195
+ for (const file of downloadedFiles) {
196
+ const fileDir = join(canonicalDir, file.subdir);
197
+ if (!existsSync(fileDir)) {
198
+ mkdirSync(fileDir, { recursive: true });
199
+ }
200
+ writeFileSync(join(fileDir, file.filename), file.data, "utf8");
201
+ }
202
+
203
+ return canonicalDir;
204
+ }
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // Agent writers
208
+ // ---------------------------------------------------------------------------
209
+
210
+ /**
211
+ * Create a symlink at `<agentDir>/skills/needle-engine` → canonical skill dir.
212
+ * If the target already exists (file, dir, or stale symlink), it is replaced.
213
+ */
214
+ function symlinkSkillDir(agentDir, canonicalDir) {
215
+ const skillsDir = join(agentDir, "skills");
216
+ const linkPath = join(skillsDir, "needle-engine");
217
+
218
+ if (!existsSync(skillsDir)) {
219
+ mkdirSync(skillsDir, { recursive: true });
220
+ }
221
+
222
+ const target = relative(skillsDir, canonicalDir);
223
+
224
+ // Remove existing entry (file, dir, or stale symlink)
225
+ try {
226
+ if (existsSync(linkPath) || lstatSync(linkPath).isSymbolicLink()) {
227
+ rmSync(linkPath, { recursive: true, force: true });
228
+ }
229
+ }
230
+ catch { /* nothing to remove */ }
231
+
232
+ try {
233
+ symlinkSync(target, linkPath, "junction");
234
+ return linkPath;
235
+ }
236
+ catch {
237
+ // Fallback: copy if symlink fails (e.g. Windows without privileges)
238
+ if (!existsSync(linkPath)) {
239
+ mkdirSync(linkPath, { recursive: true });
240
+ }
241
+ copyDirSync(canonicalDir, linkPath);
242
+ return linkPath;
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Write a Cursor rule file at `.cursor/rules/needle-engine.mdc` and symlink
248
+ * the reference files directory at `.cursor/rules/needle-engine/`.
249
+ * Cursor uses its own frontmatter format (description, globs, alwaysApply).
250
+ */
251
+ function writeCursorRule(cwd, canonicalDir, content) {
252
+ const rulesDir = join(cwd, ".cursor", "rules");
253
+ if (!existsSync(rulesDir)) {
254
+ mkdirSync(rulesDir, { recursive: true });
255
+ }
256
+
257
+ // Symlink .cursor/rules/needle-engine/ → canonical dir (for reference files)
258
+ const linkPath = join(rulesDir, "needle-engine");
259
+ const target = relative(rulesDir, canonicalDir);
260
+ try {
261
+ if (existsSync(linkPath) || lstatSync(linkPath).isSymbolicLink()) {
262
+ rmSync(linkPath, { recursive: true, force: true });
263
+ }
264
+ }
265
+ catch { /* nothing to remove */ }
266
+
267
+ try {
268
+ symlinkSync(target, linkPath, "junction");
269
+ }
270
+ catch {
271
+ // Fallback: copy directly
272
+ if (!existsSync(linkPath)) {
273
+ mkdirSync(linkPath, { recursive: true });
274
+ }
275
+ copyDirSync(canonicalDir, linkPath);
276
+ }
277
+
278
+ // Write the .mdc file with adjusted relative paths
279
+ // SKILL.md uses ./references/api.md but the .mdc sits at .cursor/rules/needle-engine.mdc
280
+ // so we need ./needle-engine/references/api.md
281
+ let body = stripFrontmatter(content);
282
+ body = body.replace(/\]\(\.\/(references|templates)\//g, "](./needle-engine/$1/");
283
+
284
+ const rulePath = join(rulesDir, "needle-engine.mdc");
285
+ const cursorContent = `---
286
+ description: Needle Engine context — use when editing TypeScript components, Vite config, GLB assets, or anything related to @needle-tools/engine.
287
+ globs:
288
+ alwaysApply: false
289
+ ---
290
+ ${body}`;
291
+ writeFileSync(rulePath, cursorContent, "utf8");
292
+ return rulePath;
293
+ }
294
+
295
+ // ---------------------------------------------------------------------------
296
+ // Helpers
297
+ // ---------------------------------------------------------------------------
298
+
299
+ /** Recursively copy a directory's contents. */
300
+ function copyDirSync(src, dest) {
301
+ for (const entry of readdirSync(src)) {
302
+ const srcPath = join(src, entry);
303
+ const destPath = join(dest, entry);
304
+ if (statSync(srcPath).isDirectory()) {
305
+ if (!existsSync(destPath)) mkdirSync(destPath, { recursive: true });
306
+ copyDirSync(srcPath, destPath);
307
+ }
308
+ else {
309
+ copyFileSync(srcPath, destPath);
310
+ }
311
+ }
312
+ }
313
+
314
+ // ---------------------------------------------------------------------------
315
+ // Main
316
+ // ---------------------------------------------------------------------------
317
+
318
+ /** Detect agents and install the Needle Engine skill for each. */
319
+ async function installSkills() {
57
320
  const cwd = process.cwd();
58
- const dirs = [".claude", ".github", ".agents"].map(d => join(cwd, d)).filter(d => existsSync(d));
59
- if (dirs.length === 0) return; // only install if developer uses an AI coding agent
60
321
 
61
- for (const dir of dirs) {
62
- const path = writeSkill(dir);
63
- if (path) needleLog(`[${pluginName}] Installed Needle Engine skill → ${path}`);
322
+ const rawContent = getSkillContent();
323
+ const { rewrittenContent, downloadedFiles } = await downloadAndRewriteRefs(rawContent);
324
+
325
+ // Always write to .agents/ as the canonical location
326
+ const canonicalDir = writeCanonicalSkillDir(cwd, rewrittenContent, downloadedFiles);
327
+
328
+ // Symlink other detected agents to the canonical dir
329
+ const detected = agents.filter(a => existsSync(join(cwd, a.detect)));
330
+ const names = ["Codex / Universal"];
331
+ for (const agent of detected) {
332
+ const path = agent.write(cwd, canonicalDir, rewrittenContent);
333
+ if (path) names.push(agent.name);
64
334
  }
335
+
336
+ const refCount = downloadedFiles.length;
337
+ const refMsg = refCount > 0 ? ` (${refCount} reference file${refCount === 1 ? "" : "s"} downloaded)` : "";
338
+ needleLog(pluginName, `Installed for ${names.length} agent${names.length === 1 ? "" : "s"}: ${names.join(", ")}${refMsg}`);
65
339
  }
@@ -1088,7 +1088,8 @@ export class Input implements IInput {
1088
1088
  if (this.context.isInAR) return;
1089
1089
  if (this.canReceiveInput(evt) === false) return;
1090
1090
  if (evt.target instanceof HTMLElement) {
1091
- evt.target.setPointerCapture(evt.pointerId);
1091
+ try { evt.target.setPointerCapture(evt.pointerId); }
1092
+ catch { /* may fail during pointer lock */ }
1092
1093
  }
1093
1094
  const id = this.getPointerId(evt);
1094
1095
  if (debug) showBalloonMessage(`pointer down #${id}, identifier:${evt.pointerId}`);
@@ -219,6 +219,32 @@ export class NewInstanceModel implements IModel {
219
219
  export type SyncInstantiateOptions = IInstantiateOptions & Pick<IModel, "deleteOnDisconnect">;
220
220
 
221
221
  // #region Sync Instantiate
222
+
223
+ declare type SyncInstantiateCallback = (instance: GameObject, model: NewInstanceModel) => void;
224
+ const _onSyncInstantiateCallbacks: SyncInstantiateCallback[] = [];
225
+
226
+ /**
227
+ * Register a callback that fires when a remote `syncInstantiate` object is created on this client.
228
+ * Use this to get references to objects spawned by other users.
229
+ * @param callback Called with the instantiated Object3D and the network model data
230
+ * @returns An unsubscribe function
231
+ * @category Networking
232
+ * @example
233
+ * ```ts
234
+ * const unsub = onSyncInstantiate((instance, model) => {
235
+ * console.log("Remote object created:", instance.name, model.originalGuid);
236
+ * });
237
+ * // later: unsub();
238
+ * ```
239
+ */
240
+ export function onSyncInstantiate(callback: SyncInstantiateCallback): () => void {
241
+ _onSyncInstantiateCallbacks.push(callback);
242
+ return () => {
243
+ const idx = _onSyncInstantiateCallbacks.indexOf(callback);
244
+ if (idx >= 0) _onSyncInstantiateCallbacks.splice(idx, 1);
245
+ };
246
+ }
247
+
222
248
  /**
223
249
  * Instantiate an object across the network. See also {@link syncDestroy}.
224
250
  * @category Networking
@@ -350,6 +376,11 @@ export function beginListenInstantiate(context: Context) {
350
376
  context.scene.add(inst);
351
377
  if (debug)
352
378
  console.log("[Remote] new instance", "gameobject:", inst?.guid, obj);
379
+ // Notify listeners about the remote instantiation
380
+ for (const cb of _onSyncInstantiateCallbacks) {
381
+ try { cb(inst, model); }
382
+ catch (err) { console.error("Error in onSyncInstantiate callback", err); }
383
+ }
353
384
  }
354
385
  });
355
386
  const cb2 = context.connection.beginListen("left-room", () => {
@@ -38,17 +38,44 @@ export class PlayerSync extends Behaviour {
38
38
  * scene.add(res.gameObject);
39
39
  * ```
40
40
  */
41
- static async setupFrom(url: string, init?: Omit<ComponentInit<PlayerSync>, "asset">): Promise<PlayerSyncWithAsset> {
42
- const assetReference = AssetReference.getOrCreateFromUrl(url);
43
- if (!assetReference.asset) {
44
- const i = await assetReference.loadAssetAsync();
45
- if(i) GameObject.getOrAddComponent(i, PlayerState);
41
+ /**
42
+ * This API is experimental and may change or be removed in the future.
43
+ * Creates a PlayerSync instance at runtime from a given URL or Object3D and sets it up for networking.
44
+ * @param urlOrObject Path to the asset that should be instantiated for each player, or a pre-created Object3D to use as the player prefab
45
+ * @param init Optional initialization parameters for the PlayerSync component
46
+ * @returns Promise resolving to a PlayerSync instance with a guaranteed asset property
47
+ * @example
48
+ * ```typescript
49
+ * // From a GLB URL:
50
+ * const res = await PlayerSync.setupFrom("/assets/demo.glb");
51
+ *
52
+ * // From a runtime-created Object3D:
53
+ * const avatar = ObjectUtils.createPrimitive("Sphere");
54
+ * const res = await PlayerSync.setupFrom(avatar);
55
+ * ```
56
+ */
57
+ static async setupFrom(urlOrObject: string | Object3D, init?: Omit<ComponentInit<PlayerSync>, "asset"> & { guid?: string }): Promise<PlayerSyncWithAsset> {
58
+ let assetReference: AssetReference;
59
+
60
+ if (typeof urlOrObject === "string") {
61
+ assetReference = AssetReference.getOrCreateFromUrl(urlOrObject);
62
+ if (!assetReference.asset) {
63
+ const i = await assetReference.loadAssetAsync();
64
+ if(i) GameObject.getOrAddComponent(i, PlayerState);
65
+ }
66
+ } else {
67
+ // Runtime Object3D passed directly — wrap in AssetReference without loading
68
+ assetReference = new AssetReference({ asset: urlOrObject });
69
+ GameObject.getOrAddComponent(urlOrObject, PlayerState);
46
70
  }
71
+
47
72
  const ps = new PlayerSync();
48
73
  ps._internalInit(init);
49
74
  ps.asset = assetReference;
50
75
  const obj = new Object3D();
51
- obj["guid"] = url;
76
+ // The guid is used to identify this PlayerSync instance across the network.
77
+ // For URLs it uses the URL itself; for runtime objects it tries the init guid, object guid, name, or uuid.
78
+ obj["guid"] = init?.guid ?? (typeof urlOrObject === "string" ? urlOrObject : (urlOrObject["guid"] || urlOrObject.name || urlOrObject.uuid));
52
79
  GameObject.addComponent(obj, ps);
53
80
  return ps as PlayerSyncWithAsset;
54
81
  }