Compare commits
@@ -15,6 +15,9 @@ public/dist/
|
||||
.cursor/
|
||||
.claude/
|
||||
|
||||
# User preferences (only the example ships)
|
||||
skills/excalidraw-skill/preferences.json
|
||||
|
||||
# Development artifacts
|
||||
*.excalidraw
|
||||
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
# Builds the MCP server with SQLite persistence
|
||||
|
||||
# Stage 1: Build backend (TypeScript compilation + native modules)
|
||||
FROM node:18-slim AS builder
|
||||
FROM node:20-slim AS builder
|
||||
|
||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -16,7 +16,7 @@ COPY tsconfig.json ./
|
||||
RUN npm run build:server
|
||||
|
||||
# Stage 2: Production MCP Server
|
||||
FROM node:18-slim AS production
|
||||
FROM node:20-slim AS production
|
||||
|
||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
+3
-3
@@ -2,7 +2,7 @@
|
||||
# Provides the web interface, REST API, and SQLite persistence
|
||||
|
||||
# Stage 1: Build frontend
|
||||
FROM node:18-slim AS frontend-builder
|
||||
FROM node:20-slim AS frontend-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -14,7 +14,7 @@ COPY vite.config.js ./
|
||||
RUN npm run build:frontend
|
||||
|
||||
# Stage 2: Build backend (TypeScript compilation + native modules)
|
||||
FROM node:18-slim AS backend-builder
|
||||
FROM node:20-slim AS backend-builder
|
||||
|
||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -28,7 +28,7 @@ COPY tsconfig.json ./
|
||||
RUN npm run build:server
|
||||
|
||||
# Stage 3: Production Canvas Server
|
||||
FROM node:18-slim AS production
|
||||
FROM node:20-slim AS production
|
||||
|
||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ Click the workspace badge to switch between isolated canvases — each workspace
|
||||
- [Quick Start](#quick-start)
|
||||
- [Configuration](#configuration)
|
||||
- [Verify Installation](#verify-installation)
|
||||
- [Updating](#updating)
|
||||
- [How We Differ from the Official Excalidraw MCP](#how-we-differ-from-the-official-excalidraw-mcp)
|
||||
- [What Changed From Upstream](#what-changed-from-upstream)
|
||||
- [Architecture](#architecture)
|
||||
@@ -58,7 +59,7 @@ Click the workspace badge to switch between isolated canvases — each workspace
|
||||
|
||||
| Requirement | Why | Check |
|
||||
|---|---|---|
|
||||
| **Node.js >= 18** (LTS 20 or 22 recommended) | Runtime | `node --version` |
|
||||
| **Node.js >= 20** (LTS 20 or 22 recommended) | Runtime | `node --version` |
|
||||
| **C++ build tools** | `better-sqlite3` compiles native bindings | See below |
|
||||
| **npm** (bundled with Node.js) | Package manager | `npm --version` |
|
||||
|
||||
@@ -75,9 +76,7 @@ sudo apt install build-essential python3
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
```bash
|
||||
npm install --global windows-build-tools
|
||||
```
|
||||
Install "Desktop development with C++" from [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/).
|
||||
|
||||
> `better-sqlite3` ships prebuilt binaries for most Node LTS versions. The build tools are only needed when a prebuilt binary isn't available for your platform/Node combination.
|
||||
|
||||
@@ -282,6 +281,119 @@ open http://localhost:3000
|
||||
|
||||
If the health check fails, see [Troubleshooting](#troubleshooting).
|
||||
|
||||
## Updating
|
||||
|
||||
Already installed a previous version? The interactive update wizard is the easiest way to update everything — MCP server **and** agent skills — in one go.
|
||||
|
||||
### Interactive Update (recommended)
|
||||
|
||||
```bash
|
||||
npx @sanjibdevnath/mcp-excalidraw-local@latest update
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Example session</summary>
|
||||
|
||||
```
|
||||
$ npx @sanjibdevnath/mcp-excalidraw-local@latest update
|
||||
|
||||
Excalidraw MCP — Update v1.2.0
|
||||
|
||||
[1/2] Skill Update
|
||||
|
||||
Found 2 existing skill installation(s):
|
||||
[1] Cursor (global) — ~/.cursor/skills/excalidraw-skill
|
||||
[2] Claude Code (global) — ~/.claude/skills/excalidraw-skill
|
||||
|
||||
Update all 2 installation(s) to v1.2.0? [Y/n]: Y
|
||||
✔ Updated Cursor (global) — ~/.cursor/skills/excalidraw-skill
|
||||
✔ Updated Claude Code (global) — ~/.claude/skills/excalidraw-skill
|
||||
|
||||
2/2 skill(s) updated.
|
||||
|
||||
[2/2] MCP Configuration
|
||||
Re-apply MCP server config? (overwrites existing entry) [y/N]: N
|
||||
MCP config unchanged.
|
||||
|
||||
Update complete! Restart your MCP client to pick up changes.
|
||||
```
|
||||
</details>
|
||||
|
||||
The update wizard:
|
||||
1. **Finds all existing skill installations** across Cursor, Claude Code, and Codex CLI (both global and local scopes)
|
||||
2. **Updates them in-place** with the latest skill files (SKILL.md, cheatsheet, geometric-thinking reference, helper scripts)
|
||||
3. **Offers to install** the skill for any detected agent that doesn't have it yet
|
||||
4. **Optionally re-applies MCP config** if needed
|
||||
|
||||
> **Why this matters:** The agent skill contains workflow guidance, sizing rules, color palettes, and anti-patterns that evolve alongside the MCP tools. Updating the MCP server without updating the skill means your AI agent is working with stale instructions.
|
||||
|
||||
### Manual Update by Installation Method
|
||||
|
||||
If you prefer to update manually, follow the steps for your installation method, then restart your MCP client.
|
||||
|
||||
#### npx users
|
||||
|
||||
If your MCP config uses `npx -y @sanjibdevnath/mcp-excalidraw-local`, npx caches the package locally and won't automatically fetch new versions.
|
||||
|
||||
**Option A — Clear the cache (one-time):**
|
||||
```bash
|
||||
npm cache clean --force
|
||||
```
|
||||
Then restart your MCP client. npx will download the latest version on next launch.
|
||||
|
||||
**Option B — Pin to `@latest` in your MCP config (permanent fix):**
|
||||
|
||||
Update the `args` in your MCP config to include `@latest`:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"excalidraw-canvas": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@sanjibdevnath/mcp-excalidraw-local@latest"],
|
||||
"env": { "CANVAS_PORT": "3000" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This ensures npx always checks for the newest published version.
|
||||
|
||||
#### From-source users
|
||||
|
||||
```bash
|
||||
cd mcp-excalidraw-local
|
||||
git pull origin main
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
#### Docker users
|
||||
|
||||
```bash
|
||||
docker pull sanjibdevnath/mcp-excalidraw-local:latest
|
||||
docker pull sanjibdevnath/mcp-excalidraw-local-canvas:latest
|
||||
```
|
||||
|
||||
Then recreate your containers (`docker compose up -d` or `docker run` again).
|
||||
|
||||
#### Updating the agent skill manually
|
||||
|
||||
If you skipped the interactive update, copy the skill files yourself:
|
||||
```bash
|
||||
cp -R skills/excalidraw-skill ~/.cursor/skills/excalidraw-skill
|
||||
cp -R skills/excalidraw-skill ~/.claude/skills/excalidraw-skill
|
||||
```
|
||||
|
||||
### Verify the update
|
||||
|
||||
```bash
|
||||
# Check the running version
|
||||
curl -s http://localhost:3000/health
|
||||
|
||||
# Or check the installed package version
|
||||
npx @sanjibdevnath/mcp-excalidraw-local --version
|
||||
```
|
||||
|
||||
## How We Differ from the Official Excalidraw MCP
|
||||
|
||||
Excalidraw now has an [official MCP](https://github.com/excalidraw/excalidraw-mcp) — it's great for quick, prompt-to-diagram generation rendered inline in chat. We solve a different problem.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@sanjibdevnath/mcp-excalidraw-local",
|
||||
"version": "1.1.2",
|
||||
"version": "1.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@sanjibdevnath/mcp-excalidraw-local",
|
||||
"version": "1.1.2",
|
||||
"version": "1.5.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@sanjibdevnath/mcp-excalidraw-local",
|
||||
"version": "1.1.2",
|
||||
"version": "1.5.0",
|
||||
"description": "Fully local MCP server for Excalidraw with SQLite persistence, multi-tenancy, auto-sync, real-time canvas, and 32 tools",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
@@ -18,8 +18,8 @@
|
||||
"dev:server": "npx tsc --watch",
|
||||
"production": "npm run build && npm run canvas",
|
||||
"prepublishOnly": "npm run build",
|
||||
"postinstall": "prebuild-install --runtime napi || node-gyp rebuild --directory node_modules/better-sqlite3 2>/dev/null || true",
|
||||
"setup": "node dist/index.js setup",
|
||||
"update": "node dist/index.js update",
|
||||
"type-check": "npx tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
@@ -107,7 +107,7 @@
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: excalidraw-skill
|
||||
description: Programmatic canvas toolkit for creating, editing, and refining Excalidraw diagrams via MCP tools (32 tools) or REST API with real-time canvas sync, multi-tenant workspace isolation, SQLite persistence, project management, full-text search, and element version history. Use when an agent needs to draw or lay out diagrams on a live canvas, iteratively refine diagrams using screenshots, manage workspaces/tenants and projects, export/import .excalidraw files or PNG/SVG images, search elements, view change history, save/restore canvas snapshots, or perform element-level CRUD. Canvas server port is configurable via CANVAS_PORT env var (default 3000).
|
||||
description: MANDATORY prerequisite for ALL Excalidraw MCP tool usage. Read this skill BEFORE calling any Excalidraw tool (batch_create_elements, create_element, create_from_mermaid, update_element, etc.) — without this skill's sizing formulas, two-batch ordering (shapes first, arrows second), and write-check-review verification cycle, diagrams will have invisible arrows, truncated text, and overlapping elements. Use whenever the user asks to draw, create, visualize, sketch, or diagram anything — flowcharts, architecture diagrams, system designs, org charts, sequence flows, decision trees, network topologies, ER diagrams, mind maps, or any visual on Excalidraw canvas. Also covers diagram refinement, PNG/SVG export, project/workspace management, and all canvas interactions.
|
||||
---
|
||||
|
||||
# Excalidraw Skill
|
||||
@@ -15,6 +15,79 @@ Run these checks **in order**:
|
||||
|
||||
See `references/cheatsheet.md` for the full MCP-vs-REST mapping and REST API gotchas.
|
||||
|
||||
## Step 1: Load User Preferences
|
||||
|
||||
Before creating any elements, load the user's diagram preferences. These control default font, roughness, stroke width, etc.
|
||||
|
||||
### Preference Resolution Order (most specific wins)
|
||||
|
||||
| Priority | Scope | Location | Persists |
|
||||
|----------|-------|----------|----------|
|
||||
| 1 (highest) | Session | In-memory (set via prompt during this conversation) | No — current session only |
|
||||
| 2 | Folder | `.claude/excalidraw-preferences.json` in the current project root | Yes — per-project |
|
||||
| 3 | Global | `~/.claude/skills/excalidraw-skill/preferences.json` | Yes — all projects |
|
||||
| 4 (lowest) | Hardcoded | Server defaults (fontFamily: 1, roughness: 0, fontSize: 20, strokeWidth: 2) | — |
|
||||
|
||||
### How to Load
|
||||
|
||||
1. **Check folder-level first**: Read `.claude/excalidraw-preferences.json` from the current working directory (or project root). If it exists and has `defaults`, use those values.
|
||||
2. **Fall back to global**: Read `~/.claude/skills/excalidraw-skill/preferences.json`. If it exists and has `defaults`, use those values.
|
||||
3. **If neither exists** → run the **First-Time Setup** prompt below.
|
||||
4. **Merge**: Folder preferences override global; global overrides hardcoded. Only override fields that are explicitly set.
|
||||
|
||||
### First-Time Setup (Interactive)
|
||||
|
||||
If no preferences file exists at either location, **prompt the user before drawing anything**:
|
||||
|
||||
> **Excalidraw Preferences Setup**
|
||||
>
|
||||
> I don't have any saved diagram preferences yet. Let me set up your defaults so every diagram looks the way you want.
|
||||
|
||||
Ask these questions (use `AskUserQuestion` tool if available, otherwise ask inline):
|
||||
|
||||
1. **Font family** — Which font for all text?
|
||||
- Excalifont (hand-drawn) = 1
|
||||
- Helvetica (sans-serif) = 2
|
||||
- Cascadia (monospace) = 3
|
||||
- Comic Shanns = 4
|
||||
- Nunito = 6
|
||||
- Lilita One = 7
|
||||
|
||||
2. **Roughness** — Diagram style?
|
||||
- Clean/professional (roughness: 0) — recommended
|
||||
- Hand-drawn sketch (roughness: 1)
|
||||
- Very rough (roughness: 2)
|
||||
|
||||
3. **Scope** — Where to save?
|
||||
- **This session only** — don't save to disk, just use for this conversation
|
||||
- **This project** — save to `.claude/excalidraw-preferences.json` in project root
|
||||
- **Global (all projects)** — save to `~/.claude/skills/excalidraw-skill/preferences.json`
|
||||
|
||||
Then save the preferences JSON to the chosen location:
|
||||
|
||||
```json
|
||||
{
|
||||
"defaults": {
|
||||
"fontFamily": <user_choice>,
|
||||
"fontSize": 20,
|
||||
"roughness": <user_choice>,
|
||||
"strokeWidth": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For session-only scope, just hold the values in memory and apply them to every element in this conversation.
|
||||
|
||||
### Applying Preferences
|
||||
|
||||
Once loaded, apply `defaults` to **every element** that supports the property:
|
||||
- `fontFamily` → all text-containing elements (text, rectangles with labels, diamonds, ellipses, arrows with labels)
|
||||
- `fontSize` → text elements and labels (unless the element explicitly overrides it)
|
||||
- `roughness` → all elements
|
||||
- `strokeWidth` → arrows and lines
|
||||
|
||||
User-specified values in individual element calls always override preferences.
|
||||
|
||||
## Core Principles (Read Before Any Diagram)
|
||||
|
||||
These principles were learned through extensive iterative use. Violating them produces bad diagrams.
|
||||
@@ -288,6 +361,118 @@ Layout:
|
||||
Title: fontSize=24 above each zone
|
||||
```
|
||||
|
||||
## Geometric Thinking (Critical — For All Diagram Types)
|
||||
|
||||
Excalidraw only offers basic primitives: rectangles, diamonds, ellipses, lines, arrows, text. Building anything beyond simple box-and-arrow diagrams requires **geometric composition** — combining many small primitives using coordinate math to form complex shapes, textures, and layouts.
|
||||
|
||||
### Principle 1: Compose Complex Shapes from Many Small Primitives
|
||||
|
||||
**Never use one large primitive where many small ones create a better result.**
|
||||
|
||||
A single large diamond looks like a flat rhombus. But 45 small diamonds arranged in a triangular grid with tessellating offset rows looks like a tiled roof. The technique:
|
||||
|
||||
1. **Identify the target shape** (triangle, circle, arc, wave, etc.)
|
||||
2. **Choose a small primitive** that can tile/fill that shape (diamond for tiles, rectangle for bricks, ellipse for clouds)
|
||||
3. **Compute a grid of positions** that fills the target shape's boundary
|
||||
4. **Apply row-by-row reduction** for tapered shapes (triangles, cones)
|
||||
5. **Add interleaving offset rows** to eliminate gaps (tessellation)
|
||||
|
||||
```
|
||||
Example — Triangular tiled roof (building width=380, center_x=1090):
|
||||
|
||||
Tile size: 52w × 36h
|
||||
Row spacing: 28px vertical (= tile height)
|
||||
Interleave offset: 26px horizontal (= tile width / 2)
|
||||
Interleave rows at: midpoint y between main rows
|
||||
|
||||
Main rows (reduce by 2 tiles per row):
|
||||
Row 1: 9 tiles at y=130 | ◇◇◇◇◇◇◇◇◇
|
||||
Row 2: 7 tiles at y=102 | ◇◇◇◇◇◇◇
|
||||
Row 3: 5 tiles at y=74 | ◇◇◇◇◇
|
||||
Row 4: 3 tiles at y=46 | ◇◇◇
|
||||
Row 5: 1 tile at y=18 | ◇
|
||||
|
||||
Interleaving rows (offset by half tile width, fill gaps):
|
||||
Inter 1: 8 tiles at y=116 | ◇◇◇◇◇◇◇◇
|
||||
Inter 2: 6 tiles at y=88 | ◇◇◇◇◇◇
|
||||
Inter 3: 4 tiles at y=60 | ◇◇◇◇
|
||||
Inter 4: 2 tiles at y=32 | ◇◇
|
||||
|
||||
Total: 45 tiles → seamless triangular roof
|
||||
```
|
||||
|
||||
### Principle 2: Tessellation — Eliminate Gaps with Offset Rows
|
||||
|
||||
When same-row primitives leave triangular/pointed gaps, add **interleaving rows** offset by half the primitive width:
|
||||
|
||||
```
|
||||
Gap pattern (diamonds side-by-side): Filled with interleaving row:
|
||||
/\ /\ /\ /\ /\ /\ /\ /\
|
||||
/ \/ \/ \/ \ / \/ \/ \/ \
|
||||
\ /\ /\ /\ / \ /\/\/\/\/\/\ /
|
||||
\/ \/ \/ \/ ◇◇◇◇◇◇◇◇◇ ← offset row
|
||||
/\ /\ /\ /\
|
||||
```
|
||||
|
||||
**Formula for interleaving:**
|
||||
```
|
||||
main_row_y[n] = base_y - n * row_spacing
|
||||
inter_row_y[n] = (main_row_y[n] + main_row_y[n+1]) / 2
|
||||
inter_row_x_offset = tile_width / 2
|
||||
inter_row_count = main_row_count[n] - 1
|
||||
```
|
||||
|
||||
### Principle 3: Parametric Positioning — Use Formulas, Not Guessing
|
||||
|
||||
Compute element positions mathematically. Common formulas:
|
||||
|
||||
**Centering N items in a container:**
|
||||
```
|
||||
item_x[i] = container_x + (container_width - N * item_width) / (N + 1) * (i + 1) + i * item_width
|
||||
```
|
||||
|
||||
**Circular arrangement (N items around center):**
|
||||
```
|
||||
angle[i] = (2π / N) * i + rotation_offset
|
||||
x[i] = center_x + radius * cos(angle[i]) - item_width / 2
|
||||
y[i] = center_y + radius * sin(angle[i]) - item_height / 2
|
||||
```
|
||||
|
||||
**Triangular reduction (pyramid/roof):**
|
||||
```
|
||||
count[row] = base_count - 2 * row
|
||||
x_start[row] = center_x - (count[row] * tile_width) / 2
|
||||
y[row] = base_y - row * row_spacing
|
||||
```
|
||||
|
||||
**Isometric projection (2.5D diagrams):**
|
||||
```
|
||||
screen_x = (grid_x - grid_y) * tile_width / 2
|
||||
screen_y = (grid_x + grid_y) * tile_height / 2
|
||||
```
|
||||
|
||||
### Principle 4: Scale Primitives to Context
|
||||
|
||||
When applying a technique to different-sized containers, **scale the primitive size proportionally**:
|
||||
|
||||
```
|
||||
Hut (body width 290px) → tiles 40w × 28h, 9 per base row
|
||||
Building (body width 380px) → tiles 52w × 36h, 9 per base row
|
||||
```
|
||||
|
||||
Maintain the same count-per-row for visual consistency; adjust individual tile dimensions.
|
||||
|
||||
### Technique Catalogs
|
||||
|
||||
For detailed recipes and formulas for specific diagram types, see [geometric-thinking.md](references/geometric-thinking.md):
|
||||
|
||||
- **Illustrative diagrams**: 11 visual element recipes (roofs, walls, clouds, trees, fences, water, smoke, windows, stairs)
|
||||
- **Flow diagrams**: Sugiyama-inspired 4-step hierarchical layout (layer assignment → ordering → coordinates → edge routing)
|
||||
- **Architecture diagrams**: Zone-grid layout with service grid-packing and dependency layering
|
||||
- **Isometric/2.5D diagrams**: Screen projection formulas for depth illusion
|
||||
- **Repeating patterns**: Generic repeat and brick-pattern offset formulas
|
||||
- **Anti-patterns**: 6 common geometric composition mistakes and fixes
|
||||
|
||||
## Workflow: Iterative Refinement
|
||||
|
||||
```
|
||||
@@ -303,55 +488,18 @@ create shapes (batch 1)
|
||||
|
||||
For multi-diagram canvases, offset each new diagram by 300px+ from the previous one's bounding box.
|
||||
|
||||
## Workflow: Multi-Tenancy (Workspaces)
|
||||
## Workflow: Tenants, Projects & Search
|
||||
|
||||
The MCP is multi-tenant. Each Cursor workspace automatically gets its own tenant (identified by a SHA-256 hash of the workspace path). All elements, projects, and snapshots are scoped to the active tenant.
|
||||
|
||||
### Automatic Tenant Detection
|
||||
|
||||
On MCP startup, the server:
|
||||
1. Creates a tenant from `process.cwd()` (initial guess)
|
||||
2. After connecting, calls `server.listRoots()` to get the real workspace path from Cursor
|
||||
3. If different, re-creates/switches to the correct tenant and notifies the canvas
|
||||
|
||||
This means globally-configured MCPs (`~/.cursor/mcp.json`) correctly detect the per-window workspace — no manual setup needed.
|
||||
|
||||
### Tenant Operations
|
||||
Multi-tenant: each Cursor workspace auto-gets its own tenant (SHA-256 hash of workspace path). Globally-configured MCPs detect per-window workspace automatically.
|
||||
|
||||
| Task | Tool | Notes |
|
||||
|------|------|-------|
|
||||
| See all workspaces | `list_tenants` | Returns id, name, workspace_path, created_at |
|
||||
| Switch workspace | `switch_tenant` with `tenantId` | Canvas reloads that tenant's elements via WebSocket |
|
||||
| Check current tenant | (from describe_scene or frontend header) | Shows "Workspace: [name]" in canvas |
|
||||
|
||||
### Multiple Cursor Instances
|
||||
|
||||
Each instance sends its own `X-Tenant-Id` header on every HTTP/MCP request. SQLite uses `busy_timeout` for concurrent write safety. No state conflicts between windows.
|
||||
|
||||
## Workflow: Projects (Within a Tenant)
|
||||
|
||||
Projects group diagrams within a tenant. Each tenant has a "Default Project" created automatically. Use projects to organize different diagram sets (e.g., "Architecture", "User Flows", "Sprint Planning").
|
||||
|
||||
| Task | Tool | Notes |
|
||||
|------|------|-------|
|
||||
| List projects | `list_projects` | Shows all projects in active tenant |
|
||||
| Switch project | `switch_project` with `projectId` | Elements change to that project's set |
|
||||
| Create new project | `switch_project` with `createName` | Creates and switches in one call |
|
||||
|
||||
## Workflow: Search & History
|
||||
|
||||
### Full-Text Search
|
||||
|
||||
`search_elements` with `query` — searches across element labels and text content in the active project. Useful for finding specific elements in large diagrams.
|
||||
|
||||
### Element Version History
|
||||
|
||||
`element_history` — view create/update/delete operations for:
|
||||
- A specific element: pass `elementId`
|
||||
- Entire active project: omit `elementId`
|
||||
- Control result count with `limit` (default 50)
|
||||
|
||||
Use history to debug unexpected changes or audit what was modified.
|
||||
| List workspaces | `list_tenants` | Returns id, name, workspace_path |
|
||||
| Switch workspace | `switch_tenant` with `tenantId` | Canvas reloads tenant's elements |
|
||||
| List projects | `list_projects` | Projects group diagrams within a tenant |
|
||||
| Switch/create project | `switch_project` | Pass `projectId` or `createName` |
|
||||
| Full-text search | `search_elements` with `query` | Searches labels and text content |
|
||||
| Version history | `element_history` | Pass `elementId` or omit for project-wide |
|
||||
|
||||
## Workflow: Refine An Existing Diagram
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"_comment": "Excalidraw MCP user preferences. Copy to preferences.json to activate.",
|
||||
"_fontReference": {
|
||||
"1": "Excalifont (hand-drawn)",
|
||||
"2": "Helvetica (sans-serif)",
|
||||
"3": "Cascadia (monospace)",
|
||||
"4": "Comic Shanns",
|
||||
"5": "Liberation Sans",
|
||||
"6": "Nunito",
|
||||
"7": "Lilita One"
|
||||
},
|
||||
"defaults": {
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"roughness": 0,
|
||||
"strokeWidth": 2
|
||||
}
|
||||
}
|
||||
@@ -94,16 +94,15 @@
|
||||
|---------|----------------|-----------------|
|
||||
| Shape labels | `"text": "My Label"` (auto-converts) | `"label": {"text": "My Label"}` |
|
||||
| Arrow binding | `"startElementId": "id"` / `"endElementId": "id"` | `"start": {"id": "id"}` / `"end": {"id": "id"}` |
|
||||
| `fontFamily` | String `"1"` or omit | String `"1"` or omit (never a number) |
|
||||
| `fontFamily` | Number or string — use value from user preferences (see Step 1 in SKILL.md) | String — use value from user preferences |
|
||||
| Tenant scoping | Auto (uses active tenant) | Include `X-Tenant-Id` header on every request |
|
||||
|
||||
### Element Creation Best Practices
|
||||
|
||||
- **Always set `roughness: 0`** for clean, professional diagrams (default is hand-drawn).
|
||||
- **Always set `strokeWidth: 2`** on arrows for visibility.
|
||||
- **Always apply user preferences** — load from Step 1 in SKILL.md and apply `fontFamily`, `roughness`, `fontSize`, `strokeWidth` to every element.
|
||||
- **Create shapes first, arrows second** (two separate `batch_create_elements` calls).
|
||||
- **Assign custom `id`** to every shape so arrows can reference it.
|
||||
- **Size shapes for their text** — Virgil font is ~30% wider than standard. Use sizing formulas from SKILL.md.
|
||||
- **Size shapes for their text** — use sizing formulas from SKILL.md.
|
||||
- `points` accepts both `[[x,y]]` tuples and `[{x,y}]` objects — normalized automatically.
|
||||
- **Curved arrows**: Use `"roundness": {"type": 2}` with 3+ points. **Elbowed arrows**: Use `"elbowed": true`.
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# Geometric Thinking — Detailed Technique Catalogs
|
||||
|
||||
## Technique Catalog: Illustrative Diagrams
|
||||
|
||||
| Visual Element | Primitive Composition | Key Parameters |
|
||||
|---------------|----------------------|----------------|
|
||||
| **Tiled roof** | Grid of diamonds, rows reduce by 2, interleave offset rows | tile_size, row_count, center_x |
|
||||
| **Brick wall** | Grid of rectangles, alternating rows offset by half-width | brick_w, brick_h, mortar_gap |
|
||||
| **Cloud** | 5-7 overlapping ellipses of varying sizes | center, radii, overlap% |
|
||||
| **Tree** | Rectangle trunk + 3-4 overlapping ellipses (canopy) | trunk_w, canopy_r |
|
||||
| **Fence** | Repeated thin rectangles with pointed-top triangles | post_spacing, post_h |
|
||||
| **Road/path** | Two parallel lines + dashed center line | width, dash_pattern |
|
||||
| **Water/waves** | Repeating sine-curve approximated by overlapping ellipses | amplitude, wavelength |
|
||||
| **Sun rays** | Central ellipse + rotated lines at equal angles | ray_count, ray_length |
|
||||
| **Smoke/steam** | 3+ ellipses of increasing size, ascending and drifting | size_step, drift_x |
|
||||
| **Window (classic)** | Rectangle + 2 thin rectangle cross-bars (H and V) | win_w, win_h, bar_thickness=3 |
|
||||
| **Stairs** | Stacked rectangles, each offset right and down | step_w, step_h, count |
|
||||
|
||||
## Technique Catalog: Flow Diagrams (Sugiyama-Inspired Layout)
|
||||
|
||||
For flowcharts and directed graphs, use the Sugiyama hierarchical layout algorithm:
|
||||
|
||||
**Step 1 — Layer Assignment:**
|
||||
Assign each node to a horizontal layer. Entry nodes at top (layer 0), each subsequent step increases layer number. Nodes in the same layer share the same Y coordinate.
|
||||
|
||||
```
|
||||
layer_y[n] = start_y + n * (node_height + vertical_gap)
|
||||
vertical_gap: 120px minimum (for visible arrows)
|
||||
```
|
||||
|
||||
**Step 2 — Ordering Within Layers:**
|
||||
Position nodes within each layer to minimize edge crossings. Place each node at the average X of its connected neighbors in the layer above.
|
||||
|
||||
```
|
||||
node_x = average(connected_parent_x_positions)
|
||||
If two nodes overlap: spread by node_width + horizontal_gap
|
||||
```
|
||||
|
||||
**Step 3 — Coordinate Assignment:**
|
||||
Center nodes around the diagram midpoint. Distribute evenly within each layer.
|
||||
|
||||
```
|
||||
layer_width = count * node_width + (count - 1) * horizontal_gap
|
||||
first_x = center_x - layer_width / 2
|
||||
node_x[i] = first_x + i * (node_width + horizontal_gap)
|
||||
```
|
||||
|
||||
**Step 4 — Edge Routing:**
|
||||
Create arrows after all shapes. Use `startElementId`/`endElementId` for binding.
|
||||
- Same-layer connections: horizontal arrows
|
||||
- Cross-layer connections: vertical arrows
|
||||
- Multi-layer spans: consider adding routing waypoints
|
||||
|
||||
**Decision branches:**
|
||||
```
|
||||
YES path → horizontal right to answer box (same layer)
|
||||
NO path → vertical down to next decision (next layer)
|
||||
```
|
||||
|
||||
## Technique Catalog: Architecture Diagrams (Zone-Grid Layout)
|
||||
|
||||
**Zone-based layout** for microservices, infrastructure, and system architecture:
|
||||
|
||||
**Step 1 — Define zones as large translucent rectangles:**
|
||||
```
|
||||
zone_width = max(services_count * (service_w + gap) + padding * 2, 400)
|
||||
zone_height = rows * (service_h + gap) + padding * 2 + title_height
|
||||
zone_bg = "#e9ecef", opacity = 30
|
||||
```
|
||||
|
||||
**Step 2 — Grid-pack services within zones:**
|
||||
```
|
||||
col = service_index % cols_per_row
|
||||
row = service_index / cols_per_row (integer division)
|
||||
service_x = zone_x + padding + col * (service_w + gap)
|
||||
service_y = zone_y + title_height + padding + row * (service_h + gap)
|
||||
```
|
||||
|
||||
**Step 3 — Connect zones with arrows:**
|
||||
- Solid arrows for synchronous calls
|
||||
- Dashed arrows (`strokeStyle: "dashed"`) for async/event-driven
|
||||
- Label arrows with protocol/method (REST, gRPC, Kafka, etc.)
|
||||
|
||||
**Step 4 — Layer zones top-to-bottom by dependency depth:**
|
||||
```
|
||||
Client layer: y = 0
|
||||
API Gateway: y = zone_height + 80
|
||||
Services: y = 2 * (zone_height + 80)
|
||||
Data stores: y = 3 * (zone_height + 80)
|
||||
```
|
||||
|
||||
## Technique Catalog: Isometric / 2.5D Diagrams
|
||||
|
||||
For infrastructure and deployment diagrams with depth:
|
||||
|
||||
```
|
||||
Isometric grid formulas:
|
||||
screen_x = origin_x + (col - row) * cell_width / 2
|
||||
screen_y = origin_y + (col + row) * cell_height / 2
|
||||
|
||||
For a server rack at grid position (2, 3):
|
||||
screen_x = 500 + (2 - 3) * 60 / 2 = 470
|
||||
screen_y = 100 + (2 + 3) * 30 / 2 = 175
|
||||
```
|
||||
|
||||
Use diamonds for floor tiles, parallelogram-approximated rectangles for side faces. Stack elements vertically (subtract from y) to show height.
|
||||
|
||||
## Technique Catalog: Repeating Patterns
|
||||
|
||||
For any repeating visual pattern (fences, grids, timelines, Gantt bars):
|
||||
|
||||
```
|
||||
Generic repeat formula:
|
||||
element_x[i] = start_x + i * (element_width + gap)
|
||||
element_y[i] = start_y (same for horizontal repeat)
|
||||
|
||||
With alternating offset (brick pattern):
|
||||
offset = (row % 2) * (element_width / 2 + gap / 2)
|
||||
element_x[i] = start_x + offset + i * (element_width + gap)
|
||||
```
|
||||
|
||||
## Anti-Patterns for Geometric Composition
|
||||
|
||||
| Mistake | Why It Fails | Do This Instead |
|
||||
|---------|-------------|-----------------|
|
||||
| Single large primitive for complex shape | Looks flat, unrealistic | Compose from many small primitives |
|
||||
| Same-row diamonds without interleaving | Visible triangular gaps | Add offset rows at midpoint Y |
|
||||
| Guessing coordinates | Misaligned elements, uneven spacing | Use parametric formulas |
|
||||
| Same tile size for all containers | Looks wrong at different scales | Scale tile size to container width |
|
||||
| Too few primitives | Sparse, gappy appearance | Use enough tiles to achieve ≥80% coverage |
|
||||
| Forgetting z-order | Background elements cover foreground | Create back-to-front: background first, details last |
|
||||
+79
-7
@@ -65,6 +65,47 @@ const CANVAS_PORT = process.env.CANVAS_PORT || process.env.PORT || '3000';
|
||||
const EXPRESS_SERVER_URL = process.env.EXPRESS_SERVER_URL || `http://localhost:${CANVAS_PORT}`;
|
||||
const ENABLE_CANVAS_SYNC = true;
|
||||
|
||||
// User preferences for element defaults (font, roughness, etc.)
|
||||
// Resolution: folder-level .claude/excalidraw-preferences.json > global ~/.claude/skills/excalidraw-skill/preferences.json > hardcoded
|
||||
interface ExcalidrawPreferences {
|
||||
fontFamily: number;
|
||||
fontSize: number;
|
||||
roughness: number;
|
||||
strokeWidth: number;
|
||||
}
|
||||
|
||||
const HARDCODED_DEFAULTS: ExcalidrawPreferences = {
|
||||
fontFamily: 1,
|
||||
fontSize: 20,
|
||||
roughness: 0,
|
||||
strokeWidth: 2,
|
||||
};
|
||||
|
||||
function loadPreferences(): ExcalidrawPreferences {
|
||||
const locations = [
|
||||
path.join(process.cwd(), '.claude', 'excalidraw-preferences.json'),
|
||||
path.join(process.env.HOME || '~', '.claude', 'skills', 'excalidraw-skill', 'preferences.json'),
|
||||
];
|
||||
|
||||
for (const loc of locations) {
|
||||
try {
|
||||
if (fs.existsSync(loc)) {
|
||||
const raw = JSON.parse(fs.readFileSync(loc, 'utf-8'));
|
||||
if (raw?.defaults) {
|
||||
logger.info(`Loaded user preferences from ${loc}`);
|
||||
return { ...HARDCODED_DEFAULTS, ...raw.defaults };
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn(`Failed to read preferences from ${loc}: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
return HARDCODED_DEFAULTS;
|
||||
}
|
||||
|
||||
const USER_PREFS = loadPreferences();
|
||||
|
||||
// One-time tokens for clear_canvas confirmation (token → expiry timestamp)
|
||||
const pendingClearTokens = new Map<string, { expiresAt: number; elementCount: number }>();
|
||||
const CLEAR_TOKEN_TTL_MS = 120_000; // 2 minutes
|
||||
@@ -2199,8 +2240,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
if (el.type === 'text') {
|
||||
base.text = text ?? '';
|
||||
base.originalText = text ?? '';
|
||||
base.fontSize = rest.fontSize ?? 20;
|
||||
base.fontFamily = rest.fontFamily ?? 1;
|
||||
base.fontSize = rest.fontSize ?? USER_PREFS.fontSize;
|
||||
base.fontFamily = rest.fontFamily ?? USER_PREFS.fontFamily;
|
||||
base.textAlign = rest.textAlign ?? 'center';
|
||||
base.verticalAlign = rest.verticalAlign ?? 'middle';
|
||||
base.autoResize = rest.autoResize ?? true;
|
||||
@@ -2294,8 +2335,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
locked: false,
|
||||
text: labelText,
|
||||
originalText: labelText,
|
||||
fontSize: isArrow ? 14 : (rest.fontSize ?? 16),
|
||||
fontFamily: rest.fontFamily ?? 1,
|
||||
fontSize: isArrow ? 14 : (rest.fontSize ?? USER_PREFS.fontSize),
|
||||
fontFamily: rest.fontFamily ?? USER_PREFS.fontFamily,
|
||||
textAlign: 'center',
|
||||
verticalAlign: 'middle',
|
||||
autoResize: true,
|
||||
@@ -2708,13 +2749,44 @@ if (process.env.DEBUG === 'true') {
|
||||
logger.debug('Debug mode enabled');
|
||||
}
|
||||
|
||||
// Start the server if this file is run directly
|
||||
if (fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
if (process.argv[2] === 'setup') {
|
||||
function isMainModule(): boolean {
|
||||
try {
|
||||
const ourPath = fs.realpathSync(fileURLToPath(import.meta.url));
|
||||
const argPath = process.argv[1];
|
||||
if (!argPath) return false;
|
||||
return ourPath === fs.realpathSync(path.resolve(argPath));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMainModule()) {
|
||||
const arg = process.argv[2];
|
||||
|
||||
if (arg === 'setup') {
|
||||
import('./setup.js').then(m => m.runSetup()).catch(error => {
|
||||
process.stderr.write(`Setup failed: ${(error as Error).message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
} else if (arg === 'update') {
|
||||
import('./setup.js').then(m => m.runUpdate()).catch(error => {
|
||||
process.stderr.write(`Update failed: ${(error as Error).message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
} else if (arg === '--help' || arg === '-h' || arg === '--version' || arg === '-v') {
|
||||
const pkgPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
||||
if (arg === '--help' || arg === '-h') {
|
||||
process.stdout.write(`${pkg.name} v${pkg.version}\n\nUsage:\n mcp-excalidraw-local Start MCP server (stdio transport)\n mcp-excalidraw-local setup Interactive setup wizard\n mcp-excalidraw-local update Update agent skills and MCP config\n mcp-excalidraw-local --help Show this help\n mcp-excalidraw-local --version Show version\n`);
|
||||
} else {
|
||||
process.stdout.write(`${pkg.version}\n`);
|
||||
}
|
||||
} catch {
|
||||
process.stderr.write('Could not read package.json\n');
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(0);
|
||||
} else {
|
||||
runServer().catch(error => {
|
||||
logger.error('Failed to start server:', error);
|
||||
|
||||
+13
-1
@@ -2,6 +2,7 @@ import express, { type Application, Request, Response, NextFunction } from 'expr
|
||||
import cors from 'cors';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { createServer } from 'http';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import dotenv from 'dotenv';
|
||||
@@ -1272,7 +1273,18 @@ export function stopCanvasServer(): Promise<void> {
|
||||
}
|
||||
|
||||
// Direct execution: `node dist/server.js` still works standalone
|
||||
if (fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
function isServerMainModule(): boolean {
|
||||
try {
|
||||
const ourPath = fs.realpathSync(fileURLToPath(import.meta.url));
|
||||
const argPath = process.argv[1];
|
||||
if (!argPath) return false;
|
||||
return ourPath === fs.realpathSync(path.resolve(argPath));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isServerMainModule()) {
|
||||
startCanvasServer().catch((err) => {
|
||||
logger.error('Failed to start canvas server:', err);
|
||||
process.exit(1);
|
||||
|
||||
+438
-11
@@ -55,7 +55,13 @@ interface AgentDef {
|
||||
skillBasePaths: { global: string; local: string };
|
||||
mcpConfigType: 'json-file' | 'cli-command';
|
||||
mcpConfigPath?: string;
|
||||
mcpCliRemove?: string;
|
||||
mcpCliCommand?: string;
|
||||
instructionConfig?: {
|
||||
global: string;
|
||||
local: string;
|
||||
format: 'claude-md' | 'cursor-mdc';
|
||||
};
|
||||
}
|
||||
|
||||
function getAgents(): AgentDef[] {
|
||||
@@ -70,6 +76,11 @@ function getAgents(): AgentDef[] {
|
||||
},
|
||||
mcpConfigType: 'json-file',
|
||||
mcpConfigPath: path.join(home, '.cursor', 'mcp.json'),
|
||||
instructionConfig: {
|
||||
global: path.join(home, '.cursor', 'rules', 'excalidraw.mdc'),
|
||||
local: path.join(process.cwd(), '.cursor', 'rules', 'excalidraw.mdc'),
|
||||
format: 'cursor-mdc',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Claude Code',
|
||||
@@ -79,7 +90,13 @@ function getAgents(): AgentDef[] {
|
||||
local: path.join(process.cwd(), '.claude', 'skills'),
|
||||
},
|
||||
mcpConfigType: 'cli-command',
|
||||
mcpCliCommand: 'claude mcp add excalidraw-canvas --scope user -e CANVAS_PORT=3000 -- npx -y @sanjibdevnath/mcp-excalidraw-local',
|
||||
mcpCliRemove: 'claude mcp remove excalidraw-canvas --scope user',
|
||||
mcpCliCommand: 'claude mcp add excalidraw-canvas --scope user -e CANVAS_PORT=3000 -- npx -y @sanjibdevnath/mcp-excalidraw-local@latest',
|
||||
instructionConfig: {
|
||||
global: path.join(home, '.claude', 'CLAUDE.md'),
|
||||
local: path.join(process.cwd(), 'CLAUDE.md'),
|
||||
format: 'claude-md',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Codex CLI',
|
||||
@@ -101,16 +118,16 @@ function detectInstalledAgents(): AgentDef[] {
|
||||
// ── Phase 1: Environment Check ──────────────────────────────
|
||||
|
||||
async function phaseEnvironment(rl: readline.Interface): Promise<boolean> {
|
||||
heading('1/3', 'Environment');
|
||||
heading('1/4', 'Environment');
|
||||
let allOk = true;
|
||||
|
||||
// Node.js version
|
||||
const nodeVersion = process.version;
|
||||
const major = parseInt(nodeVersion.slice(1).split('.')[0] ?? '0', 10);
|
||||
if (major >= 18) {
|
||||
if (major >= 20) {
|
||||
ok(`Node.js ${nodeVersion} ${'.' .repeat(Math.max(0, 24 - nodeVersion.length))} OK`);
|
||||
} else {
|
||||
fail(`Node.js ${nodeVersion} — requires >= 18.0.0`);
|
||||
fail(`Node.js ${nodeVersion} — requires >= 20.0.0`);
|
||||
allOk = false;
|
||||
}
|
||||
|
||||
@@ -133,7 +150,6 @@ async function phaseEnvironment(rl: readline.Interface): Promise<boolean> {
|
||||
ok('Rebuild successful');
|
||||
} catch {
|
||||
fail('Rebuild failed. Try manually:');
|
||||
info(` cd ${path.resolve(__dirname, '..')}`);
|
||||
info(' npm rebuild better-sqlite3');
|
||||
info('');
|
||||
info('Prerequisites:');
|
||||
@@ -142,7 +158,8 @@ async function phaseEnvironment(rl: readline.Interface): Promise<boolean> {
|
||||
} else if (process.platform === 'linux') {
|
||||
info(' sudo apt install build-essential python3');
|
||||
} else {
|
||||
info(' npm install --global windows-build-tools');
|
||||
info(' Install "Desktop development with C++" from Visual Studio Build Tools');
|
||||
info(' https://visualstudio.microsoft.com/visual-cpp-build-tools/');
|
||||
}
|
||||
allOk = false;
|
||||
}
|
||||
@@ -165,10 +182,103 @@ async function phaseEnvironment(rl: readline.Interface): Promise<boolean> {
|
||||
return allOk;
|
||||
}
|
||||
|
||||
// ── Preference Setup ─────────────────────────────────────────
|
||||
|
||||
const FONT_OPTIONS: { value: number; label: string }[] = [
|
||||
{ value: 1, label: 'Excalifont (hand-drawn)' },
|
||||
{ value: 2, label: 'Helvetica (sans-serif)' },
|
||||
{ value: 3, label: 'Cascadia (monospace)' },
|
||||
{ value: 4, label: 'Comic Shanns' },
|
||||
{ value: 6, label: 'Nunito' },
|
||||
{ value: 7, label: 'Lilita One' },
|
||||
];
|
||||
|
||||
const ROUGHNESS_OPTIONS: { value: number; label: string }[] = [
|
||||
{ value: 0, label: 'Clean / professional' },
|
||||
{ value: 1, label: 'Hand-drawn sketch' },
|
||||
{ value: 2, label: 'Very rough' },
|
||||
];
|
||||
|
||||
function getGlobalPreferencesPath(): string {
|
||||
return path.join(os.homedir(), '.claude', 'skills', 'excalidraw-skill', 'preferences.json');
|
||||
}
|
||||
|
||||
function globalPreferencesExist(): boolean {
|
||||
return fs.existsSync(getGlobalPreferencesPath());
|
||||
}
|
||||
|
||||
function writePreferencesFile(filePath: string, prefs: { fontFamily: number; fontSize: number; roughness: number; strokeWidth: number }): void {
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
const content = {
|
||||
defaults: prefs,
|
||||
};
|
||||
fs.writeFileSync(filePath, JSON.stringify(content, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
async function phasePreferences(rl: readline.Interface, phaseLabel: string): Promise<void> {
|
||||
heading(phaseLabel, 'Diagram Preferences');
|
||||
|
||||
const prefsPath = getGlobalPreferencesPath();
|
||||
|
||||
if (globalPreferencesExist()) {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(prefsPath, 'utf-8'));
|
||||
const d = raw?.defaults;
|
||||
if (d) {
|
||||
const fontLabel = FONT_OPTIONS.find(f => f.value === d.fontFamily)?.label ?? `font ${d.fontFamily}`;
|
||||
const roughLabel = ROUGHNESS_OPTIONS.find(r => r.value === d.roughness)?.label ?? `roughness ${d.roughness}`;
|
||||
ok(`Current: ${fontLabel}, ${roughLabel}, fontSize ${d.fontSize}, strokeWidth ${d.strokeWidth}`);
|
||||
const change = await confirm(rl, 'Change preferences?', false);
|
||||
if (!change) return;
|
||||
}
|
||||
} catch {
|
||||
warn(`Could not read ${prefsPath}, will reconfigure.`);
|
||||
}
|
||||
}
|
||||
|
||||
info('These defaults apply to every diagram (font, style, etc.).');
|
||||
info('');
|
||||
|
||||
// Font
|
||||
process.stdout.write('\n Font family:\n');
|
||||
FONT_OPTIONS.forEach((f, i) => {
|
||||
const marker = f.value === 1 ? ' (default)' : '';
|
||||
process.stdout.write(` ${CYAN}[${i + 1}]${RESET} ${f.label}${marker}\n`);
|
||||
});
|
||||
const fontAnswer = (await ask(rl, 'Choose [1]: ')).trim();
|
||||
const fontIdx = fontAnswer === '' ? 0 : parseInt(fontAnswer, 10) - 1;
|
||||
const fontFamily = (fontIdx >= 0 && fontIdx < FONT_OPTIONS.length) ? FONT_OPTIONS[fontIdx]!.value : 1;
|
||||
|
||||
// Roughness
|
||||
process.stdout.write('\n Diagram style:\n');
|
||||
ROUGHNESS_OPTIONS.forEach((r, i) => {
|
||||
const marker = r.value === 0 ? ' (default)' : '';
|
||||
process.stdout.write(` ${CYAN}[${i + 1}]${RESET} ${r.label}${marker}\n`);
|
||||
});
|
||||
const roughAnswer = (await ask(rl, 'Choose [1]: ')).trim();
|
||||
const roughIdx = roughAnswer === '' ? 0 : parseInt(roughAnswer, 10) - 1;
|
||||
const roughness = (roughIdx >= 0 && roughIdx < ROUGHNESS_OPTIONS.length) ? ROUGHNESS_OPTIONS[roughIdx]!.value : 0;
|
||||
|
||||
const prefs = { fontFamily, fontSize: 20, roughness, strokeWidth: 2 };
|
||||
|
||||
try {
|
||||
writePreferencesFile(prefsPath, prefs);
|
||||
const fontLabel = FONT_OPTIONS.find(f => f.value === fontFamily)?.label ?? `${fontFamily}`;
|
||||
const roughLabel = ROUGHNESS_OPTIONS.find(r => r.value === roughness)?.label ?? `${roughness}`;
|
||||
ok(`Saved: ${fontLabel}, ${roughLabel}`);
|
||||
ok(`File: ${prefsPath}`);
|
||||
} catch (err) {
|
||||
fail(`Failed to save preferences: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 2: Skill Installation ─────────────────────────────
|
||||
|
||||
async function phaseSkillInstall(rl: readline.Interface): Promise<void> {
|
||||
heading('2/3', 'Agent Skill');
|
||||
heading('2/4', 'Agent Skill');
|
||||
|
||||
const wantSkill = await confirm(rl, 'Install the Excalidraw agent skill?');
|
||||
if (!wantSkill) {
|
||||
@@ -224,6 +334,17 @@ async function phaseSkillInstall(rl: readline.Interface): Promise<void> {
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
copyDirSync(skillSource, destDir);
|
||||
ok(`Installed to ${destDir}`);
|
||||
|
||||
// Write instruction directive to ensure skill auto-triggers
|
||||
if (agent.instructionConfig) {
|
||||
const instrPath = agent.instructionConfig[scope];
|
||||
try {
|
||||
writeInstructionDirective(instrPath, agent.instructionConfig.format);
|
||||
ok(`Skill directive added to ${instrPath}`);
|
||||
} catch (instrErr) {
|
||||
warn(`Could not write directive to ${instrPath}: ${(instrErr as Error).message}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
fail(`Failed to install to ${destDir}: ${(err as Error).message}`);
|
||||
}
|
||||
@@ -243,10 +364,83 @@ function copyDirSync(src: string, dest: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Instruction Directives ──────────────────────────────────
|
||||
|
||||
const DIRECTIVE_MARKER_START = '<!-- excalidraw-mcp:start -->';
|
||||
const DIRECTIVE_MARKER_END = '<!-- excalidraw-mcp:end -->';
|
||||
|
||||
const EXCALIDRAW_DIRECTIVE_BODY =
|
||||
'When Excalidraw MCP tools are available (batch_create_elements, create_element, ' +
|
||||
'create_from_mermaid, etc.), ALWAYS invoke the `/excalidraw-skill` skill FIRST ' +
|
||||
'before calling any Excalidraw tool. The skill contains essential sizing formulas, ' +
|
||||
'batch ordering rules (shapes first, arrows second), and a mandatory ' +
|
||||
'write-check-review verification cycle. Using Excalidraw tools without consulting ' +
|
||||
'this skill produces broken diagrams — invisible arrows, truncated text, ' +
|
||||
'overlapping elements.';
|
||||
|
||||
function buildClaudeMdSection(): string {
|
||||
return [
|
||||
DIRECTIVE_MARKER_START,
|
||||
'## Excalidraw Canvas — Skill Directive',
|
||||
'',
|
||||
EXCALIDRAW_DIRECTIVE_BODY,
|
||||
DIRECTIVE_MARKER_END,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function buildCursorMdc(): string {
|
||||
return [
|
||||
'---',
|
||||
'description: Always consult excalidraw-skill before using Excalidraw MCP tools',
|
||||
'globs:',
|
||||
'alwaysApply: true',
|
||||
'---',
|
||||
'',
|
||||
'## Excalidraw Canvas — Skill Directive',
|
||||
'',
|
||||
EXCALIDRAW_DIRECTIVE_BODY,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function writeInstructionDirective(filePath: string, format: 'claude-md' | 'cursor-mdc'): void {
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
if (format === 'cursor-mdc') {
|
||||
// Cursor .mdc files are standalone — write/overwrite the whole file
|
||||
fs.writeFileSync(filePath, buildCursorMdc(), 'utf-8');
|
||||
return;
|
||||
}
|
||||
|
||||
// For claude-md: append or replace the marked section
|
||||
let content = '';
|
||||
if (fs.existsSync(filePath)) {
|
||||
content = fs.readFileSync(filePath, 'utf-8');
|
||||
}
|
||||
|
||||
const section = buildClaudeMdSection();
|
||||
const startIdx = content.indexOf(DIRECTIVE_MARKER_START);
|
||||
const endIdx = content.indexOf(DIRECTIVE_MARKER_END);
|
||||
|
||||
if (startIdx !== -1 && endIdx !== -1) {
|
||||
// Replace existing section
|
||||
content = content.slice(0, startIdx) + section + content.slice(endIdx + DIRECTIVE_MARKER_END.length);
|
||||
} else {
|
||||
// Append with spacing
|
||||
const trimmed = content.trimEnd();
|
||||
content = trimmed + (trimmed ? '\n\n' : '') + section + '\n';
|
||||
}
|
||||
|
||||
fs.writeFileSync(filePath, content, 'utf-8');
|
||||
}
|
||||
|
||||
// ── Phase 3: MCP Configuration ──────────────────────────────
|
||||
|
||||
async function phaseMcpConfig(rl: readline.Interface): Promise<void> {
|
||||
heading('3/3', 'MCP Configuration');
|
||||
heading('4/4', 'MCP Configuration');
|
||||
|
||||
const wantConfig = await confirm(rl, 'Add MCP server to agent configs automatically?');
|
||||
if (!wantConfig) {
|
||||
@@ -285,6 +479,9 @@ async function phaseMcpConfig(rl: readline.Interface): Promise<void> {
|
||||
}
|
||||
|
||||
try {
|
||||
if (agent.mcpCliRemove) {
|
||||
try { execSync(agent.mcpCliRemove, { stdio: 'pipe' }); } catch { /* ignore if not found */ }
|
||||
}
|
||||
execSync(agent.mcpCliCommand, { stdio: 'inherit' });
|
||||
ok(`Registered 'excalidraw-canvas' via ${agent.name} CLI`);
|
||||
} catch (err) {
|
||||
@@ -299,14 +496,18 @@ async function phaseMcpConfig(rl: readline.Interface): Promise<void> {
|
||||
function mergeJsonConfig(configPath: string): void {
|
||||
const mcpEntry = {
|
||||
command: 'npx',
|
||||
args: ['-y', '@sanjibdevnath/mcp-excalidraw-local'],
|
||||
args: ['-y', '@sanjibdevnath/mcp-excalidraw-local@latest'],
|
||||
env: { CANVAS_PORT: '3000' },
|
||||
};
|
||||
|
||||
let existing: any = {};
|
||||
if (fs.existsSync(configPath)) {
|
||||
const raw = fs.readFileSync(configPath, 'utf-8');
|
||||
existing = JSON.parse(raw);
|
||||
try {
|
||||
existing = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new Error(`Failed to parse ${configPath} — fix the JSON syntax and try again.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!existing.mcpServers) {
|
||||
@@ -327,6 +528,23 @@ function mergeJsonConfig(configPath: string): void {
|
||||
fs.writeFileSync(configPath, JSON.stringify(existing, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
function checkJsonConfigStatus(configPath: string): 'up-to-date' | 'needs-update' | 'not-found' {
|
||||
if (!fs.existsSync(configPath)) return 'not-found';
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(configPath, 'utf-8');
|
||||
const config = JSON.parse(raw);
|
||||
const entry = config?.mcpServers?.['excalidraw-canvas'];
|
||||
if (!entry) return 'not-found';
|
||||
|
||||
const args: string[] = entry.args ?? [];
|
||||
const hasLatest = args.some((a: string) => a.includes('@latest'));
|
||||
return hasLatest ? 'up-to-date' : 'needs-update';
|
||||
} catch {
|
||||
return 'not-found';
|
||||
}
|
||||
}
|
||||
|
||||
function printManualConfig(): void {
|
||||
process.stdout.write(`
|
||||
Manual config (JSON):
|
||||
@@ -334,7 +552,7 @@ function printManualConfig(): void {
|
||||
"mcpServers": {
|
||||
"excalidraw-canvas": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@sanjibdevnath/mcp-excalidraw-local"],
|
||||
"args": ["-y", "@sanjibdevnath/mcp-excalidraw-local@latest"],
|
||||
"env": { "CANVAS_PORT": "3000" }
|
||||
}
|
||||
}
|
||||
@@ -342,9 +560,217 @@ function printManualConfig(): void {
|
||||
`);
|
||||
}
|
||||
|
||||
// ── Update ───────────────────────────────────────────────────
|
||||
|
||||
interface SkillInstallation {
|
||||
agent: AgentDef;
|
||||
scope: 'global' | 'local';
|
||||
path: string;
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
function getPackageVersion(): string {
|
||||
try {
|
||||
const pkgPath = path.resolve(__dirname, '..', 'package.json');
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
||||
return pkg.version ?? 'unknown';
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
function findExistingSkillInstalls(): SkillInstallation[] {
|
||||
const agents = getAgents();
|
||||
const installs: SkillInstallation[] = [];
|
||||
for (const agent of agents) {
|
||||
for (const scope of ['global', 'local'] as const) {
|
||||
const skillDir = path.join(agent.skillBasePaths[scope], 'excalidraw-skill');
|
||||
installs.push({
|
||||
agent,
|
||||
scope,
|
||||
path: skillDir,
|
||||
exists: fs.existsSync(path.join(skillDir, 'SKILL.md')),
|
||||
});
|
||||
}
|
||||
}
|
||||
return installs;
|
||||
}
|
||||
|
||||
export async function runUpdate(): Promise<void> {
|
||||
if (!process.stdin.isTTY) {
|
||||
process.stderr.write('Error: Update requires an interactive terminal.\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
const version = getPackageVersion();
|
||||
process.stdout.write(`\n ${BOLD}Excalidraw MCP — Update${RESET} ${DIM}v${version}${RESET}\n`);
|
||||
|
||||
try {
|
||||
// ── Phase 1: Detect existing skill installations ──────────
|
||||
heading('1/3', 'Skill Update');
|
||||
|
||||
const allInstalls = findExistingSkillInstalls();
|
||||
const existing = allInstalls.filter(i => i.exists);
|
||||
const missing = allInstalls.filter(i => !i.exists);
|
||||
const detectedAgents = detectInstalledAgents();
|
||||
|
||||
const skillSource = path.resolve(__dirname, '..', 'skills', 'excalidraw-skill');
|
||||
if (!fs.existsSync(skillSource)) {
|
||||
fail(`Skill source not found at ${skillSource}`);
|
||||
fail('This can happen with corrupted installs. Try: npx @sanjibdevnath/mcp-excalidraw-local@latest setup');
|
||||
rl.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing.length > 0) {
|
||||
process.stdout.write(`\n Found ${CYAN}${existing.length}${RESET} existing skill installation(s):\n`);
|
||||
existing.forEach((inst, i) => {
|
||||
const label = `${inst.agent.name} (${inst.scope})`;
|
||||
process.stdout.write(` ${CYAN}[${i + 1}]${RESET} ${label} — ${DIM}${inst.path}${RESET}\n`);
|
||||
});
|
||||
|
||||
const doUpdate = await confirm(rl, `\n Update all ${existing.length} installation(s) to v${version}?`);
|
||||
if (doUpdate) {
|
||||
let updated = 0;
|
||||
for (const inst of existing) {
|
||||
try {
|
||||
copyDirSync(skillSource, inst.path);
|
||||
ok(`Updated ${inst.agent.name} (${inst.scope}) — ${inst.path}`);
|
||||
updated++;
|
||||
|
||||
// Update instruction directive
|
||||
if (inst.agent.instructionConfig) {
|
||||
const instrPath = inst.agent.instructionConfig[inst.scope];
|
||||
try {
|
||||
writeInstructionDirective(instrPath, inst.agent.instructionConfig.format);
|
||||
ok(`Skill directive updated in ${instrPath}`);
|
||||
} catch (instrErr) {
|
||||
warn(`Could not update directive in ${instrPath}: ${(instrErr as Error).message}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
fail(`Failed to update ${inst.path}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
process.stdout.write(`\n ${GREEN}${updated}/${existing.length}${RESET} skill(s) updated.\n`);
|
||||
} else {
|
||||
info(`${DIM}Skipped skill update.${RESET}`);
|
||||
}
|
||||
} else {
|
||||
info('No existing skill installations found.');
|
||||
}
|
||||
|
||||
// Offer to install for detected agents that don't have the skill
|
||||
const agentsWithoutSkill = detectedAgents.filter(agent =>
|
||||
!existing.some(inst => inst.agent.name === agent.name),
|
||||
);
|
||||
|
||||
if (agentsWithoutSkill.length > 0) {
|
||||
process.stdout.write(`\n Agents without the skill:\n`);
|
||||
agentsWithoutSkill.forEach((a, i) => {
|
||||
process.stdout.write(` ${YELLOW}[${i + 1}]${RESET} ${a.name}\n`);
|
||||
});
|
||||
|
||||
const doInstall = await confirm(rl, 'Install the skill for these agents?');
|
||||
if (doInstall) {
|
||||
for (const agent of agentsWithoutSkill) {
|
||||
const scopeAnswer = await ask(rl, `\n ${agent.name} — scope? [G]lobal / [l]ocal: `);
|
||||
const scope = scopeAnswer.trim().toLowerCase() === 'l' ? 'local' : 'global';
|
||||
const destDir = path.join(agent.skillBasePaths[scope], 'excalidraw-skill');
|
||||
|
||||
try {
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
copyDirSync(skillSource, destDir);
|
||||
ok(`Installed to ${destDir}`);
|
||||
|
||||
// Write instruction directive
|
||||
if (agent.instructionConfig) {
|
||||
const instrPath = agent.instructionConfig[scope];
|
||||
try {
|
||||
writeInstructionDirective(instrPath, agent.instructionConfig.format);
|
||||
ok(`Skill directive added to ${instrPath}`);
|
||||
} catch (instrErr) {
|
||||
warn(`Could not write directive to ${instrPath}: ${(instrErr as Error).message}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
fail(`Failed to install to ${destDir}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 2: Preferences ─────────────────────────────────
|
||||
await phasePreferences(rl, '2/3');
|
||||
|
||||
// ── Phase 3: MCP config check ────────────────────────────
|
||||
heading('3/3', 'MCP Configuration');
|
||||
|
||||
for (const agent of detectedAgents) {
|
||||
if (agent.mcpConfigType === 'json-file' && agent.mcpConfigPath) {
|
||||
const status = checkJsonConfigStatus(agent.mcpConfigPath);
|
||||
|
||||
if (status === 'up-to-date') {
|
||||
ok(`${agent.name} — already uses @latest, auto-updates on restart.`);
|
||||
} else if (status === 'needs-update') {
|
||||
const doIt = await confirm(rl, `${agent.name} — config uses a pinned version. Migrate to @latest?`);
|
||||
if (doIt) {
|
||||
try {
|
||||
mergeJsonConfig(agent.mcpConfigPath);
|
||||
ok(`Migrated to @latest in ${agent.mcpConfigPath}`);
|
||||
} catch (err) {
|
||||
fail(`Failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const doIt = await confirm(rl, `${agent.name} — no MCP config found. Add it?`);
|
||||
if (doIt) {
|
||||
try {
|
||||
mergeJsonConfig(agent.mcpConfigPath);
|
||||
ok(`Added 'excalidraw-canvas' to ${agent.mcpConfigPath}`);
|
||||
} catch (err) {
|
||||
fail(`Failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (agent.mcpConfigType === 'cli-command' && agent.mcpCliCommand) {
|
||||
info(`${agent.name} — config managed via CLI (cannot auto-detect version).`);
|
||||
const doIt = await confirm(rl, `${agent.name} — re-register with @latest?`, false);
|
||||
if (doIt) {
|
||||
try {
|
||||
if (agent.mcpCliRemove) {
|
||||
try { execSync(agent.mcpCliRemove, { stdio: 'pipe' }); } catch { /* ignore if not found */ }
|
||||
}
|
||||
execSync(agent.mcpCliCommand, { stdio: 'inherit' });
|
||||
ok(`Re-registered 'excalidraw-canvas' via ${agent.name} CLI`);
|
||||
} catch (err) {
|
||||
fail(`CLI registration failed: ${(err as Error).message}`);
|
||||
}
|
||||
} else {
|
||||
info(`${DIM}Skipped.${RESET}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(`\n ${GREEN}${BOLD}Update complete!${RESET} Restart your MCP client to pick up changes.\n\n`);
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main ─────────────────────────────────────────────────────
|
||||
|
||||
export async function runSetup(): Promise<void> {
|
||||
if (!process.stdin.isTTY) {
|
||||
process.stderr.write('Error: Setup requires an interactive terminal. Run this command directly in your terminal (not piped or in CI).\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
@@ -355,6 +781,7 @@ export async function runSetup(): Promise<void> {
|
||||
try {
|
||||
await phaseEnvironment(rl);
|
||||
await phaseSkillInstall(rl);
|
||||
await phasePreferences(rl, '3/4');
|
||||
await phaseMcpConfig(rl);
|
||||
|
||||
process.stdout.write(`\n ${GREEN}${BOLD}Done!${RESET} Open ${CYAN}http://localhost:3000${RESET} to verify the canvas.\n\n`);
|
||||
|
||||
Reference in New Issue
Block a user