@sakaniui/react 0.8.8 → 0.8.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,13 +2,14 @@
2
2
  /**
3
3
  * SakaniUI MCP server -- lets an AI editor/agent look up the design system instead of guessing.
4
4
  *
5
- * npx -y -p @sakaniui/react sakaniui-mcp [--project <dir>]
5
+ * npx -y @sakaniui/mcp [--project <dir>] (or: npx -y -p @sakaniui/react sakaniui-mcp)
6
6
  *
7
7
  * Zero dependencies: speaks newline-delimited JSON-RPC (MCP stdio transport).
8
8
  *
9
- * Read-only by construction. It reads four fixed files (package.json, dist/manifest.json,
10
- * AGENTS.md, CHANGELOG.md) from a @sakaniui/react install and writes nothing, runs nothing and
11
- * opens no network connection. No tool takes a path; every tool argument is validated.
9
+ * Read-only by construction. It reads five fixed files (package.json, dist/manifest.json,
10
+ * dist/icon-index.json, AGENTS.md, CHANGELOG.md) from a @sakaniui/react install and writes nothing,
11
+ * runs nothing and opens no network connection. No tool takes a path; every argument is validated.
12
+ * Code passed to check_code is only read as text, never executed.
12
13
  *
13
14
  * Version-aware. `npx` may fetch a newer release than the project uses, so the server looks for
14
15
  * the project's own installed @sakaniui/react (walking up from --project or the working
@@ -30,7 +31,7 @@ const readJson = (root, rel) => { try { return JSON.parse(readText(root, rel));
30
31
  function loadInstall(root) {
31
32
  const pkg = readJson(root, 'package.json');
32
33
  if (!pkg || pkg.name !== '@sakaniui/react') return null;
33
- return { root, version: pkg.version, manifest: readJson(root, 'dist/manifest.json'), guide: readText(root, 'AGENTS.md'), changelog: readText(root, 'CHANGELOG.md') };
34
+ return { root, version: pkg.version, manifest: readJson(root, 'dist/manifest.json'), icons: readJson(root, 'dist/icon-index.json'), guide: readText(root, 'AGENTS.md'), changelog: readText(root, 'CHANGELOG.md') };
34
35
  }
35
36
 
36
37
  function findProjectInstall() {
@@ -46,7 +47,8 @@ function findProjectInstall() {
46
47
  return null;
47
48
  }
48
49
 
49
- const bundled = loadInstall(ownRoot);
50
+ // inside @sakaniui/react the data sits next to bin/; inside @sakaniui/mcp it is copied into data/
51
+ const bundled = loadInstall(fs.existsSync(path.join(ownRoot, 'dist', 'manifest.json')) ? ownRoot : path.join(ownRoot, 'data'));
50
52
  if (!bundled || !bundled.manifest) {
51
53
  process.stderr.write('sakaniui-mcp: dist/manifest.json not found. Run `npm run build:lib` (or install a published version).\n');
52
54
  process.exit(1);
@@ -64,8 +66,25 @@ const active = project && project.manifest && schemaOf(project) >= schemaOf(bund
64
66
  const manifest = active.manifest;
65
67
  const guide = active.guide || bundled.guide || '';
66
68
  const changelog = active.changelog || bundled.changelog || '';
69
+ const iconIndex = (active.icons && active.icons.icons) || (bundled.icons && bundled.icons.icons) || [];
67
70
  const projectVersion = project ? project.version : null;
68
71
 
72
+ // The HTML attributes React accepts (shipped in the manifest since 0.8.10; older manifests use the fallback list).
73
+ const nativeAttrs = new Set(manifest.nativeAttributes || []);
74
+ const NATIVE = { test: (n) => /^(aria-|data-)/.test(n) || /^on[A-Z]/.test(n) || n === 'key' || n === 'ref' || n === 'children' || (nativeAttrs.size ? nativeAttrs.has(n) : NATIVE_FALLBACK.test(n)) };
75
+ /** What to say about a prop the component does not declare: an error when nothing accepts it, else a note. */
76
+ function unknownProp(c, name) {
77
+ if (NATIVE.test(name)) {
78
+ // a real HTML attribute (placeholder, href, name, defaultChecked...): passed to the element, and fine.
79
+ // `color` is the one people mistake for a styling prop: it does not colour the component.
80
+ return name === 'color' ? { level: 'warning', message: `"color" is not a ${c.name} prop: it is the old HTML colour attribute and does not change the component's colours. Use the component's own props, or design tokens (get_tokens).` } : null;
81
+ }
82
+ // close spellings, plus the usual synonyms (leftIcon -> leadingIcon, onChange vs onValueChange is left to the list)
83
+ const canon = (x) => x.toLowerCase().replace(/left|start|before|prefix/g, 'leading').replace(/right|end|after|suffix/g, 'trailing').replace(/colou?r|tone|kind|type/g, 'variant');
84
+ const near = c.props.map((p) => p.name).filter((p) => distance(p.toLowerCase(), name.toLowerCase()) <= 2 || canon(p) === canon(name));
85
+ return { level: 'error', message: `"${name}" is not a ${c.name} prop${near.length ? ` (did you mean ${near.map((x) => `"${x}"`).join(', ')}?)` : ''}. ${c.name} accepts: ${c.props.map((p) => p.name).join(', ') || 'no props of its own'}.` };
86
+ }
87
+
69
88
  /* ---------- versions ---------- */
70
89
  const SEMVER = /^\d+\.\d+\.\d+/;
71
90
  const parse = (v) => String(v).split('-')[0].split('.').map(Number);
@@ -91,6 +110,15 @@ function inInstalled(c, installed = projectVersion) {
91
110
  return cmp(c.since, installed) <= 0;
92
111
  }
93
112
 
113
+ /** Is this prop (or this value of it) in the project's install? Returns a message when it is not. */
114
+ function propTooNew(c, p, value, installed = projectVersion) {
115
+ if (!installed) return null;
116
+ if (p.since && cmp(p.since, installed) > 0) return `"${p.name}" was added to ${c.name} in ${p.since}; the project has ${installed}, where it does not exist. Upgrade (npm install @sakaniui/react@latest) or leave it out.`;
117
+ const vs = p.valuesSince && typeof value === 'string' ? p.valuesSince[value] : null;
118
+ if (vs && cmp(vs, installed) > 0) return `${p.name}=${JSON.stringify(value)} was added in ${vs}; the project has ${installed}, where it does not exist.`;
119
+ return null;
120
+ }
121
+
94
122
  /* ---------- helpers ---------- */
95
123
  const byName = new Map(manifest.components.map((c) => [c.name.toLowerCase(), c]));
96
124
  const brief = (c) => {
@@ -152,7 +180,78 @@ function closest(name) {
152
180
  .map(([, c]) => c);
153
181
  }
154
182
 
155
- const NATIVE = /^(className|style|id|ref|key|children|role|tabIndex|title|hidden|lang|dir|slot|on[A-Z].*|aria-.*|data-.*)$/;
183
+ /* ---------- icons ---------- */
184
+ const iconNames = new Set();
185
+ for (const i of iconIndex) { iconNames.add(i.name); if (i.filled) iconNames.add(i.filled); }
186
+ const iconOtherExports = new Set(((active.icons && active.icons.other) || (bundled.icons && bundled.icons.other)) || []);
187
+ const iconWords = (name) => name.replace(/^Icon/, '').replace(/Filled$/, '').replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/([A-Za-z])(\d)/g, '$1 $2').toLowerCase();
188
+
189
+ // The conventional icon for common UI intents, ranked first (tags alone rank e.g. IconHttpDelete above IconTrash for "delete").
190
+ const COMMON = {
191
+ delete: ['IconTrash'], remove: ['IconTrash', 'IconX'], trash: ['IconTrash'], bin: ['IconTrash'], edit: ['IconPencil', 'IconEdit'], rename: ['IconPencil'],
192
+ close: ['IconX'], dismiss: ['IconX'], cancel: ['IconX'], add: ['IconPlus'], new: ['IconPlus'], create: ['IconPlus'], plus: ['IconPlus'],
193
+ search: ['IconSearch'], find: ['IconSearch'], settings: ['IconSettings', 'IconAdjustments'], preferences: ['IconSettings'], menu: ['IconMenu2'],
194
+ more: ['IconDots', 'IconDotsVertical'], options: ['IconDots', 'IconDotsVertical'], user: ['IconUser'], profile: ['IconUser', 'IconUserCircle'], account: ['IconUserCircle'],
195
+ home: ['IconHome'], back: ['IconArrowLeft', 'IconChevronLeft'], next: ['IconArrowRight', 'IconChevronRight'], forward: ['IconArrowRight'], previous: ['IconArrowLeft', 'IconChevronLeft'],
196
+ expand: ['IconChevronDown'], collapse: ['IconChevronUp'], dropdown: ['IconChevronDown'], upload: ['IconUpload'], download: ['IconDownload'], share: ['IconShare'],
197
+ copy: ['IconCopy'], duplicate: ['IconCopy'], save: ['IconDeviceFloppy'], filter: ['IconFilter'], sort: ['IconArrowsSort'], calendar: ['IconCalendar'], date: ['IconCalendar'],
198
+ notification: ['IconBell'], notifications: ['IconBell'], bell: ['IconBell'], mail: ['IconMail'], email: ['IconMail'], message: ['IconMessage'], chat: ['IconMessageCircle'],
199
+ phone: ['IconPhone'], call: ['IconPhone'], logout: ['IconLogout'], signout: ['IconLogout'], login: ['IconLogin'], help: ['IconHelpCircle', 'IconHelp'], info: ['IconInfoCircle'],
200
+ warning: ['IconAlertTriangle'], error: ['IconAlertCircle'], success: ['IconCircleCheck'], check: ['IconCheck'], done: ['IconCheck'], favorite: ['IconHeart', 'IconStar'],
201
+ like: ['IconHeart', 'IconThumbUp'], star: ['IconStar'], rating: ['IconStar'], cart: ['IconShoppingCart'], refresh: ['IconRefresh'], reload: ['IconRefresh'], link: ['IconLink'],
202
+ attach: ['IconPaperclip'], attachment: ['IconPaperclip'], lock: ['IconLock'], password: ['IconLock'], view: ['IconEye'], show: ['IconEye'], hide: ['IconEyeOff'],
203
+ play: ['IconPlayerPlay'], pause: ['IconPlayerPause'], stop: ['IconPlayerStop'], theme: ['IconSun', 'IconMoon'], dark: ['IconMoon'], light: ['IconSun'],
204
+ external: ['IconExternalLink'], drag: ['IconGripVertical'], sidebar: ['IconLayoutSidebar'], dashboard: ['IconLayoutDashboard'], analytics: ['IconChartBar'], chart: ['IconChartBar'],
205
+ money: ['IconCurrencyDollar'], payment: ['IconCreditCard'], card: ['IconCreditCard'], location: ['IconMapPin'], time: ['IconClock'], clock: ['IconClock'], file: ['IconFile'],
206
+ folder: ['IconFolder'], image: ['IconPhoto'], photo: ['IconPhoto'], video: ['IconVideo'],
207
+ };
208
+
209
+ function searchIcons(query, limit = 12) {
210
+ const terms = String(query).toLowerCase().replace(/icon/g, ' ').split(/[^a-z0-9]+/).filter(Boolean);
211
+ if (!terms.length) return [];
212
+ const boosted = new Map();
213
+ for (const t of terms) (COMMON[t] || []).forEach((n, k) => boosted.set(n, Math.max(boosted.get(n) || 0, 40 - k * 5)));
214
+ return iconIndex
215
+ .map((i) => {
216
+ const words = iconWords(i.name).split(' ');
217
+ const tags = (i.tags || []).map((t) => t.toLowerCase());
218
+ let s = boosted.get(i.name) || 0;
219
+ for (const t of terms) {
220
+ if (words.join(' ') === t) s += 30;
221
+ else if (words[0] === t) s += 12;
222
+ else if (words.includes(t)) s += 8;
223
+ else if (words.some((w) => w.startsWith(t))) s += 4;
224
+ if (tags.includes(t)) s += 6;
225
+ else if (tags.some((g) => g.includes(t))) s += 2;
226
+ if ((i.category || '').toLowerCase() === t) s += 2;
227
+ }
228
+ return [s - words.length * 0.1, i];
229
+ })
230
+ .filter(([s]) => s > 0.5)
231
+ .sort((a, b) => b[0] - a[0])
232
+ .slice(0, limit)
233
+ .map(([, i]) => i);
234
+ }
235
+
236
+ /** Checks a value given for an icon prop. Returns an error string, or null. */
237
+ function iconProblem(propName, raw, importedElsewhere = new Set(), localNames = new Set()) {
238
+ const v = String(raw).trim().replace(/^\{|\}$/g, '').trim();
239
+ if (/^</.test(v)) {
240
+ const tag = (/^<\s*([A-Za-z][\w.]*)/.exec(v) || [])[1] || 'Icon';
241
+ const real = iconNames.has(tag) || importedElsewhere.has(tag) ? tag : (searchIcons(tag, 1)[0] || {}).name;
242
+ const unknown = real && real !== tag ? ` ${tag} is not a SakaniUI icon either.` : '';
243
+ return `"${propName}" takes the icon component itself, not an element: write ${propName}={${real || 'IconName'}}, not ${propName}={<${tag} />}.${unknown}${!real ? ' Use search_icons to find the real name.' : ''}`;
244
+ }
245
+ if (!/^[A-Za-z_$][\w$]*$/.test(v)) return null; // an expression we cannot judge
246
+ if (iconNames.has(v) || importedElsewhere.has(v) || localNames.has(v)) return null;
247
+ // a local name (alias, parameter, constant): the import line was already checked, and we cannot see the rest
248
+ if (!/^Icon[A-Z0-9]/.test(v)) return null;
249
+ const hint = searchIcons(v, 3).map((i) => i.name);
250
+ return `"${v}" is not a SakaniUI icon.${hint.length ? ` Did you mean: ${hint.join(', ')}?` : ''} Use search_icons to find the real name.`;
251
+ }
252
+
253
+ // Attributes every component forwards to its root element. A prop outside this list and the contract is worth a warning.
254
+ const NATIVE_FALLBACK = /^(className|style|id|ref|key|children|role|tabIndex|title|hidden|lang|dir|slot|on[A-Z].*|aria-.*|data-.*|href|target|rel|download|name|type|value|defaultValue|checked|defaultChecked|disabled|required|readOnly|placeholder|htmlFor|form|autoFocus|autoComplete|autoCapitalize|spellCheck|inputMode|pattern|min|max|step|minLength|maxLength|rows|cols|multiple|accept|src|srcSet|alt|width|height|loading|draggable|tabindex|ariaLabel)$/;
156
255
 
157
256
  /** Release notes: [{version, body}] newest first, from the CHANGELOG that matches the answering install. */
158
257
  function releases() {
@@ -162,6 +261,215 @@ function releases() {
162
261
  return out.sort((a, b) => cmp(b.version, a.version));
163
262
  }
164
263
 
264
+ /* ---------- check_code: a small, read-only JSX scanner (the code is never executed) ---------- */
265
+ /** Line number lookup in O(log n): a precomputed index of line starts per code string. */
266
+ function lineIndex(code) {
267
+ const starts = [0];
268
+ for (let i = 0; i < code.length; i++) if (code.charCodeAt(i) === 10) starts.push(i + 1);
269
+ return (pos) => { let lo = 0, hi = starts.length - 1; while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (starts[mid] <= pos) lo = mid; else hi = mid - 1; } return lo + 1; };
270
+ }
271
+ let lineAt = (code, i) => code.slice(0, i).split('\n').length;
272
+
273
+ function parseImports(code) {
274
+ const out = [];
275
+ const re = /import\s+(type\s+)?(?:([A-Za-z_$][\w$]*)\s*,?\s*)?(?:\{([^}]*)\})?\s*from\s*['"]([^'"]+)['"]/g;
276
+ let m;
277
+ while ((m = re.exec(code))) {
278
+ const names = (m[3] || '').split(',').map((x) => x.trim()).filter(Boolean).map((x) => {
279
+ const typeOnly = /^type\s+/.test(x);
280
+ const [imported, local] = x.replace(/^type\s+/, '').split(/\s+as\s+/).map((y) => y.trim());
281
+ return { imported, local: local || imported, typeOnly: typeOnly || !!m[1] };
282
+ });
283
+ out.push({ from: m[4], names, line: lineAt(code, m.index), def: m[2] || null });
284
+ }
285
+ return out;
286
+ }
287
+
288
+ /** Reads a balanced {...} starting at code[i] === '{'. Returns the index after the closing brace. */
289
+ function skipBraces(code, i) {
290
+ let depth = 0;
291
+ for (; i < code.length; i++) {
292
+ const ch = code[i];
293
+ if (ch === '"' || ch === "'" || ch === '`') {
294
+ const q = ch;
295
+ for (i++; i < code.length && code[i] !== q; i++) if (code[i] === '\\') i++;
296
+ continue;
297
+ }
298
+ if (ch === '{') depth++;
299
+ else if (ch === '}') { depth--; if (depth === 0) return i + 1; }
300
+ }
301
+ return code.length;
302
+ }
303
+
304
+ const ATTR_NAME = /[A-Za-z_][\w:.-]*/y;
305
+
306
+ function parseElements(code, wanted) {
307
+ const els = [];
308
+ const re = /<([A-Z][\w]*(?:\.[A-Z][\w]*)?)(?=[\s/>])/g;
309
+ let m;
310
+ while ((m = re.exec(code))) {
311
+ const tag = m[1];
312
+ if (!wanted(tag)) continue;
313
+ let i = m.index + m[0].length;
314
+ const attrs = [];
315
+ let spread = false;
316
+ let selfClosing = false;
317
+ // every pass must move forward; an unterminated attribute ends the element instead of looping
318
+ for (let guard = 0; i < code.length && guard < 5000; guard++) {
319
+ while (/\s/.test(code[i])) i++;
320
+ if (code[i] === '/' && code[i + 1] === '>') { selfClosing = true; i += 2; break; }
321
+ if (code[i] === '>') { i++; break; }
322
+ if (code[i] === '<') break; // a new tag before this one closed: malformed, stop here
323
+ if (code[i] === '{') { const end = skipBraces(code, i); if (/^\{\s*\.\.\./.test(code.slice(i, end))) spread = true; i = end; continue; }
324
+ ATTR_NAME.lastIndex = i;
325
+ const nm = ATTR_NAME.exec(code);
326
+ if (!nm) { i++; continue; }
327
+ const name = nm[0];
328
+ i += name.length;
329
+ while (/\s/.test(code[i])) i++;
330
+ if (code[i] !== '=') { attrs.push({ name, kind: 'boolean', value: true }); continue; }
331
+ i++;
332
+ while (/\s/.test(code[i])) i++;
333
+ if (code[i] === '"' || code[i] === "'") {
334
+ const q = code[i];
335
+ const end = code.indexOf(q, i + 1);
336
+ if (end === -1) { attrs.push({ name, kind: 'string', value: code.slice(i + 1) }); i = code.length; break; }
337
+ attrs.push({ name, kind: 'string', value: code.slice(i + 1, end) });
338
+ i = end + 1;
339
+ } else if (code[i] === '{') {
340
+ const end = skipBraces(code, i);
341
+ const expr = code.slice(i + 1, end - 1).trim();
342
+ attrs.push({ name, ...classify(expr) });
343
+ i = end;
344
+ }
345
+ }
346
+ els.push({ tag, attrs, spread, selfClosing, line: lineAt(code, m.index) });
347
+ if (els.length >= 5000) break;
348
+ re.lastIndex = Math.max(re.lastIndex, i);
349
+ }
350
+ return els;
351
+ }
352
+
353
+ function classify(expr) {
354
+ let m;
355
+ if ((m = /^(['"])([\s\S]*)\1$/.exec(expr))) return { kind: 'string', value: m[2], raw: expr };
356
+ if ((m = /^`([^`$]*)`$/.exec(expr))) return { kind: 'string', value: m[1], raw: expr };
357
+ if (/^-?\d+(\.\d+)?$/.test(expr)) return { kind: 'number', value: Number(expr), raw: expr };
358
+ if (expr === 'true' || expr === 'false') return { kind: 'boolean', value: expr === 'true', raw: expr };
359
+ if (/^</.test(expr)) return { kind: 'element', raw: expr };
360
+ if (/^[A-Za-z_$][\w$]*$/.test(expr)) return { kind: 'identifier', value: expr, raw: expr };
361
+ return { kind: 'expression', raw: expr };
362
+ }
363
+
364
+ const TYPE_LIKE = /(Props|Type|Variant|Size|Item|Option|Datum|Column|Link|Plan|Post|Action|Step|Image|Author|Change|Data|Tab|Color|Category|Job|Member|Testimonial|Transaction)$/;
365
+
366
+ /** Replaces the contents of template literals with spaces (newlines kept): code shown inside a string is not code. */
367
+ function blankTemplates(code) {
368
+ let out = '';
369
+ for (let i = 0; i < code.length; i++) {
370
+ const ch = code[i];
371
+ if (ch === '`') {
372
+ let j = i + 1, depth = 0;
373
+ for (; j < code.length; j++) {
374
+ if (code[j] === '\\') { j++; continue; }
375
+ if (code[j] === '$' && code[j + 1] === '{') { depth++; j++; continue; }
376
+ if (code[j] === '}' && depth > 0) { depth--; continue; }
377
+ if (code[j] === '`' && depth === 0) break;
378
+ }
379
+ out += '`' + code.slice(i + 1, j).replace(/[^\n]/g, ' ') + '`';
380
+ i = j;
381
+ } else out += ch;
382
+ }
383
+ return out;
384
+ }
385
+
386
+ function checkCode(source) {
387
+ const code = blankTemplates(source);
388
+ lineAt = ((idx) => (_c, pos) => idx(pos))(lineIndex(code));
389
+ const errors = [];
390
+ const warnings = [];
391
+ const imports = parseImports(code);
392
+ const importedFrom = new Map(); // local name -> module
393
+ for (const imp of imports) { for (const n of imp.names) importedFrom.set(n.local, imp.from); if (imp.def) importedFrom.set(imp.def, imp.from); }
394
+ const importedElsewhere = new Set([...importedFrom].filter(([, from]) => !from.startsWith('@sakaniui/react')).map(([n]) => n));
395
+ // anything the snippet declares itself (const Icon = ..., function X, class Y) or aliases: not ours to judge
396
+ const localNames = new Set([...code.matchAll(/\b(?:const|let|var|function|class)\s+([A-Za-z_$][\w$]*)/g)].map((m) => m[1]));
397
+ for (const imp of imports) for (const n of imp.names) if (n.local !== n.imported) localNames.add(n.local);
398
+
399
+ for (const imp of imports) {
400
+ if (!imp.from.startsWith('@sakaniui/react')) continue;
401
+ const entry = imp.from;
402
+ if (!['@sakaniui/react', '@sakaniui/react/blocks', '@sakaniui/react/icons'].includes(entry)) {
403
+ if (imp.names.length) errors.push({ line: imp.line, message: `"${entry}" has no named exports. Import components from @sakaniui/react, blocks from @sakaniui/react/blocks and icons from @sakaniui/react/icons.` });
404
+ continue;
405
+ }
406
+ if (imp.def) errors.push({ line: imp.line, message: `${entry} has no default export; use a named import: import { ${imp.def} } from '${entry}';` });
407
+ for (const n of imp.names) {
408
+ if (n.typeOnly) continue;
409
+ const c = byName.get(n.imported.toLowerCase());
410
+ const isUtility = manifest.utilities?.some((u) => u.name === n.imported);
411
+ if (entry === '@sakaniui/react/icons') {
412
+ if (!iconNames.has(n.imported) && !iconOtherExports.has(n.imported)) {
413
+ const hint = searchIcons(n.imported, 3).map((i) => i.name);
414
+ errors.push({ line: imp.line, message: `${n.imported} is not exported by @sakaniui/react/icons.${hint.length ? ` Did you mean: ${hint.join(', ')}?` : ''}` });
415
+ }
416
+ continue;
417
+ }
418
+ if (c && c.name !== n.imported) { errors.push({ line: imp.line, message: `${n.imported} has the wrong casing; the export is ${c.name}.` }); continue; }
419
+ if (entry === '@sakaniui/react/blocks') {
420
+ if (!c || c.kind !== 'block') errors.push({ line: imp.line, message: c ? `${n.imported} is a component: import it from @sakaniui/react.` : `${n.imported} is not a SakaniUI block.` });
421
+ continue;
422
+ }
423
+ if (c && c.kind === 'block') { errors.push({ line: imp.line, message: `${n.imported} is a block: import it from @sakaniui/react/blocks.` }); continue; }
424
+ if (!c && !isUtility) {
425
+ if (TYPE_LIKE.test(n.imported)) continue; // most likely a type export
426
+ const near = closest(n.imported);
427
+ errors.push({ line: imp.line, message: `${n.imported} is not exported by @sakaniui/react.${near.length ? ` Did you mean: ${near.join(', ')}?` : ''} Use search_components.` });
428
+ }
429
+ if (c && inInstalled(c) === false) errors.push({ line: imp.line, message: `${c.name} was added in ${c.since}; the project has ${projectVersion}.` });
430
+ }
431
+ }
432
+
433
+ // elements: anything imported from @sakaniui, plus manifest names used without an import
434
+ const isSakani = (tag) => importedFrom.get(tag)?.startsWith('@sakaniui/react') || (!importedFrom.has(tag) && byName.has(tag.toLowerCase()) && byName.get(tag.toLowerCase()).name === tag);
435
+ const elements = parseElements(code, isSakani);
436
+ for (const el of elements) {
437
+ const c = byName.get(el.tag.toLowerCase());
438
+ if (!c) continue;
439
+ const at = (message) => ({ line: el.line, message: `<${el.tag}>: ${message}` });
440
+ if (inInstalled(c) === false && !importedFrom.has(el.tag)) errors.push(at(`${c.name} was added in ${c.since}; the project has ${projectVersion}, where it does not exist.`));
441
+ if (!importedFrom.has(el.tag)) warnings.push(at(`not imported. Add: import { ${c.name} } from '${c.import}';`));
442
+ const given = new Map(el.attrs.map((a) => [a.name, a]));
443
+ if (!el.selfClosing) given.set('children', { kind: 'expression' });
444
+ for (const r of c.contract.requiredProps) {
445
+ if (given.has(r)) continue;
446
+ if (el.spread) warnings.push(at(`required prop "${r}" is not written out (it may come from the spread).`));
447
+ else errors.push(at(`missing required prop "${r}".`));
448
+ }
449
+ const known = new Map(c.props.map((p) => [p.name, p]));
450
+ for (const a of el.attrs) {
451
+ const p = known.get(a.name);
452
+ if (!p) { const u = unknownProp(c, a.name); if (u) (u.level === 'error' ? errors : warnings).push(at(u.message)); continue; }
453
+ if (p.values && ['string', 'number', 'boolean'].includes(a.kind) && !p.values.includes(a.value)) {
454
+ errors.push(at(`${a.name}=${JSON.stringify(a.value)} is not allowed. Use one of: ${p.values.map((x) => JSON.stringify(x)).join(', ')}.`));
455
+ }
456
+ { const tooNew = propTooNew(c, p, a.kind === 'string' ? a.value : undefined); if (tooNew) errors.push(at(tooNew)); }
457
+ if (p.type === 'IconComponent') {
458
+ if (a.kind === 'string') errors.push(at(`"${a.name}" takes an icon component, not a string: ${a.name}={IconName}.`));
459
+ else if (a.kind === 'element' || a.kind === 'identifier') {
460
+ const e = iconProblem(a.name, a.raw, importedElsewhere, localNames);
461
+ if (e) errors.push(at(e));
462
+ else if (a.kind === 'identifier' && iconNames.has(a.value) && !importedFrom.has(a.value) && !localNames.has(a.value)) warnings.push(at(`${a.value} is not imported. Add: import { ${a.value} } from '@sakaniui/react/icons';`));
463
+ }
464
+ }
465
+ }
466
+ }
467
+ if (!elements.length && !imports.some((i) => i.from.startsWith('@sakaniui/react'))) warnings.push({ line: 1, message: 'No SakaniUI imports or elements found in this code.' });
468
+ const out = { ok: errors.length === 0, checkedElements: elements.length, errors, warnings };
469
+ const note = versionNote(); if (note) out.versionNote = note;
470
+ return out;
471
+ }
472
+
165
473
  const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
166
474
 
167
475
  /* ---------- tools ---------- */
@@ -194,7 +502,20 @@ const tools = {
194
502
  const c = byName.get(n.value.toLowerCase());
195
503
  if (!c) return fail(`No component named "${n.value}". Did you mean: ${closest(n.value).join(', ') || '(none close)'}? Use search_components.`);
196
504
  const out = { ...c, note: 'Native HTML attributes (className, style, aria-*, event handlers) are also accepted on the root element.' };
505
+ if (c.example) out.exampleNote = 'Start from `example`: it is type-checked against this release. Replace the placeholder text and values; keep the prop names and shapes.';
506
+ if (c.props.some((p) => p.type === 'IconComponent')) out.iconNote = 'Icon props take the component itself (icon={IconTrash}), not an element. The example uses IconStar as a placeholder: find the real icon with search_icons.';
197
507
  if (inInstalled(c) === false) out.availableInInstalled = false;
508
+ if (projectVersion) {
509
+ out.props = c.props.map((p) => {
510
+ const tooNew = propTooNew(c, p);
511
+ const newerValues = p.valuesSince ? Object.entries(p.valuesSince).filter(([, v]) => cmp(v, projectVersion) > 0).map(([k]) => k) : [];
512
+ if (!tooNew && !newerValues.length) return p;
513
+ const q = { ...p };
514
+ if (tooNew) q.availableInInstalled = false;
515
+ if (newerValues.length) q.valuesNotInInstalled = newerValues;
516
+ return q;
517
+ });
518
+ }
198
519
  const note = versionNote();
199
520
  if (note) out.versionNote = note;
200
521
  if (inInstalled(c) === false) out.warning = `${c.name} was added in ${c.since} but the project has ${projectVersion}. Do not use it without upgrading (npm install @sakaniui/react@latest).`;
@@ -251,10 +572,12 @@ const tools = {
251
572
  for (const r of c.contract.requiredProps) if (!Object.hasOwn(props, r)) errors.push(`Missing required prop "${r}".`);
252
573
  for (const [k, v] of Object.entries(props)) {
253
574
  const p = known.get(k);
254
- if (!p) { if (!NATIVE.test(k)) warnings.push(`"${k}" is not a ${c.name} prop in the manifest (it would only work if it is a native attribute).`); continue; }
575
+ if (!p) { const u = unknownProp(c, k); if (u) (u.level === 'error' ? errors : warnings).push(u.message); continue; }
255
576
  if (p.values && (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') && !p.values.includes(v)) {
256
577
  errors.push(`"${k}" = ${JSON.stringify(v)} is not allowed. Use one of: ${p.values.map((x) => JSON.stringify(x)).join(', ')}.`);
257
578
  }
579
+ if (p.type === 'IconComponent' && typeof v === 'string') { const e = iconProblem(k, v); if (e) errors.push(e); }
580
+ const tooNew = propTooNew(c, p, v); if (tooNew) errors.push(tooNew);
258
581
  }
259
582
  if (c.contract.requiresAccessibleName && !errors.length) warnings.push('This control has no visible text of its own: keep its accessible-name prop set.');
260
583
  const out = { component: c.name, ok: errors.length === 0, errors, warnings };
@@ -301,11 +624,34 @@ const tools = {
301
624
  return text(pick.map((r) => `## ${r.version}\n\n${r.body}`).join('\n\n'));
302
625
  },
303
626
  },
627
+ search_icons: {
628
+ description: 'Find real icon names (Tabler set, exported from @sakaniui/react/icons) by meaning, e.g. "delete", "settings", "arrow right". Never guess an icon name: only names returned here exist.',
629
+ inputSchema: { type: 'object', properties: { query: { type: 'string' }, limit: { type: 'number' } }, required: ['query'] },
630
+ run(args) {
631
+ const q = strArg(args, 'query', { required: true }); if (q.err) return fail(q.err);
632
+ if (!iconIndex.length) return fail('The icon index is not available in this install.');
633
+ const limit = Number.isInteger(args.limit) ? Math.min(Math.max(args.limit, 1), 30) : 12;
634
+ const hits = searchIcons(q.value, limit).map((i) => { const o = { name: i.name }; if (i.filled) o.filled = i.filled; if (i.category) o.category = i.category; if (i.tags) o.tags = i.tags.slice(0, 5); return o; });
635
+ if (!hits.length) return text(`No icon matches "${q.value}". Try a simpler word (e.g. "delete" rather than "remove item").`);
636
+ return text({ import: "import { IconName } from '@sakaniui/react/icons';", usage: 'icon={IconName} (the component, not <IconName />)', icons: hits });
637
+ },
638
+ },
639
+ check_code: {
640
+ description: 'Check a JSX/TSX snippet you wrote: imports from @sakaniui/react (and /blocks, /icons) must exist, every SakaniUI element must have its required props, prop values must be allowed, and icon props must be real icon components passed as components. The code is only read, never run. Run it on every snippet before you answer.',
641
+ inputSchema: { type: 'object', properties: { code: { type: 'string', description: 'The snippet, including its import lines.' } }, required: ['code'] },
642
+ run(args) {
643
+ const code = args.code;
644
+ if (typeof code !== 'string' || !code.trim()) return fail('"code" must be a non-empty string.');
645
+ if (code.length > 50000) return fail('"code" is too long (max 50,000 characters).');
646
+ return text(checkCode(code));
647
+ },
648
+ },
304
649
  get_guide: {
305
650
  description: 'The AGENTS.md guide (principles, token rules, when nothing fits, glass, icons, per-component "why / when not to use", checklist). Pass a section number or heading text to get just that section; omit for the section list.',
306
- inputSchema: { type: 'object', properties: { section: { type: 'string' } } },
651
+ inputSchema: { type: 'object', properties: { section: { type: 'string', description: 'A section number ("3") or words from its heading ("icons").' } } },
307
652
  run(args) {
308
- const s0 = strArg(args, 'section'); if (s0.err) return fail(s0.err);
653
+ // agents sometimes call it with `query`; treat that as the section
654
+ const s0 = strArg(args.section === undefined && args.query !== undefined ? { section: args.query } : args, 'section'); if (s0.err) return fail(s0.err);
309
655
  if (!guide) return fail('AGENTS.md is not available in this install.');
310
656
  const parts = guide.split(/^(?=## )/m).filter((p) => p.startsWith('## '));
311
657
  const heads = parts.map((p) => p.split('\n')[0].replace(/^## /, ''));
@@ -318,45 +664,71 @@ const tools = {
318
664
  };
319
665
 
320
666
  /* ---------- JSON-RPC over stdio ---------- */
321
- const send = (msg) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', ...msg }) + '\n');
667
+ // Protocol versions this server implements; the client's is used when it is one of them.
668
+ const PROTOCOL_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
322
669
 
670
+ let stdoutClosed = false;
671
+ process.stdout.on('error', () => { stdoutClosed = true; process.exit(0); }); // the client went away (EPIPE)
672
+ const write = (payload) => { if (!stdoutClosed) process.stdout.write(JSON.stringify(payload) + '\n'); };
673
+ const reply = (id, body) => ({ jsonrpc: '2.0', id, ...body });
674
+
675
+ /** Handles one message. Returns the response, or null for notifications. */
323
676
  function handle(msg) {
324
- if (!msg || typeof msg !== 'object' || Array.isArray(msg)) return send({ id: null, error: { code: -32600, message: 'Invalid request' } });
677
+ if (!msg || typeof msg !== 'object' || Array.isArray(msg)) return reply(null, { error: { code: -32600, message: 'Invalid request' } });
325
678
  const { id, method, params } = msg;
326
679
  const isRequest = id !== undefined && id !== null;
680
+ if (typeof method !== 'string') return isRequest ? reply(id, { error: { code: -32600, message: 'Invalid request: method must be a string' } }) : null;
681
+ if (!isRequest) return null; // notifications (initialized, cancelled, ...) need no answer
327
682
  try {
328
683
  switch (method) {
329
684
  case 'initialize': {
330
685
  const note = versionNote();
331
- return send({ id, result: {
332
- protocolVersion: params?.protocolVersion || '2025-06-18',
333
- capabilities: { tools: {} },
334
- serverInfo: { name: 'sakaniui', version: refVersion },
335
- instructions: `SakaniUI ${refVersion}${projectVersion ? ` (project has ${projectVersion})` : ''}. Read-only reference. Look components up with search_components / get_component before writing JSX, validate with check_usage, use only tokens from get_tokens, and read get_guide when nothing fits.${note ? ` ${note}` : ''}`,
686
+ const asked = params && typeof params.protocolVersion === 'string' ? params.protocolVersion : null;
687
+ return reply(id, { result: {
688
+ protocolVersion: asked && PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0],
689
+ capabilities: { tools: { listChanged: false } },
690
+ serverInfo: { name: 'sakaniui', title: 'SakaniUI', version: refVersion },
691
+ instructions: `SakaniUI ${refVersion}${projectVersion ? ` (project has ${projectVersion})` : ''}. Read-only reference. Look components up with search_components / get_component and start from their \`example\`; find icon names with search_icons (never guess them); run check_code on every snippet before you answer; style anything beyond props with the design tokens from get_tokens (CSS variables), not hard-coded colours or utility classes the project may not have; read get_guide when nothing fits.${note ? ` ${note}` : ''}`,
336
692
  } });
337
693
  }
338
694
  case 'ping':
339
- return send({ id, result: {} });
695
+ return reply(id, { result: {} });
340
696
  case 'tools/list':
341
- return send({ id, result: { tools: Object.entries(tools).map(([name, t]) => ({ name, description: t.description, inputSchema: t.inputSchema, annotations: READ_ONLY })) } });
697
+ return reply(id, { result: { tools: Object.entries(tools).map(([name, t]) => ({ name, description: t.description, inputSchema: t.inputSchema, annotations: READ_ONLY })) } });
342
698
  case 'tools/call': {
343
699
  const t = typeof params?.name === 'string' && Object.hasOwn(tools, params.name) ? tools[params.name] : null;
344
- if (!t) return send({ id, error: { code: -32602, message: `Unknown tool: ${String(params?.name).slice(0, 60)}` } });
700
+ if (!t) return reply(id, { error: { code: -32602, message: `Unknown tool: ${String(params?.name).slice(0, 60)}` } });
345
701
  const args = params.arguments;
346
- if (args !== undefined && args !== null && (typeof args !== 'object' || Array.isArray(args))) return send({ id, result: fail('Tool arguments must be an object.') });
347
- return send({ id, result: t.run(args || {}) });
702
+ if (args !== undefined && args !== null && (typeof args !== 'object' || Array.isArray(args))) return reply(id, { result: fail('Tool arguments must be an object.') });
703
+ return reply(id, { result: t.run(args || {}) });
348
704
  }
705
+ // Some clients ask for these even when only tools are offered; an empty list is the polite answer.
706
+ case 'resources/list': return reply(id, { result: { resources: [] } });
707
+ case 'resources/templates/list': return reply(id, { result: { resourceTemplates: [] } });
708
+ case 'prompts/list': return reply(id, { result: { prompts: [] } });
709
+ case 'logging/setLevel': return reply(id, { result: {} });
349
710
  default:
350
- if (isRequest) send({ id, error: { code: -32601, message: `Method not found: ${String(method).slice(0, 60)}` } });
711
+ return reply(id, { error: { code: -32601, message: `Method not found: ${method.slice(0, 60)}` } });
351
712
  }
352
713
  } catch (e) {
353
- if (isRequest) send({ id, error: { code: -32603, message: String(e?.message || e) } });
714
+ return reply(id, { error: { code: -32603, message: String(e?.message || e) } });
354
715
  }
355
716
  }
356
717
 
357
- readline.createInterface({ input: process.stdin }).on('line', (line) => {
718
+ const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
719
+ rl.on('line', (line) => {
358
720
  if (!line.trim()) return;
359
721
  let msg;
360
- try { msg = JSON.parse(line); } catch { return send({ id: null, error: { code: -32700, message: 'Parse error' } }); }
361
- handle(msg);
722
+ try { msg = JSON.parse(line); } catch { return write(reply(null, { error: { code: -32700, message: 'Parse error' } })); }
723
+ if (Array.isArray(msg)) { // a batch (older protocol versions): answer with one array
724
+ if (!msg.length) return write(reply(null, { error: { code: -32600, message: 'Invalid request: empty batch' } }));
725
+ const out = msg.map(handle).filter(Boolean);
726
+ if (out.length) write(out);
727
+ return;
728
+ }
729
+ const r = handle(msg);
730
+ if (r) write(r);
362
731
  });
732
+ // the editor closed the connection: exit instead of lingering as an orphan process
733
+ // (flush first: on macOS and Windows, writes to a pipe are asynchronous)
734
+ rl.on('close', () => { if (stdoutClosed) process.exit(0); else process.stdout.write('', () => process.exit(0)); });