feat: integrate excalidraw MCP into bootstrap and runtime setup
- install.sh: run mosaic-ensure-excalidraw post-install (non-fatal) - runtime-setup.ts: configure excalidraw MCP during wizard setup - bin/mosaic-ensure-excalidraw: install deps + register MCP with Claude - runtime/mcp/EXCALIDRAW.json: MCP server config template - tools/excalidraw/: headless .excalidraw → SVG export server Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
119
bin/mosaic-ensure-excalidraw
Executable file
119
bin/mosaic-ensure-excalidraw
Executable file
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||
TOOLS_DIR="$MOSAIC_HOME/tools/excalidraw"
|
||||
MODE="apply"
|
||||
SCOPE="user"
|
||||
|
||||
err() { echo "[mosaic-excalidraw] ERROR: $*" >&2; }
|
||||
log() { echo "[mosaic-excalidraw] $*"; }
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--check) MODE="check"; shift ;;
|
||||
--scope)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
err "--scope requires a value: user|local"
|
||||
exit 2
|
||||
fi
|
||||
SCOPE="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
err "Unknown argument: $1"
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
require_binary() {
|
||||
local name="$1"
|
||||
if ! command -v "$name" >/dev/null 2>&1; then
|
||||
err "Required binary missing: $name"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
check_software() {
|
||||
require_binary node
|
||||
require_binary npm
|
||||
}
|
||||
|
||||
check_tool_dir() {
|
||||
[[ -d "$TOOLS_DIR" ]] || { err "Tool dir not found: $TOOLS_DIR"; return 1; }
|
||||
[[ -f "$TOOLS_DIR/package.json" ]] || { err "package.json not found in $TOOLS_DIR"; return 1; }
|
||||
[[ -f "$TOOLS_DIR/launch.sh" ]] || { err "launch.sh not found in $TOOLS_DIR"; return 1; }
|
||||
}
|
||||
|
||||
check_npm_deps() {
|
||||
[[ -d "$TOOLS_DIR/node_modules/@modelcontextprotocol" ]] || return 1
|
||||
[[ -d "$TOOLS_DIR/node_modules/@excalidraw" ]] || return 1
|
||||
[[ -d "$TOOLS_DIR/node_modules/jsdom" ]] || return 1
|
||||
}
|
||||
|
||||
install_npm_deps() {
|
||||
if check_npm_deps; then
|
||||
return 0
|
||||
fi
|
||||
log "Installing npm deps in $TOOLS_DIR..."
|
||||
(cd "$TOOLS_DIR" && npm install --silent) || {
|
||||
err "npm install failed in $TOOLS_DIR"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
check_claude_config() {
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
p = Path.home() / ".claude.json"
|
||||
if not p.exists():
|
||||
raise SystemExit(1)
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
raise SystemExit(1)
|
||||
mcp = data.get("mcpServers")
|
||||
if not isinstance(mcp, dict):
|
||||
raise SystemExit(1)
|
||||
entry = mcp.get("excalidraw")
|
||||
if not isinstance(entry, dict):
|
||||
raise SystemExit(1)
|
||||
cmd = entry.get("command", "")
|
||||
if not cmd.endswith("launch.sh"):
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
}
|
||||
|
||||
apply_claude_config() {
|
||||
require_binary claude
|
||||
local launch_sh="$TOOLS_DIR/launch.sh"
|
||||
claude mcp add --scope user excalidraw -- "$launch_sh"
|
||||
}
|
||||
|
||||
# ── Check mode ────────────────────────────────────────────────────────────────
|
||||
|
||||
if [[ "$MODE" == "check" ]]; then
|
||||
check_software
|
||||
check_tool_dir
|
||||
if ! check_npm_deps; then
|
||||
err "npm deps not installed in $TOOLS_DIR (run without --check to install)"
|
||||
exit 1
|
||||
fi
|
||||
if ! check_claude_config; then
|
||||
err "excalidraw not registered in ~/.claude.json"
|
||||
exit 1
|
||||
fi
|
||||
log "excalidraw MCP is configured and available"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Apply mode ────────────────────────────────────────────────────────────────
|
||||
|
||||
check_software
|
||||
check_tool_dir
|
||||
install_npm_deps
|
||||
apply_claude_config
|
||||
log "excalidraw MCP configured (scope: $SCOPE)"
|
||||
@@ -177,6 +177,12 @@ else
|
||||
fi
|
||||
fi
|
||||
|
||||
if "$TARGET_DIR/bin/mosaic-ensure-excalidraw" >/dev/null 2>&1; then
|
||||
ok "excalidraw MCP configured"
|
||||
else
|
||||
warn "excalidraw MCP setup failed (non-fatal) — run 'mosaic-ensure-excalidraw' to retry"
|
||||
fi
|
||||
|
||||
if [[ "${MOSAIC_SKIP_SKILLS_SYNC:-0}" == "1" ]]; then
|
||||
ok "Skills sync skipped (MOSAIC_SKIP_SKILLS_SYNC=1)"
|
||||
else
|
||||
|
||||
7
runtime/mcp/EXCALIDRAW.json
Normal file
7
runtime/mcp/EXCALIDRAW.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "excalidraw",
|
||||
"launch": "${MOSAIC_TOOLS}/excalidraw/launch.sh",
|
||||
"enabled": true,
|
||||
"required": false,
|
||||
"description": "Headless .excalidraw → SVG export and diagram generation via @excalidraw/excalidraw"
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import type { WizardPrompter } from '../prompter/interface.js';
|
||||
import type { WizardState, RuntimeName } from '../types.js';
|
||||
import { detectRuntime, type RuntimeInfo } from '../runtime/detector.js';
|
||||
@@ -66,5 +70,20 @@ export async function runtimeSetupStage(
|
||||
`MCP setup failed: ${err instanceof Error ? err.message : String(err)}. Run 'mosaic seq fix' later.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Configure excalidraw MCP (non-fatal — optional tool)
|
||||
const mosaicHome = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
||||
const ensureExcalidraw = join(mosaicHome, 'bin', 'mosaic-ensure-excalidraw');
|
||||
if (existsSync(ensureExcalidraw)) {
|
||||
const spin3 = p.spinner();
|
||||
spin3.update('Configuring excalidraw MCP...');
|
||||
const res = spawnSync(ensureExcalidraw, [], { encoding: 'utf8' });
|
||||
if (res.status === 0) {
|
||||
spin3.stop('excalidraw MCP configured');
|
||||
} else {
|
||||
spin3.stop('excalidraw MCP setup failed (non-fatal)');
|
||||
p.warn("Run 'mosaic-ensure-excalidraw' manually if needed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1
tools/excalidraw/.gitignore
vendored
Normal file
1
tools/excalidraw/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
5
tools/excalidraw/launch.sh
Executable file
5
tools/excalidraw/launch.sh
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Launcher for Excalidraw MCP stdio server.
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec node --loader "$SCRIPT_DIR/loader.mjs" "$SCRIPT_DIR/server.mjs"
|
||||
76
tools/excalidraw/loader.mjs
Normal file
76
tools/excalidraw/loader.mjs
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Custom ESM loader to fix missing .js extensions in @excalidraw/excalidraw deps.
|
||||
*
|
||||
* Problems patched:
|
||||
* 1. excalidraw imports 'roughjs/bin/rough' (and other roughjs/* paths) without .js
|
||||
* 2. roughjs/* files import sibling modules as './canvas' (relative, no .js)
|
||||
* 3. JSON files need { type: 'json' } import attribute in Node.js v22+
|
||||
*
|
||||
* Usage: node --loader ./loader.mjs server.mjs [args...]
|
||||
*/
|
||||
|
||||
import { fileURLToPath, pathToFileURL } from 'url';
|
||||
import { dirname, resolve as pathResolve } from 'path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Modules that have incompatible ESM format — redirect to local stubs
|
||||
const STUBS = {
|
||||
'@excalidraw/laser-pointer': pathToFileURL(pathResolve(__dirname, 'stubs/laser-pointer.mjs')).href,
|
||||
};
|
||||
|
||||
export async function resolve(specifier, context, nextResolve) {
|
||||
// 0. Module stubs (incompatible ESM format packages)
|
||||
if (STUBS[specifier]) {
|
||||
return { url: STUBS[specifier], shortCircuit: true };
|
||||
}
|
||||
|
||||
// 1. Bare roughjs/* specifiers without .js extension
|
||||
if (/^roughjs\/bin\/[a-z-]+$/.test(specifier)) {
|
||||
return nextResolve(`${specifier}.js`, context);
|
||||
}
|
||||
|
||||
// 2. Relative imports without extension (e.g. './canvas' from roughjs/bin/rough.js)
|
||||
// These come in as relative paths that resolve to extensionless file URLs.
|
||||
if (specifier.startsWith('./') || specifier.startsWith('../')) {
|
||||
// Try resolving first; if it fails with a missing-extension error, add .js
|
||||
try {
|
||||
return await nextResolve(specifier, context);
|
||||
} catch (err) {
|
||||
if (err.code === 'ERR_MODULE_NOT_FOUND') {
|
||||
// Try appending .js
|
||||
try {
|
||||
return await nextResolve(`${specifier}.js`, context);
|
||||
} catch {
|
||||
// Fall through to original error
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. JSON imports need type: 'json' attribute
|
||||
if (specifier.endsWith('.json')) {
|
||||
const resolved = await nextResolve(specifier, context);
|
||||
if (!resolved.importAttributes?.type) {
|
||||
return {
|
||||
...resolved,
|
||||
importAttributes: { ...resolved.importAttributes, type: 'json' },
|
||||
};
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
return nextResolve(specifier, context);
|
||||
}
|
||||
|
||||
export async function load(url, context, nextLoad) {
|
||||
// Ensure JSON files are loaded with json format
|
||||
if (url.endsWith('.json')) {
|
||||
return nextLoad(url, {
|
||||
...context,
|
||||
importAttributes: { ...context.importAttributes, type: 'json' },
|
||||
});
|
||||
}
|
||||
return nextLoad(url, context);
|
||||
}
|
||||
11
tools/excalidraw/package.json
Normal file
11
tools/excalidraw/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "excalidraw-mcp",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.12.0",
|
||||
"@excalidraw/excalidraw": "^0.18.0",
|
||||
"jsdom": "^25.0.1"
|
||||
}
|
||||
}
|
||||
323
tools/excalidraw/server.mjs
Normal file
323
tools/excalidraw/server.mjs
Normal file
@@ -0,0 +1,323 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Excalidraw MCP stdio server
|
||||
* Provides headless .excalidraw → SVG export via @excalidraw/excalidraw.
|
||||
* Optional: diagram generation via EXCALIDRAW_GEN_PATH (excalidraw_gen.py).
|
||||
*/
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { z } from "zod/v3";
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. DOM environment — must be established BEFORE importing excalidraw
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
|
||||
url: 'http://localhost/',
|
||||
pretendToBeVisual: true,
|
||||
});
|
||||
|
||||
const { window } = dom;
|
||||
|
||||
// Helper: define a global, overriding read-only getters (e.g. navigator in Node v22)
|
||||
function defineGlobal(key, value) {
|
||||
if (value === undefined) return;
|
||||
try {
|
||||
Object.defineProperty(global, key, {
|
||||
value,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
} catch {
|
||||
// Already defined and non-configurable — skip
|
||||
}
|
||||
}
|
||||
|
||||
// Core DOM globals
|
||||
defineGlobal('window', window);
|
||||
defineGlobal('document', window.document);
|
||||
defineGlobal('navigator', window.navigator);
|
||||
defineGlobal('location', window.location);
|
||||
defineGlobal('history', window.history);
|
||||
defineGlobal('screen', window.screen);
|
||||
|
||||
// Element / event interfaces
|
||||
for (const key of [
|
||||
'Node', 'Element', 'HTMLElement', 'SVGElement', 'SVGSVGElement',
|
||||
'HTMLCanvasElement', 'HTMLImageElement', 'Image',
|
||||
'Event', 'CustomEvent', 'MouseEvent', 'PointerEvent',
|
||||
'KeyboardEvent', 'TouchEvent', 'WheelEvent', 'InputEvent',
|
||||
'MutationObserver', 'ResizeObserver', 'IntersectionObserver',
|
||||
'XMLHttpRequest', 'XMLSerializer',
|
||||
'DOMParser', 'Range',
|
||||
'getComputedStyle', 'matchMedia',
|
||||
]) {
|
||||
defineGlobal(key, window[key]);
|
||||
}
|
||||
|
||||
// Animation frame stubs (jsdom doesn't implement them)
|
||||
global.requestAnimationFrame = (fn) => setTimeout(() => fn(Date.now()), 0);
|
||||
global.cancelAnimationFrame = (id) => clearTimeout(id);
|
||||
|
||||
// CSS Font Loading API stub — jsdom doesn't implement FontFace
|
||||
class FontFaceStub {
|
||||
constructor(family, source, _descriptors) {
|
||||
this.family = family;
|
||||
this.source = source;
|
||||
this.status = 'loaded';
|
||||
this.loaded = Promise.resolve(this);
|
||||
}
|
||||
load() { return Promise.resolve(this); }
|
||||
}
|
||||
defineGlobal('FontFace', FontFaceStub);
|
||||
|
||||
// FontFaceSet stub for document.fonts
|
||||
const fontFaceSet = {
|
||||
add: () => {},
|
||||
delete: () => {},
|
||||
has: () => false,
|
||||
clear: () => {},
|
||||
load: () => Promise.resolve([]),
|
||||
check: () => true,
|
||||
ready: Promise.resolve(),
|
||||
status: 'loaded',
|
||||
forEach: () => {},
|
||||
[Symbol.iterator]: function*() {},
|
||||
};
|
||||
Object.defineProperty(window.document, 'fonts', {
|
||||
value: fontFaceSet,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// Canvas stub — excalidraw's exportToSvg doesn't need real canvas rendering,
|
||||
// but the class must exist for isinstance checks.
|
||||
if (!global.HTMLCanvasElement) {
|
||||
defineGlobal('HTMLCanvasElement', window.HTMLCanvasElement ?? class HTMLCanvasElement {});
|
||||
}
|
||||
|
||||
// Device pixel ratio
|
||||
global.devicePixelRatio = 1;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1b. Stub canvas getContext — excalidraw calls this at module init time.
|
||||
// jsdom throws "Not implemented" by default; we return a no-op 2D stub.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const _canvasCtx = {
|
||||
canvas: { width: 800, height: 600 },
|
||||
fillRect: () => {}, clearRect: () => {}, strokeRect: () => {},
|
||||
getImageData: (x, y, w, h) => ({ data: new Uint8ClampedArray(w * h * 4), width: w, height: h }),
|
||||
putImageData: () => {}, createImageData: () => ({ data: new Uint8ClampedArray(0) }),
|
||||
setTransform: () => {}, resetTransform: () => {}, transform: () => {},
|
||||
drawImage: () => {}, save: () => {}, restore: () => {},
|
||||
scale: () => {}, rotate: () => {}, translate: () => {},
|
||||
beginPath: () => {}, closePath: () => {}, moveTo: () => {}, lineTo: () => {},
|
||||
bezierCurveTo: () => {}, quadraticCurveTo: () => {},
|
||||
arc: () => {}, arcTo: () => {}, ellipse: () => {}, rect: () => {},
|
||||
fill: () => {}, stroke: () => {}, clip: () => {},
|
||||
fillText: () => {}, strokeText: () => {},
|
||||
measureText: (t) => ({ width: t.length * 8, actualBoundingBoxAscent: 12, actualBoundingBoxDescent: 3, fontBoundingBoxAscent: 14, fontBoundingBoxDescent: 4 }),
|
||||
createLinearGradient: () => ({ addColorStop: () => {} }),
|
||||
createRadialGradient: () => ({ addColorStop: () => {} }),
|
||||
createPattern: () => null,
|
||||
setLineDash: () => {}, getLineDash: () => [],
|
||||
isPointInPath: () => false, isPointInStroke: () => false,
|
||||
getContextAttributes: () => ({ alpha: true, desynchronized: false }),
|
||||
font: '10px sans-serif', fillStyle: '#000', strokeStyle: '#000',
|
||||
lineWidth: 1, lineCap: 'butt', lineJoin: 'miter',
|
||||
textAlign: 'start', textBaseline: 'alphabetic',
|
||||
globalAlpha: 1, globalCompositeOperation: 'source-over',
|
||||
shadowOffsetX: 0, shadowOffsetY: 0, shadowBlur: 0, shadowColor: 'transparent',
|
||||
miterLimit: 10, lineDashOffset: 0, filter: 'none', imageSmoothingEnabled: true,
|
||||
};
|
||||
|
||||
// Patch before excalidraw import so module-level canvas calls get the stub
|
||||
if (window.HTMLCanvasElement) {
|
||||
window.HTMLCanvasElement.prototype.getContext = function (type) {
|
||||
if (type === '2d') return _canvasCtx;
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Load excalidraw (dynamic import so globals are set first)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let exportToSvg;
|
||||
try {
|
||||
const excalidraw = await import('@excalidraw/excalidraw');
|
||||
exportToSvg = excalidraw.exportToSvg;
|
||||
if (!exportToSvg) throw new Error('exportToSvg not found in package exports');
|
||||
} catch (err) {
|
||||
process.stderr.write(`FATAL: Failed to load @excalidraw/excalidraw: ${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. SVG export helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function renderToSvg(elements, appState, files) {
|
||||
const svgEl = await exportToSvg({
|
||||
elements: elements ?? [],
|
||||
appState: {
|
||||
exportWithDarkMode: false,
|
||||
exportBackground: true,
|
||||
viewBackgroundColor: '#ffffff',
|
||||
...appState,
|
||||
},
|
||||
files: files ?? {},
|
||||
});
|
||||
const serializer = new window.XMLSerializer();
|
||||
return serializer.serializeToString(svgEl);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Gen subprocess helper (optional — requires EXCALIDRAW_GEN_PATH)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function requireGenPath() {
|
||||
const p = process.env.EXCALIDRAW_GEN_PATH;
|
||||
if (!p) {
|
||||
return null;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
function spawnGen(args) {
|
||||
const genPath = requireGenPath();
|
||||
if (!genPath) {
|
||||
return {
|
||||
ok: false,
|
||||
text: 'EXCALIDRAW_GEN_PATH is not set. Set it to the path of excalidraw_gen.py to use diagram generation.',
|
||||
};
|
||||
}
|
||||
const result = spawnSync('python3', [genPath, ...args], { encoding: 'utf8' });
|
||||
if (result.error) return { ok: false, text: `spawn error: ${result.error.message}` };
|
||||
if (result.status !== 0) return { ok: false, text: result.stderr || 'subprocess failed' };
|
||||
return { ok: true, text: result.stdout.trim() };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. MCP Server
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const server = new McpServer({
|
||||
name: "excalidraw",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
// --- Tool: excalidraw_to_svg ---
|
||||
|
||||
server.tool(
|
||||
"excalidraw_to_svg",
|
||||
"Convert Excalidraw elements JSON to SVG string",
|
||||
{
|
||||
elements: z.string().describe("JSON string of Excalidraw elements array"),
|
||||
app_state: z.string().optional().describe("JSON string of appState overrides"),
|
||||
},
|
||||
async ({ elements, app_state }) => {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(elements);
|
||||
} catch (err) {
|
||||
throw new Error(`Invalid elements JSON: ${err.message}`);
|
||||
}
|
||||
const appState = app_state ? JSON.parse(app_state) : {};
|
||||
const svg = await renderToSvg(parsed, appState, {});
|
||||
return { content: [{ type: "text", text: svg }] };
|
||||
}
|
||||
);
|
||||
|
||||
// --- Tool: excalidraw_file_to_svg ---
|
||||
|
||||
server.tool(
|
||||
"excalidraw_file_to_svg",
|
||||
"Convert an .excalidraw file to SVG (writes .svg alongside the input file)",
|
||||
{
|
||||
file_path: z.string().describe("Absolute or relative path to .excalidraw file"),
|
||||
},
|
||||
async ({ file_path }) => {
|
||||
const absPath = resolve(file_path);
|
||||
if (!existsSync(absPath)) {
|
||||
throw new Error(`File not found: ${absPath}`);
|
||||
}
|
||||
const raw = JSON.parse(readFileSync(absPath, 'utf8'));
|
||||
const svg = await renderToSvg(raw.elements, raw.appState, raw.files);
|
||||
const outPath = absPath.replace(/\.excalidraw$/, '.svg');
|
||||
writeFileSync(outPath, svg, 'utf8');
|
||||
return {
|
||||
content: [{ type: "text", text: `SVG written to: ${outPath}\n\n${svg}` }],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// --- Tool: list_diagrams ---
|
||||
|
||||
server.tool(
|
||||
"list_diagrams",
|
||||
"List available diagram templates from the DIAGRAMS registry (requires EXCALIDRAW_GEN_PATH)",
|
||||
{},
|
||||
async () => {
|
||||
const res = spawnGen(['--list']);
|
||||
return { content: [{ type: "text", text: res.text }] };
|
||||
}
|
||||
);
|
||||
|
||||
// --- Tool: generate_diagram ---
|
||||
|
||||
server.tool(
|
||||
"generate_diagram",
|
||||
"Generate an .excalidraw file from a named diagram template (requires EXCALIDRAW_GEN_PATH)",
|
||||
{
|
||||
name: z.string().describe("Diagram template name (from list_diagrams)"),
|
||||
output_path: z.string().optional().describe("Output path for the .excalidraw file"),
|
||||
},
|
||||
async ({ name, output_path }) => {
|
||||
const args = [name];
|
||||
if (output_path) args.push('--output', output_path);
|
||||
const res = spawnGen(args);
|
||||
if (!res.ok) throw new Error(res.text);
|
||||
return { content: [{ type: "text", text: res.text }] };
|
||||
}
|
||||
);
|
||||
|
||||
// --- Tool: generate_and_export ---
|
||||
|
||||
server.tool(
|
||||
"generate_and_export",
|
||||
"Generate an .excalidraw file and immediately export it to SVG (requires EXCALIDRAW_GEN_PATH)",
|
||||
{
|
||||
name: z.string().describe("Diagram template name (from list_diagrams)"),
|
||||
output_path: z.string().optional().describe("Output path for the .excalidraw file (SVG written alongside)"),
|
||||
},
|
||||
async ({ name, output_path }) => {
|
||||
const genArgs = [name];
|
||||
if (output_path) genArgs.push('--output', output_path);
|
||||
const genRes = spawnGen(genArgs);
|
||||
if (!genRes.ok) throw new Error(genRes.text);
|
||||
|
||||
const excalidrawPath = genRes.text;
|
||||
if (!existsSync(excalidrawPath)) {
|
||||
throw new Error(`Generated file not found: ${excalidrawPath}`);
|
||||
}
|
||||
|
||||
const raw = JSON.parse(readFileSync(excalidrawPath, 'utf8'));
|
||||
const svg = await renderToSvg(raw.elements, raw.appState, raw.files);
|
||||
const svgPath = excalidrawPath.replace(/\.excalidraw$/, '.svg');
|
||||
writeFileSync(svgPath, svg, 'utf8');
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `Generated: ${excalidrawPath}\nExported SVG: ${svgPath}` }],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// --- Start ---
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
7
tools/excalidraw/stubs/laser-pointer.mjs
Normal file
7
tools/excalidraw/stubs/laser-pointer.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Stub for @excalidraw/laser-pointer
|
||||
* The real package uses a Parcel bundle format that Node.js ESM can't consume.
|
||||
* For headless SVG export, the laser pointer feature is not needed.
|
||||
*/
|
||||
export class LaserPointer {}
|
||||
export default { LaserPointer };
|
||||
Reference in New Issue
Block a user