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>
789 lines
27 KiB
JavaScript
789 lines
27 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* Interactive setup wizard for mcp-excalidraw-local.
|
|
* Runs via: npx @sanjibdevnath/mcp-excalidraw-local setup
|
|
*
|
|
* Uses only Node.js built-ins — no third-party dependencies.
|
|
* Every phase is optional and skippable.
|
|
*/
|
|
|
|
import * as readline from 'readline';
|
|
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);
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────
|
|
|
|
const BOLD = '\x1b[1m';
|
|
const DIM = '\x1b[2m';
|
|
const GREEN = '\x1b[32m';
|
|
const RED = '\x1b[31m';
|
|
const YELLOW = '\x1b[33m';
|
|
const CYAN = '\x1b[36m';
|
|
const RESET = '\x1b[0m';
|
|
|
|
function ok(msg: string) { process.stdout.write(` ${GREEN}✔${RESET} ${msg}\n`); }
|
|
function fail(msg: string) { process.stdout.write(` ${RED}✘${RESET} ${msg}\n`); }
|
|
function warn(msg: string) { process.stdout.write(` ${YELLOW}⚠${RESET} ${msg}\n`); }
|
|
function info(msg: string) { process.stdout.write(` ${msg}\n`); }
|
|
function heading(phase: string, title: string) {
|
|
process.stdout.write(`\n ${BOLD}[${phase}] ${title}${RESET}\n`);
|
|
}
|
|
|
|
function ask(rl: readline.Interface, prompt: string): Promise<string> {
|
|
return new Promise(resolve => rl.question(` ${prompt}`, resolve));
|
|
}
|
|
|
|
async function confirm(rl: readline.Interface, prompt: string, defaultYes = true): Promise<boolean> {
|
|
const hint = defaultYes ? '[Y/n]' : '[y/N]';
|
|
const answer = (await ask(rl, `${prompt} ${hint}: `)).trim().toLowerCase();
|
|
if (answer === '') return defaultYes;
|
|
return answer === 'y' || answer === 'yes';
|
|
}
|
|
|
|
// ── Agent Definitions ────────────────────────────────────────
|
|
|
|
interface AgentDef {
|
|
name: string;
|
|
detectPaths: string[];
|
|
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[] {
|
|
const home = os.homedir();
|
|
return [
|
|
{
|
|
name: 'Cursor',
|
|
detectPaths: [path.join(home, '.cursor')],
|
|
skillBasePaths: {
|
|
global: path.join(home, '.cursor', 'skills'),
|
|
local: path.join(process.cwd(), '.cursor', 'skills'),
|
|
},
|
|
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',
|
|
detectPaths: [path.join(home, '.claude')],
|
|
skillBasePaths: {
|
|
global: path.join(home, '.claude', 'skills'),
|
|
local: path.join(process.cwd(), '.claude', 'skills'),
|
|
},
|
|
mcpConfigType: 'cli-command',
|
|
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',
|
|
detectPaths: [path.join(home, '.codex')],
|
|
skillBasePaths: {
|
|
global: path.join(home, '.codex', 'skills'),
|
|
local: path.join(process.cwd(), '.codex', 'skills'),
|
|
},
|
|
mcpConfigType: 'json-file',
|
|
mcpConfigPath: path.join(home, '.codex', 'mcp.json'),
|
|
},
|
|
];
|
|
}
|
|
|
|
function detectInstalledAgents(): AgentDef[] {
|
|
return getAgents().filter(a => a.detectPaths.some(p => fs.existsSync(p)));
|
|
}
|
|
|
|
// ── Phase 1: Environment Check ──────────────────────────────
|
|
|
|
async function phaseEnvironment(rl: readline.Interface): Promise<boolean> {
|
|
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 >= 20) {
|
|
ok(`Node.js ${nodeVersion} ${'.' .repeat(Math.max(0, 24 - nodeVersion.length))} OK`);
|
|
} else {
|
|
fail(`Node.js ${nodeVersion} — requires >= 20.0.0`);
|
|
allOk = false;
|
|
}
|
|
|
|
// better-sqlite3 bindings
|
|
try {
|
|
execSync('node -e "require(\'better-sqlite3\')"', { stdio: 'pipe', cwd: path.resolve(__dirname, '..') });
|
|
ok('better-sqlite3 bindings ........... OK');
|
|
} catch {
|
|
fail('better-sqlite3 bindings ........... FAILED');
|
|
const doFix = await confirm(rl, 'Native module needs rebuild. Fix now?');
|
|
if (doFix) {
|
|
try {
|
|
info(`${DIM}Running npm rebuild better-sqlite3...${RESET}`);
|
|
execSync('npm rebuild better-sqlite3', {
|
|
stdio: 'inherit',
|
|
cwd: path.resolve(__dirname, '..'),
|
|
});
|
|
// Verify
|
|
execSync('node -e "require(\'better-sqlite3\')"', { stdio: 'pipe', cwd: path.resolve(__dirname, '..') });
|
|
ok('Rebuild successful');
|
|
} catch {
|
|
fail('Rebuild failed. Try manually:');
|
|
info(' npm rebuild better-sqlite3');
|
|
info('');
|
|
info('Prerequisites:');
|
|
if (process.platform === 'darwin') {
|
|
info(' xcode-select --install');
|
|
} else if (process.platform === 'linux') {
|
|
info(' sudo apt install build-essential python3');
|
|
} else {
|
|
info(' Install "Desktop development with C++" from Visual Studio Build Tools');
|
|
info(' https://visualstudio.microsoft.com/visual-cpp-build-tools/');
|
|
}
|
|
allOk = false;
|
|
}
|
|
} else {
|
|
info('Manual fix: npm rebuild better-sqlite3');
|
|
allOk = false;
|
|
}
|
|
}
|
|
|
|
// Frontend build
|
|
const frontendIndex = path.resolve(__dirname, '..', 'dist', 'frontend', 'index.html');
|
|
if (fs.existsSync(frontendIndex)) {
|
|
ok('Frontend build .................... OK');
|
|
} else {
|
|
warn('Frontend build .................... NOT FOUND');
|
|
info(`Expected: ${frontendIndex}`);
|
|
info('Run: npm run build');
|
|
}
|
|
|
|
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/4', 'Agent Skill');
|
|
|
|
const wantSkill = await confirm(rl, 'Install the Excalidraw agent skill?');
|
|
if (!wantSkill) {
|
|
info(`${DIM}Skill folder: skills/excalidraw-skill/ (copy manually if needed)${RESET}`);
|
|
return;
|
|
}
|
|
|
|
const agents = detectInstalledAgents();
|
|
if (agents.length === 0) {
|
|
warn('No supported agents detected (Cursor, Claude Code, Codex CLI).');
|
|
info(`${DIM}Skill folder: skills/excalidraw-skill/ (copy manually when ready)${RESET}`);
|
|
return;
|
|
}
|
|
|
|
process.stdout.write('\n Detected agents:\n');
|
|
agents.forEach((a, i) => {
|
|
process.stdout.write(` ${CYAN}[${i + 1}]${RESET} ${a.name}\n`);
|
|
});
|
|
|
|
const selection = await ask(rl, "Which agents? (comma-separated, 'all', or 'skip'): ");
|
|
const trimmed = selection.trim().toLowerCase();
|
|
|
|
if (trimmed === 'skip' || trimmed === '') return;
|
|
|
|
let selectedAgents: AgentDef[];
|
|
if (trimmed === 'all') {
|
|
selectedAgents = agents;
|
|
} else {
|
|
const indices = trimmed.split(',').map(s => parseInt(s.trim(), 10) - 1);
|
|
selectedAgents = indices
|
|
.filter(i => i >= 0 && i < agents.length)
|
|
.map(i => agents[i]!);
|
|
}
|
|
|
|
if (selectedAgents.length === 0) {
|
|
warn('No valid agents selected.');
|
|
return;
|
|
}
|
|
|
|
const skillSource = path.resolve(__dirname, '..', 'skills', 'excalidraw-skill');
|
|
if (!fs.existsSync(skillSource)) {
|
|
fail(`Skill source not found at ${skillSource}`);
|
|
return;
|
|
}
|
|
|
|
for (const agent of selectedAgents) {
|
|
const scopeAnswer = await ask(rl, `\n ${agent.name} — scope? [G]lobal / [l]ocal: `);
|
|
const scope = scopeAnswer.trim().toLowerCase() === 'l' ? 'local' : 'global';
|
|
const destBase = agent.skillBasePaths[scope];
|
|
const destDir = path.join(destBase, 'excalidraw-skill');
|
|
|
|
try {
|
|
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}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function copyDirSync(src: string, dest: string): void {
|
|
fs.mkdirSync(dest, { recursive: true });
|
|
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
const srcPath = path.join(src, entry.name);
|
|
const destPath = path.join(dest, entry.name);
|
|
if (entry.isDirectory()) {
|
|
copyDirSync(srcPath, destPath);
|
|
} else {
|
|
fs.copyFileSync(srcPath, destPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── 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('4/4', 'MCP Configuration');
|
|
|
|
const wantConfig = await confirm(rl, 'Add MCP server to agent configs automatically?');
|
|
if (!wantConfig) {
|
|
printManualConfig();
|
|
return;
|
|
}
|
|
|
|
const agents = detectInstalledAgents();
|
|
if (agents.length === 0) {
|
|
warn('No supported agents detected.');
|
|
printManualConfig();
|
|
return;
|
|
}
|
|
|
|
for (const agent of agents) {
|
|
if (agent.mcpConfigType === 'json-file' && agent.mcpConfigPath) {
|
|
const doIt = await confirm(rl, `${agent.name} — add to ${agent.mcpConfigPath}?`);
|
|
if (!doIt) {
|
|
info(`${DIM}Skipped. Add manually later.${RESET}`);
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
mergeJsonConfig(agent.mcpConfigPath);
|
|
ok(`Added 'excalidraw-canvas' to ${agent.mcpConfigPath}`);
|
|
} catch (err) {
|
|
fail(`Failed: ${(err as Error).message}`);
|
|
info('Add manually:');
|
|
printManualConfig();
|
|
}
|
|
} else if (agent.mcpConfigType === 'cli-command' && agent.mcpCliCommand) {
|
|
const doIt = await confirm(rl, `${agent.name} — register via CLI?`);
|
|
if (!doIt) {
|
|
info(`${DIM}Skipped.${RESET}`);
|
|
continue;
|
|
}
|
|
|
|
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) {
|
|
fail(`CLI registration failed: ${(err as Error).message}`);
|
|
info('Register manually:');
|
|
info(` ${agent.mcpCliCommand}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function mergeJsonConfig(configPath: string): void {
|
|
const mcpEntry = {
|
|
command: 'npx',
|
|
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');
|
|
try {
|
|
existing = JSON.parse(raw);
|
|
} catch {
|
|
throw new Error(`Failed to parse ${configPath} — fix the JSON syntax and try again.`);
|
|
}
|
|
}
|
|
|
|
if (!existing.mcpServers) {
|
|
existing.mcpServers = {};
|
|
}
|
|
|
|
if (existing.mcpServers['excalidraw-canvas']) {
|
|
process.stdout.write(` ${YELLOW}Entry 'excalidraw-canvas' already exists — overwriting.${RESET}\n`);
|
|
}
|
|
|
|
existing.mcpServers['excalidraw-canvas'] = mcpEntry;
|
|
|
|
const dir = path.dirname(configPath);
|
|
if (!fs.existsSync(dir)) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
}
|
|
|
|
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):
|
|
${DIM}{
|
|
"mcpServers": {
|
|
"excalidraw-canvas": {
|
|
"command": "npx",
|
|
"args": ["-y", "@sanjibdevnath/mcp-excalidraw-local@latest"],
|
|
"env": { "CANVAS_PORT": "3000" }
|
|
}
|
|
}
|
|
}${RESET}
|
|
`);
|
|
}
|
|
|
|
// ── 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,
|
|
});
|
|
|
|
process.stdout.write(`\n ${BOLD}Excalidraw MCP — Setup${RESET}\n`);
|
|
|
|
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`);
|
|
} finally {
|
|
rl.close();
|
|
}
|
|
}
|