Compare commits

..
12 Commits
Author SHA1 Message Date
sanjibdevnathlabs-release-bot[bot] 4e410f1205 chore(release): v1.5.1 2026-03-17 13:27:27 +00:00
sanjibdevnathlabsandClaude Opus 4.6 71b2a55231 ♻️ refactor(fonts): extract font families to shared JSON single source of truth
Font family IDs were duplicated across 5 files (types.ts, setup.ts,
index.ts, SKILL.md, preferences.example.json) with inconsistent
mappings — Comic Shanns was 4 in some places but actually 8 in
Excalidraw source. This caused wrong fonts to render on canvas.

Fix: create src/font-families.json as the canonical font data, import
it in types.ts, and derive all other references from it. Static docs
now point to the JSON file instead of duplicating the mapping.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 18:55:11 +05:30
sanjibdevnathlabs-release-bot[bot] 25767838fa chore(release): v1.5.0 2026-03-17 12:12:15 +00:00
sanjibdevnathlabsandClaude Opus 4.6 493a20054b feat(setup): add interactive diagram preferences to setup and update flows
Users are now prompted to choose their preferred font family and roughness
style during both `setup` and `update`. Preferences are saved to
~/.claude/skills/excalidraw-skill/preferences.json, which the MCP server
already reads at startup via loadPreferences(). This ensures third-party
users who install via npx get preferences configured before first use.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 17:39:58 +05:30
sanjibdevnathlabs-release-bot[bot] aa29ddbf13 chore(release): v1.4.0 2026-03-17 09:02:29 +00:00
sanjibdevnathlabsandClaude Opus 4.6 9114e81f02 feat(skill): add user-configurable diagram preferences system
Add a preference system that lets users configure default font, roughness,
fontSize, and strokeWidth — with three scopes (session/folder/global).

Skill layer (Step 1 in SKILL.md):
- Reads .claude/excalidraw-preferences.json (folder) then
  ~/.claude/skills/excalidraw-skill/preferences.json (global)
- If neither exists, prompts user interactively on first use
- Session-only scope keeps preferences in-memory without saving

Server layer (index.ts):
- loadPreferences() reads the same files at startup
- Replaces hardcoded fontFamily ?? 1 with USER_PREFS.fontFamily
- Folder-level preferences override global; user values override both

Also ships preferences.example.json as a template (preferences.json
is gitignored).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 14:27:34 +05:30
sanjibdevnathlabs-release-bot[bot] fac1c7a267 chore(release): v1.3.0 2026-03-13 18:19:16 +00:00
sanjibdevnathlabs 9311561227 feat(skill): add auto-triggering for excalidraw-skill via CLAUDE.md directives
The excalidraw-skill was not being auto-invoked when users prompted
Claude to draw diagrams, despite being installed. Claude would call
Excalidraw MCP tools directly, bypassing the skill's critical sizing
formulas and verification workflow — producing broken diagrams with
invisible arrows, truncated text, and overlapping elements. The root
cause is that Claude Code's skill system is advisory: when direct MCP
tools or built-in Bash instructions are available, Claude skips skill
consultation entirely.

🔧 Skill description rewrite:
- Lead with "MANDATORY prerequisite" to assert priority over raw MCP tools
- Add user-intent trigger keywords (draw, visualize, sketch, diagram)
- Name specific consequences of skipping the skill
- List common diagram types for semantic matching

🏗️ Setup/update auto-directives:
- Write marked CLAUDE.md sections during skill install and update
- Write Cursor .mdc rules with alwaysApply for Cursor users
- Use HTML comment markers for idempotent append-or-replace on updates
- Non-fatal directive writing — skill installs even if directive fails

🎯 Two-layer defense ensures reliable skill triggering: the description
catches semantic matching, while the CLAUDE.md directive provides an
authoritative instruction that Claude cannot deprioritize in favor of
raw tool access.
2026-03-13 23:46:59 +05:30
sanjibdevnathlabs-release-bot[bot] 6db9227b59 chore(release): v1.2.1 2026-03-13 17:26:09 +00:00
sanjibdevnathlabs 6791a4171f 🐛 fix(cli): resolve Claude Code re-registration failure and add @latest auto-update
The `claude mcp add` command fails with "already exists" when the server
is already registered. Additionally, configs written by setup/update used
a bare package name without @latest, causing npx to serve stale cached
versions indefinitely.

🔧 CLI registration:
- Remove existing entry before re-adding for Claude Code (both setup and update flows)
- Silently ignore removal errors when entry doesn't exist yet

 Auto-update via @latest:
- Write @latest suffix in JSON configs, CLI commands, and manual instructions
- Smart detection in update flow: skip if config already has @latest,
  offer migration if pinned, offer creation if missing

🎯 Users who run setup or update now get configs that always fetch the
newest version on MCP client restart, eliminating the stale-cache problem
without requiring manual npm cache clearing.
2026-03-13 22:53:39 +05:30
sanjibdevnathlabs-release-bot[bot] 956e33398c chore(release): v1.2.0 2026-03-13 17:09:58 +00:00
Sanjib Devnath 86abe92fa2 feat(cli): add interactive update command for skill and config updates (#10)
Existing users who update the npm package often forget to update the agent
skill files, leaving their AI agent working with stale workflow guidance
(sizing rules, color palettes, anti-patterns). The new `update` subcommand
solves this by detecting and updating all skill installations automatically.

🔧 Interactive update wizard:
- Scan all agent directories (Cursor, Claude Code, Codex CLI) across global
  and local scopes for existing excalidraw-skill installations
- Batch-update found installations with a single Y/n confirmation
- Offer to install the skill for detected agents that don't have it yet
- Optionally re-apply MCP config (defaults to no for non-breaking updates)

📝 Documentation:
- Rewrite README "Updating" section to lead with the interactive command
- Add collapsible example session showing the update flow
- Retain manual update methods as fallback for each installation path

🎯 Ensures skill files stay in sync with MCP tools across releases,
preventing the subtle drift where new tool capabilities exist but the
agent's workflow guidance doesn't reference them.
2026-03-13 22:37:58 +05:30
11 changed files with 728 additions and 40 deletions
+3
View File
@@ -15,6 +15,9 @@ public/dist/
.cursor/
.claude/
# User preferences (only the example ships)
skills/excalidraw-skill/preferences.json
# Development artifacts
*.excalidraw
+114
View File
@@ -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)
@@ -280,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.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@sanjibdevnath/mcp-excalidraw-local",
"version": "1.1.5",
"version": "1.5.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@sanjibdevnath/mcp-excalidraw-local",
"version": "1.1.5",
"version": "1.5.1",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@sanjibdevnath/mcp-excalidraw-local",
"version": "1.1.5",
"version": "1.5.1",
"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",
@@ -19,6 +19,7 @@
"production": "npm run build && npm run canvas",
"prepublishOnly": "npm run build",
"setup": "node dist/index.js setup",
"update": "node dist/index.js update",
"type-check": "npx tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
+74 -1
View File
@@ -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: 5, 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? _(IDs from `src/font-families.json`)_
- Excalifont (hand-drawn) = 5
- Helvetica (sans-serif) = 2
- Cascadia (monospace) = 3
- Comic Shanns = 8
- 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.
@@ -0,0 +1,10 @@
{
"_comment": "Excalidraw MCP user preferences. Copy to preferences.json to activate.",
"_fontReference": "See src/font-families.json for canonical font ID → name mapping.",
"defaults": {
"fontFamily": 5,
"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`.
+13
View File
@@ -0,0 +1,13 @@
{
"fonts": [
{ "id": 5, "name": "Excalifont", "label": "Excalifont (hand-drawn)", "aliases": ["excalifont", "hand-drawn"] },
{ "id": 2, "name": "Helvetica", "label": "Helvetica (sans-serif)", "aliases": ["helvetica", "arial", "sans-serif"] },
{ "id": 3, "name": "Cascadia", "label": "Cascadia (monospace)", "aliases": ["cascadia", "monospace", "courier"] },
{ "id": 8, "name": "Comic Shanns", "label": "Comic Shanns", "aliases": ["comic shanns", "comic sans"] },
{ "id": 6, "name": "Nunito", "label": "Nunito", "aliases": ["nunito"] },
{ "id": 7, "name": "Lilita One", "label": "Lilita One", "aliases": ["lilita one"] },
{ "id": 9, "name": "Liberation Sans", "label": "Liberation Sans", "aliases": ["liberation sans"], "legacy": true },
{ "id": 1, "name": "Virgil", "label": "Virgil (legacy)", "aliases": ["virgil"], "legacy": true }
],
"defaultFontFamily": 5
}
+56 -8
View File
@@ -27,7 +27,9 @@ import {
ExcalidrawElementType,
validateElement,
normalizeFontFamily,
files as globalFiles
files as globalFiles,
DEFAULT_FONT_FAMILY,
FONT_FAMILY_DESCRIPTION,
} from './types.js';
import fetch from 'node-fetch';
import { startCanvasServer, stopCanvasServer } from './server.js';
@@ -65,6 +67,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: DEFAULT_FONT_FAMILY,
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
@@ -418,7 +461,7 @@ const tools: Tool[] = [
opacity: { type: 'number' },
text: { type: 'string' },
fontSize: { type: 'number' },
fontFamily: { type: ['string', 'number'], description: 'Font family: 1=Excalifont (hand-drawn), 2=Helvetica (sans-serif), 3=Cascadia (monospace), 4=Comic Shanns, 5=Liberation Sans, 6=Nunito, 7=Lilita One. Accepts name strings too.' },
fontFamily: { type: ['string', 'number'], description: FONT_FAMILY_DESCRIPTION },
startElementId: { type: 'string', description: 'For arrows: ID of the element to bind the arrow start to. Arrow auto-routes to element edge.' },
endElementId: { type: 'string', description: 'For arrows: ID of the element to bind the arrow end to. Arrow auto-routes to element edge.' },
endArrowhead: { type: 'string', description: 'Arrowhead style at end: arrow, bar, dot, triangle, or null' },
@@ -649,7 +692,7 @@ const tools: Tool[] = [
opacity: { type: 'number' },
text: { type: 'string' },
fontSize: { type: 'number' },
fontFamily: { type: ['string', 'number'], description: 'Font family: 1=Excalifont, 2=Helvetica, 3=Cascadia, 4=Comic Shanns, 5=Liberation Sans, 6=Nunito, 7=Lilita One. Accepts name strings too.' },
fontFamily: { type: ['string', 'number'], description: FONT_FAMILY_DESCRIPTION },
startElementId: { type: 'string', description: 'For arrows: ID of element to bind arrow start to' },
endElementId: { type: 'string', description: 'For arrows: ID of element to bind arrow end to' },
endArrowhead: { type: 'string', description: 'Arrowhead style at end: arrow, bar, dot, triangle, or null' },
@@ -2199,8 +2242,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 +2337,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,
@@ -2727,12 +2770,17 @@ if (isMainModule()) {
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 --help Show this help\n mcp-excalidraw-local --version Show version\n`);
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`);
}
+421 -6
View File
@@ -13,6 +13,7 @@ import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { execSync } from 'child_process';
import { FONT_FAMILIES, DEFAULT_FONT_FAMILY } from './types.js';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
@@ -55,7 +56,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 +77,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 +91,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,7 +119,7 @@ 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
@@ -165,10 +183,99 @@ async function phaseEnvironment(rl: readline.Interface): Promise<boolean> {
return allOk;
}
// ── Preference Setup ─────────────────────────────────────────
// Derived from FONT_FAMILIES in types.ts — single source of truth
const FONT_OPTIONS = FONT_FAMILIES
.filter(f => !f.legacy)
.map(f => ({ value: f.id, label: f.label }));
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 === DEFAULT_FONT_FAMILY ? ' (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 : DEFAULT_FONT_FAMILY;
// 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 +331,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 +361,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 +476,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,7 +493,7 @@ 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' },
};
@@ -331,6 +525,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):
@@ -338,7 +549,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" }
}
}
@@ -346,6 +557,209 @@ 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> {
@@ -364,6 +778,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`);
+30 -18
View File
@@ -311,24 +311,36 @@ export interface ExcalidrawFile {
// In-memory file storage (image files are too large for SQLite row storage)
export const files = new Map<string, ExcalidrawFile>();
// Font family normalization: Excalidraw expects numeric IDs, but agents
// often send string names. Map common names to their numeric equivalents.
const FONT_FAMILY_MAP: Record<string, number> = {
'virgil': 1,
'hand-drawn': 1,
'excalifont': 1,
'helvetica': 2,
'arial': 2,
'sans-serif': 2,
'cascadia': 3,
'monospace': 3,
'courier': 3,
'comic shanns': 4,
'comic sans': 4,
'liberation sans': 5,
'nunito': 6,
'lilita one': 7,
};
// ── Font families — single source of truth ──────────────────────────────
// IDs match the @excalidraw/excalidraw FONT_FAMILY constant.
// The canonical data lives in font-families.json; every other file derives from it.
import fontData from './font-families.json' with { type: 'json' };
export interface FontFamilyDef {
id: number;
name: string;
label: string;
aliases: string[];
legacy?: boolean; // hidden from setup menus / tool docs
}
export const FONT_FAMILIES: FontFamilyDef[] = fontData.fonts as FontFamilyDef[];
export const DEFAULT_FONT_FAMILY: number = fontData.defaultFontFamily;
// Derived: description string for MCP tool schemas
export const FONT_FAMILY_DESCRIPTION =
'Font family: ' +
FONT_FAMILIES.filter(f => !f.legacy).map(f => `${f.id}=${f.name}`).join(', ') +
'. Accepts name strings too.';
// Derived: string → number mapping for normalization
const FONT_FAMILY_MAP: Record<string, number> = {};
for (const font of FONT_FAMILIES) {
for (const alias of font.aliases) {
FONT_FAMILY_MAP[alias] = font.id;
}
}
export function normalizeFontFamily(value: string | number | undefined): number | undefined {
if (value === undefined || value === null) return undefined;