Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e410f1205 | ||
|
|
71b2a55231 | ||
|
|
25767838fa | ||
|
|
493a20054b |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@sanjibdevnath/mcp-excalidraw-local",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@sanjibdevnath/mcp-excalidraw-local",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@sanjibdevnath/mcp-excalidraw-local",
|
||||
"version": "1.4.0",
|
||||
"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",
|
||||
|
||||
@@ -26,7 +26,7 @@ Before creating any elements, load the user's diagram preferences. These control
|
||||
| 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) | — |
|
||||
| 4 (lowest) | Hardcoded | Server defaults (fontFamily: 5, roughness: 0, fontSize: 20, strokeWidth: 2) | — |
|
||||
|
||||
### How to Load
|
||||
|
||||
@@ -45,11 +45,11 @@ If no preferences file exists at either location, **prompt the user before drawi
|
||||
|
||||
Ask these questions (use `AskUserQuestion` tool if available, otherwise ask inline):
|
||||
|
||||
1. **Font family** — Which font for all text?
|
||||
- Excalifont (hand-drawn) = 1
|
||||
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 = 4
|
||||
- Comic Shanns = 8
|
||||
- Nunito = 6
|
||||
- Lilita One = 7
|
||||
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
{
|
||||
"_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"
|
||||
},
|
||||
"_fontReference": "See src/font-families.json for canonical font ID → name mapping.",
|
||||
"defaults": {
|
||||
"fontFamily": 1,
|
||||
"fontFamily": 5,
|
||||
"fontSize": 20,
|
||||
"roughness": 0,
|
||||
"strokeWidth": 2
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+6
-4
@@ -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';
|
||||
@@ -75,7 +77,7 @@ interface ExcalidrawPreferences {
|
||||
}
|
||||
|
||||
const HARDCODED_DEFAULTS: ExcalidrawPreferences = {
|
||||
fontFamily: 1,
|
||||
fontFamily: DEFAULT_FONT_FAMILY,
|
||||
fontSize: 20,
|
||||
roughness: 0,
|
||||
strokeWidth: 2,
|
||||
@@ -459,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' },
|
||||
@@ -690,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' },
|
||||
|
||||
+100
-6
@@ -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);
|
||||
@@ -118,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
|
||||
@@ -182,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) {
|
||||
@@ -347,7 +437,7 @@ function writeInstructionDirective(filePath: string, format: 'claude-md' | 'curs
|
||||
// ── 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) {
|
||||
@@ -519,7 +609,7 @@ export async function runUpdate(): Promise<void> {
|
||||
|
||||
try {
|
||||
// ── Phase 1: Detect existing skill installations ──────────
|
||||
heading('1/2', 'Skill Update');
|
||||
heading('1/3', 'Skill Update');
|
||||
|
||||
const allInstalls = findExistingSkillInstalls();
|
||||
const existing = allInstalls.filter(i => i.exists);
|
||||
@@ -612,8 +702,11 @@ export async function runUpdate(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 2: MCP config check ────────────────────────────
|
||||
heading('2/2', 'MCP Configuration');
|
||||
// ── 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) {
|
||||
@@ -685,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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user