Compare commits

...
8 Commits
Author SHA1 Message Date
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 DevnathandGitHub 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
9 changed files with 591 additions and 16 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.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@sanjibdevnath/mcp-excalidraw-local",
"version": "1.1.5",
"version": "1.4.0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@sanjibdevnath/mcp-excalidraw-local",
"version": "1.1.5",
"version": "1.4.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",
@@ -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: 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.
@@ -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`.
+51 -5
View File
@@ -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,
@@ -2727,12 +2768,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`);
}
+324 -3
View File
@@ -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',
@@ -224,6 +241,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,6 +271,79 @@ 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> {
@@ -285,6 +386,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 +403,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 +435,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 +459,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 +467,206 @@ 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/2', '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: MCP config check ────────────────────────────
heading('2/2', '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> {