@needle-tools/engine
Version:
Needle Engine is a web-based runtime for 3D apps. It runs on your machine for development with great integrations into editors like Unity or Blender - and can be deployed onto any device! It is flexible, extensible and networking and XR are built-in.
340 lines (300 loc) • 12.1 kB
JavaScript
import { copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, writeFileSync } from "fs";
import { dirname, join, relative } from "path";
import { fileURLToPath } from "url";
import { needleLog } from "./logging.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const pluginName = "needle-ai";
/**
* Supported AI coding agents.
* Each entry defines how to detect the agent and how to write its skill file.
* `detect` is the directory that must exist in the project root.
* `write(cwd, canonicalDir, content)` installs the skill in the agent's native format.
*
* The `.agents/` entry is special: it is the canonical location where SKILL.md
* and downloaded reference files are written directly. All other agents symlink
* their skill directory to `.agents/skills/needle-engine/`.
*/
const agents = [
{
name: "Claude Code",
detect: ".claude",
write: (cwd, canonicalDir, content) => symlinkSkillDir(join(cwd, ".claude"), canonicalDir),
},
{
name: "GitHub Copilot",
detect: ".github",
write: (cwd, canonicalDir, content) => symlinkSkillDir(join(cwd, ".github"), canonicalDir),
},
{
name: "Cursor",
detect: ".cursor",
write: (cwd, canonicalDir, content) => writeCursorRule(cwd, canonicalDir, content),
},
];
/**
* Needle Engine AI skill installer.
*
* Auto-detects AI coding agents by checking for their config directories
* in the project root, then writes the Needle Engine skill in each agent's
* native format.
*
* Supported agents: Claude Code, GitHub Copilot, Cursor, Codex / universal.
*
* `.agents/skills/needle-engine/` is always created as the canonical skill
* location. SKILL.md and downloaded reference files live there. Other agents
* symlink to it to avoid duplication.
*
* Remote reference files (API docs, templates, etc.) linked from SKILL.md are
* downloaded at install time and stored locally with relative paths.
*
* @param {"build" | "serve"} command
* @param {{} | undefined | null} config
* @param {import('../types/index.js').userSettings} userSettings
* @returns {import('vite').Plugin | null}
*/
export function needleAI(command, config, userSettings) {
return {
name: pluginName,
enforce: "pre",
async buildStart() {
await installSkills();
},
async configureServer() {
await installSkills();
},
};
}
/** Read the SKILL.md template shipped with the engine. */
function getSkillContent() {
const templatePath = join(__dirname, "../../SKILL.md");
return readFileSync(templatePath, "utf8");
}
/**
* Extract the body of a SKILL.md file (everything after the YAML frontmatter).
* Returns the full content if no frontmatter is found.
*/
function stripFrontmatter(content) {
const match = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n([\s\S]*)$/);
return match ? match[1].trimStart() : content;
}
// ---------------------------------------------------------------------------
// Remote reference downloading
// ---------------------------------------------------------------------------
/**
* Extract markdown links to raw.githubusercontent.com files from content.
* Returns an array of { fullMatch, linkText, url, subdir, filename, localRelPath }.
*/
function extractRemoteRefs(content) {
const pattern = /\[([^\]]*)\]\((https:\/\/raw\.githubusercontent\.com\/[^)]+)\)/g;
const refs = [];
let match;
while ((match = pattern.exec(content)) !== null) {
const url = match[2];
const urlPath = new URL(url).pathname;
const segments = urlPath.split("/").filter(Boolean);
const filename = segments[segments.length - 1];
const subdir = segments[segments.length - 2] || "";
refs.push({
fullMatch: match[0],
linkText: match[1],
url,
subdir,
filename,
localRelPath: `./${subdir}/${filename}`,
});
}
return refs;
}
/**
* Download a single remote file. Returns text on success, null on failure.
* @param {{ fullMatch: string, linkText: string, url: string, subdir: string, filename: string, localRelPath: string }} ref
* @param {number} timeoutMs
* @returns {Promise<string | null>}
*/
async function downloadRef(ref, timeoutMs = 5000) {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const response = await fetch(ref.url, { signal: controller.signal });
clearTimeout(timer);
if (!response.ok) return null;
return await response.text();
}
catch {
return null;
}
}
/**
* Download all remote references found in content. Returns rewritten content
* (absolute URLs → relative paths) and the downloaded file data.
* Failed downloads keep their original absolute URL.
*/
async function downloadAndRewriteRefs(content) {
const refs = extractRemoteRefs(content);
if (refs.length === 0) return { rewrittenContent: content, downloadedFiles: [] };
if (typeof globalThis.fetch !== "function") {
return { rewrittenContent: content, downloadedFiles: [] };
}
const results = await Promise.allSettled(
refs.map(async ref => {
const data = await downloadRef(ref);
return { ref, data };
})
);
let rewrittenContent = content;
const downloadedFiles = [];
for (const result of results) {
if (result.status === "fulfilled" && result.value.data !== null) {
const { ref, data } = result.value;
const newLink = `[${ref.linkText}](${ref.localRelPath})`;
rewrittenContent = rewrittenContent.replace(ref.fullMatch, newLink);
downloadedFiles.push({
subdir: ref.subdir,
filename: ref.filename,
data,
});
}
}
return { rewrittenContent, downloadedFiles };
}
// ---------------------------------------------------------------------------
// Canonical skill directory (.agents/)
// ---------------------------------------------------------------------------
/**
* Write SKILL.md and downloaded reference files into the canonical location
* at `.agents/skills/needle-engine/`. This directory is always created and
* serves as the symlink target for all other agents.
* @returns {string} The absolute path to the canonical skill directory.
*/
function writeCanonicalSkillDir(cwd, content, downloadedFiles) {
const canonicalDir = join(cwd, ".agents", "skills", "needle-engine");
if (!existsSync(canonicalDir)) {
mkdirSync(canonicalDir, { recursive: true });
}
writeFileSync(join(canonicalDir, "SKILL.md"), content, "utf8");
for (const file of downloadedFiles) {
const fileDir = join(canonicalDir, file.subdir);
if (!existsSync(fileDir)) {
mkdirSync(fileDir, { recursive: true });
}
writeFileSync(join(fileDir, file.filename), file.data, "utf8");
}
return canonicalDir;
}
// ---------------------------------------------------------------------------
// Agent writers
// ---------------------------------------------------------------------------
/**
* Create a symlink at `<agentDir>/skills/needle-engine` → canonical skill dir.
* If the target already exists (file, dir, or stale symlink), it is replaced.
*/
function symlinkSkillDir(agentDir, canonicalDir) {
const skillsDir = join(agentDir, "skills");
const linkPath = join(skillsDir, "needle-engine");
if (!existsSync(skillsDir)) {
mkdirSync(skillsDir, { recursive: true });
}
const target = relative(skillsDir, canonicalDir);
// Remove existing entry (file, dir, or stale symlink)
try {
if (existsSync(linkPath) || lstatSync(linkPath).isSymbolicLink()) {
rmSync(linkPath, { recursive: true, force: true });
}
}
catch { /* nothing to remove */ }
try {
symlinkSync(target, linkPath, "junction");
return linkPath;
}
catch {
// Fallback: copy if symlink fails (e.g. Windows without privileges)
if (!existsSync(linkPath)) {
mkdirSync(linkPath, { recursive: true });
}
copyDirSync(canonicalDir, linkPath);
return linkPath;
}
}
/**
* Write a Cursor rule file at `.cursor/rules/needle-engine.mdc` and symlink
* the reference files directory at `.cursor/rules/needle-engine/`.
* Cursor uses its own frontmatter format (description, globs, alwaysApply).
*/
function writeCursorRule(cwd, canonicalDir, content) {
const rulesDir = join(cwd, ".cursor", "rules");
if (!existsSync(rulesDir)) {
mkdirSync(rulesDir, { recursive: true });
}
// Symlink .cursor/rules/needle-engine/ → canonical dir (for reference files)
const linkPath = join(rulesDir, "needle-engine");
const target = relative(rulesDir, canonicalDir);
try {
if (existsSync(linkPath) || lstatSync(linkPath).isSymbolicLink()) {
rmSync(linkPath, { recursive: true, force: true });
}
}
catch { /* nothing to remove */ }
try {
symlinkSync(target, linkPath, "junction");
}
catch {
// Fallback: copy directly
if (!existsSync(linkPath)) {
mkdirSync(linkPath, { recursive: true });
}
copyDirSync(canonicalDir, linkPath);
}
// Write the .mdc file with adjusted relative paths
// SKILL.md uses ./references/api.md but the .mdc sits at .cursor/rules/needle-engine.mdc
// so we need ./needle-engine/references/api.md
let body = stripFrontmatter(content);
body = body.replace(/\]\(\.\/(references|templates)\//g, "](./needle-engine/$1/");
const rulePath = join(rulesDir, "needle-engine.mdc");
const cursorContent = `---
description: Needle Engine context — use when editing TypeScript components, Vite config, GLB assets, or anything related to @needle-tools/engine.
globs:
alwaysApply: false
---
${body}`;
writeFileSync(rulePath, cursorContent, "utf8");
return rulePath;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Recursively copy a directory's contents. */
function copyDirSync(src, dest) {
for (const entry of readdirSync(src)) {
const srcPath = join(src, entry);
const destPath = join(dest, entry);
if (statSync(srcPath).isDirectory()) {
if (!existsSync(destPath)) mkdirSync(destPath, { recursive: true });
copyDirSync(srcPath, destPath);
}
else {
copyFileSync(srcPath, destPath);
}
}
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
/** Detect agents and install the Needle Engine skill for each. */
async function installSkills() {
const cwd = process.cwd();
const rawContent = getSkillContent();
const { rewrittenContent, downloadedFiles } = await downloadAndRewriteRefs(rawContent);
// Always write to .agents/ as the canonical location
const canonicalDir = writeCanonicalSkillDir(cwd, rewrittenContent, downloadedFiles);
// Symlink other detected agents to the canonical dir
const detected = agents.filter(a => existsSync(join(cwd, a.detect)));
const names = ["Codex / Universal"];
for (const agent of detected) {
const path = agent.write(cwd, canonicalDir, rewrittenContent);
if (path) names.push(agent.name);
}
const refCount = downloadedFiles.length;
const refMsg = refCount > 0 ? ` (${refCount} reference file${refCount === 1 ? "" : "s"} downloaded)` : "";
needleLog(pluginName, `Installed for ${names.length} agent${names.length === 1 ? "" : "s"}: ${names.join(", ")}${refMsg}`);
}