UNPKG

@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
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}`); }