@sakaniui/react 0.8.11 → 0.8.13

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.
package/AGENTS.md CHANGED
@@ -152,6 +152,12 @@ CSS of a component into your own file.
152
152
 
153
153
  Format: **Use for** · **Why it is built this way** · **Not for / instead**.
154
154
 
155
+ **Looking for a component by another name?** SakaniUI uses its own names. A *dialog* or *alert dialog* is
156
+ `Modal`; a *snackbar* is `Toast`; a *dropdown menu* is `Menu`; a *navbar* or *app bar* is `TopBar`; a
157
+ *date picker* is `Calendar`; a *loader* is `Spinner`; a *toggle* is `Switch`; a *banner* is `Announcement`;
158
+ *autocomplete* is `Combobox`. There is no `Dialog`, `AlertDialog`, `Snackbar` or `Navbar`. Each component
159
+ lists its other names under `aliases` in `manifest.json`, and `search_components` understands them.
160
+
155
161
  ### Actions
156
162
  - **Button** — Use for any action that does something. *Why:* 5 variants × 3 sizes mirror Figma 1:1;
157
163
  `loading` disables interaction so double-submits can't happen. *Not for* navigation → `Link`; icon-only →
package/CHANGELOG.md CHANGED
@@ -2,6 +2,40 @@
2
2
 
3
3
  All notable changes to `@sakaniui/react` are documented here.
4
4
 
5
+ ## 0.8.13
6
+
7
+ A user reported the MCP server "didn't work". Reproduced: on a Node older than 16.9 the server connected
8
+ and listed its tools, then **every tool call failed** with `Object.hasOwn is not a function`.
9
+
10
+ - **Fixed: tool calls failing on older Node.** The server no longer uses `Object.hasOwn` (Node 16.9+) or the
11
+ `node:` import prefix, so it works on Node 16 (tested on 16.20) as well as 18, 20, 22 and 26. Node older
12
+ than 16 now stops with a plain message ("Node.js 14.18.0 is too old... needs Node.js 18 or later") instead
13
+ of failing later. 18+ remains the supported range.
14
+ - **New: `npx -y @sakaniui/mcp --doctor`** prints a report (Node version, platform, whether `npx` is on the
15
+ PATH, the reference data, the version found in your project) and exercises three real tools. Also
16
+ `--version` and `--help`.
17
+ - **The build now guards against this**: it fails if the server uses an API newer than Node 16, and it runs a
18
+ real tool call with `Object.hasOwn` removed from the runtime.
19
+ - **Troubleshooting** added to the README, the package README and sakaniui.com/docs/ai (PATH problems in
20
+ desktop editors, Windows, proxies, stale `npx` caches).
21
+ - Correction: the 0.8.12 notes said "250+" aliases; the number is 249.
22
+
23
+ ## 0.8.12
24
+
25
+ Found while testing 0.8.11 in Claude Code: after writing correct code, the agent offered to wrap the
26
+ delete button in "SakaniUI's Dialog/AlertDialog". Neither exists; the component is `Modal`. It named it
27
+ from memory without looking it up.
28
+
29
+ - **`aliases` in `manifest.json`**: the other names people use for a component, 250+ across 61 components
30
+ (dialog, alert dialog -> `Modal`; snackbar -> `Toast`; navbar, app bar -> `TopBar`; date picker ->
31
+ `Calendar`; loader -> `Spinner`; toggle -> `Switch`; autocomplete -> `Combobox`; dropzone ->
32
+ `FileUpload`; pricing table -> `PricingTableBlock`; ...). Hand-written; the build checks every one
33
+ points at a real component and never shadows a real name.
34
+ - **MCP:** `search_components` ranks by alias ("dialog" finds `Modal` first; a plain exact name such as
35
+ "button" always wins). `get_component`, `check_usage` and `check_code` now say what the name is here:
36
+ *"Dialog is not exported by @sakaniui/react. In SakaniUI that is `Modal` (Confirmation dialog)."*
37
+ - `AGENTS.md` lists the most common renamings.
38
+
5
39
  ## 0.8.11
6
40
 
7
41
  Found by asking Claude Code, with the published 0.8.10 MCP server, to add a delete button to a table row:
package/README.md CHANGED
@@ -155,6 +155,27 @@ Tools: `search_components`, `get_component` (with a type-checked `example`), `se
155
155
  an export that is not described, a prop that is not declared, an example that does not type-check, an
156
156
  icon name that does not exist, a token that is not defined, or anything that looks like a private reference.
157
157
 
158
+ ### Troubleshooting the MCP server
159
+
160
+ Run this in a terminal first. It checks Node, the data, the tools and your project, and prints a report you can
161
+ paste when asking for help:
162
+
163
+ ```bash
164
+ npx -y @sakaniui/mcp --doctor
165
+ ```
166
+
167
+ | Symptom | Fix |
168
+ |---|---|
169
+ | `spawn npx ENOENT`, or the editor cannot start it | Desktop editors often start with a smaller `PATH` than your terminal (common with nvm and Homebrew). Use the full path: run `which npx` (macOS/Linux) or `where npx` (Windows) and put that in `"command"`, e.g. `"/opt/homebrew/bin/npx"`. |
170
+ | Windows | `npx` is a `.cmd` script: use `"command": "cmd"` with `"args": ["/c", "npx", "-y", "@sakaniui/mcp"]`. |
171
+ | Connects, but tool calls fail (before 0.8.13) | Node older than 16.9. Update Node (18 or later is supported), or use `@sakaniui/mcp@latest`. |
172
+ | "Node.js ... is too old" | Install Node 18 or later: https://nodejs.org (check with `node --version`). |
173
+ | Times out on the first start | The first run downloads about 200 kB. Run `npx -y @sakaniui/mcp --version` once in a terminal to warm the cache, then restart the editor. |
174
+ | Behind a company proxy or private npm registry | The machine must reach `registry.npmjs.org` (check `npm config get registry`) and `@sakaniui/*` packages must be allowed. |
175
+ | Odd `npx` errors, or an old version keeps running | Delete the `_npx` folder inside your npm cache (find it with `npm config get cache`), then restart the editor. |
176
+ | Added it but no tools appear | Restart the editor. In Claude Code run `/mcp` and look for `sakaniui` with 10 tools. |
177
+ | `Failed to connect` after typing the command by hand | Check the package name for a full-width `@` or stray characters; copy the command from here instead. |
178
+
158
179
  ## Glass & liquid glass
159
180
 
160
181
  Glass is built into the theme system, not added component by component. Next to light/dark, SakaniUI has a second axis — **Surface** — with three modes: **Solid** (the default), **Glass** (frosted translucency) and **Liquid** (Apple-style liquid glass: the backdrop *bends* at the edges like a lens, with a faint color fringe, rim light and depth). Components read a small set of `--surface-*` tokens, so switching an area from Solid to Glass or Liquid re-themes everything inside it at once — in the Figma file (variable modes and effect styles) and in code. Solid stays pixel-identical to before.
@@ -17,10 +17,21 @@
17
17
  * manifest era, answers come from this copy but every component is checked against its `since`
18
18
  * release and flagged when the project does not have it yet.
19
19
  */
20
- import fs from 'node:fs';
21
- import path from 'node:path';
22
- import readline from 'node:readline';
23
- import { fileURLToPath } from 'node:url';
20
+ import fs from 'fs';
21
+ import path from 'path';
22
+ import readline from 'readline';
23
+ import { fileURLToPath } from 'url';
24
+
25
+ /* ---------- Node version guard ---------- */
26
+ // Tested on Node 16, 18, 20, 22 and 26; 18+ is the supported range. Say so plainly instead of failing later in a way
27
+ // that looks like the server is broken (before 0.8.13 a Node older than 16.9 connected, listed its tools, then failed every call).
28
+ const NODE_MAJOR = Number(process.versions.node.split('.')[0]);
29
+ if (NODE_MAJOR < 16) {
30
+ process.stderr.write(`sakaniui-mcp: Node.js ${process.versions.node} is too old. SakaniUI's MCP server needs Node.js 18 or later (https://nodejs.org). Run \`node --version\` to check.\n`);
31
+ process.exit(1);
32
+ }
33
+ // `Object.hasOwn` is Node 16.9+; this works everywhere
34
+ const has = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
24
35
 
25
36
  const ownRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
26
37
 
@@ -48,6 +59,21 @@ function findProjectInstall() {
48
59
  }
49
60
 
50
61
  // inside @sakaniui/react the data sits next to bin/; inside @sakaniui/mcp it is copied into data/
62
+ /* ---------- command line: --version, --help, --doctor ---------- */
63
+ const argvFlag = (...names) => process.argv.slice(2).some((a) => names.includes(a));
64
+ if (argvFlag('--help', '-h')) {
65
+ process.stdout.write(`sakaniui-mcp: the SakaniUI MCP server (read-only). Your editor starts it; there is nothing to type.
66
+
67
+ npx -y @sakaniui/mcp start the server (waits silently for an editor)
68
+ npx -y @sakaniui/mcp --doctor check this machine and print a report (paste it when asking for help)
69
+ npx -y @sakaniui/mcp --version print the version
70
+ npx -y @sakaniui/mcp --project <dir> answer for the @sakaniui/react installed in <dir>
71
+
72
+ Docs: https://www.sakaniui.com/docs/ai
73
+ `);
74
+ process.exit(0);
75
+ }
76
+
51
77
  const bundled = loadInstall(fs.existsSync(path.join(ownRoot, 'dist', 'manifest.json')) ? ownRoot : path.join(ownRoot, 'data'));
52
78
  if (!bundled || !bundled.manifest) {
53
79
  process.stderr.write('sakaniui-mcp: dist/manifest.json not found. Run `npm run build:lib` (or install a published version).\n');
@@ -121,6 +147,22 @@ function propTooNew(c, p, value, installed = projectVersion) {
121
147
 
122
148
  /* ---------- helpers ---------- */
123
149
  const byName = new Map(manifest.components.map((c) => [c.name.toLowerCase(), c]));
150
+ // Common names for components ("Dialog" -> Modal). Only used when a name is not found, and to rank search results.
151
+ const norm = (x) => String(x).toLowerCase().replace(/[^a-z0-9]/g, '');
152
+ const aliasIndex = new Map(); // normalized alias -> components
153
+ for (const c of manifest.components) for (const a of c.aliases || []) { const k = norm(a); if (!aliasIndex.has(k)) aliasIndex.set(k, []); aliasIndex.get(k).push(c); }
154
+ /** The real component(s) for a name that does not exist: "Dialog" -> [Modal]. */
155
+ function byAlias(name) {
156
+ const k = norm(name);
157
+ const exact = aliasIndex.get(k) || aliasIndex.get(k.replace(/s$/, '')) || [];
158
+ return [...new Set(exact)].slice(0, 4);
159
+ }
160
+ const aliasHint = (name) => {
161
+ const hits = byAlias(name);
162
+ if (!hits.length) return '';
163
+ return ` ${hits.length === 1 ? `In SakaniUI that is \`${hits[0].name}\` (${hits[0].summary.replace(/\.$/, '')})` : `In SakaniUI that could be ${hits.map((h) => `\`${h.name}\``).join(' or ')}`}.`;
164
+ };
165
+
124
166
  const brief = (c) => {
125
167
  const out = { name: c.name, kind: c.kind, category: c.category, import: c.import, since: c.since, summary: c.summary, requiredProps: c.contract.requiredProps };
126
168
  if (inInstalled(c) === false) out.availableInInstalled = false;
@@ -150,9 +192,19 @@ const verArg = (args, key) => {
150
192
  };
151
193
  const firstError = (...rs) => rs.find((r) => r.err);
152
194
 
153
- function score(c, terms) {
195
+ function score(c, terms, phrase = '') {
154
196
  const hay = `${c.name} ${c.category ?? ''} ${c.summary} ${c.props.map((p) => p.name).join(' ')}`.toLowerCase();
155
197
  let s = 0;
198
+ // an alias is the name people use for this component: "dialog" finds Modal, "snackbar" finds Toast
199
+ const p = norm(phrase);
200
+ let aliasWords = 0;
201
+ for (const a of c.aliases || []) {
202
+ const na = norm(a);
203
+ if (na === p || na + 's' === p || na === p + 's') s += 60;
204
+ else if (na.length >= 4 && p.includes(na)) s += 25;
205
+ else if (!aliasWords && terms.length && a.toLowerCase().split(/[^a-z0-9]+/).some((w) => w.length >= 3 && terms.includes(w))) { s += 6; aliasWords = 1; } // loosely related: counts once
206
+ }
207
+ if (p && norm(c.name) === p) s += 100; // the real name always wins
156
208
  for (const t of terms) {
157
209
  if (c.name.toLowerCase() === t) s += 20;
158
210
  else if (c.name.toLowerCase().includes(t)) s += 8;
@@ -434,7 +486,7 @@ function checkCode(source) {
434
486
  if (!c && !isUtility) {
435
487
  if (TYPE_LIKE.test(n.imported)) continue; // most likely a type export
436
488
  const near = closest(n.imported);
437
- errors.push({ line: imp.line, message: `${n.imported} is not exported by @sakaniui/react.${near.length ? ` Did you mean: ${near.join(', ')}?` : ''} Use search_components.` });
489
+ errors.push({ line: imp.line, message: `${n.imported} is not exported by @sakaniui/react.${aliasHint(n.imported)}${near.length ? ` Did you mean: ${near.join(', ')}?` : ''} Use search_components.` });
438
490
  }
439
491
  if (c && inInstalled(c) === false) errors.push({ line: imp.line, message: `${c.name} was added in ${c.since}; the project has ${projectVersion}.` });
440
492
  }
@@ -578,7 +630,7 @@ const tools = {
578
630
  const terms = q.value.toLowerCase().split(/\s+/).filter(Boolean);
579
631
  const hits = manifest.components
580
632
  .filter((c) => !k.value || c.kind === k.value)
581
- .map((c) => [score(c, terms), c])
633
+ .map((c) => [score(c, terms, q.value), c])
582
634
  .filter(([s]) => s > 0)
583
635
  .sort((a, b) => b[0] - a[0])
584
636
  .slice(0, limit)
@@ -594,7 +646,7 @@ const tools = {
594
646
  run(args) {
595
647
  const n = strArg(args, 'name', { required: true }); if (n.err) return fail(n.err);
596
648
  const c = byName.get(n.value.toLowerCase());
597
- if (!c) return fail(`No component named "${n.value}". Did you mean: ${closest(n.value).join(', ') || '(none close)'}? Use search_components.`);
649
+ if (!c) return fail(`No component named "${n.value}".${aliasHint(n.value)} ${closest(n.value).length ? `Did you mean: ${closest(n.value).join(', ')}? ` : ''}Use search_components.`.replace(/\s+/g, ' '));
598
650
  const out = { ...c, note: 'Native HTML attributes (className, style, aria-*, event handlers) are also accepted on the root element.' };
599
651
  if (c.props.some((p) => p.shape)) out.shapeNote = 'A prop with a `shape` takes an object (or an array of objects) with exactly those fields; `required` fields must be present. In a generic shape, `keyof T & string` means the value must be a field of your own data type.';
600
652
  if (c.recipes && c.recipes.length) out.recipeNote = 'See `recipes`: patterns the types alone do not teach, compiled against this release.';
@@ -640,7 +692,7 @@ const tools = {
640
692
  const g = strArg(args, 'group'); const q = strArg(args, 'query');
641
693
  const e = firstError(g, q); if (e) return fail(e.err);
642
694
  const groups = manifest.tokens.groups;
643
- if (g.value && !Object.hasOwn(groups, g.value)) return fail(`Unknown token group "${g.value}". Groups: ${Object.keys(groups).join(', ')}`);
695
+ if (g.value && !has(groups, g.value)) return fail(`Unknown token group "${g.value}". Groups: ${Object.keys(groups).join(', ')}`);
644
696
  const needle = q.value?.toLowerCase();
645
697
  const out = {};
646
698
  for (const [name, toks] of Object.entries(groups)) {
@@ -660,12 +712,12 @@ const tools = {
660
712
  if (typeof props !== 'object' || Array.isArray(props)) return fail('"props" must be an object of prop name -> value.');
661
713
  if (Object.keys(props).length > 200) return fail('"props" has too many entries (max 200).');
662
714
  const c = byName.get(n.value.toLowerCase());
663
- if (!c) return fail(`No component named "${n.value}". Did you mean: ${closest(n.value).join(', ') || '(none close)'}?`);
715
+ if (!c) return fail(`No component named "${n.value}".${aliasHint(n.value)} ${closest(n.value).length ? `Did you mean: ${closest(n.value).join(', ')}?` : ''}`.replace(/\s+/g, ' ').trim());
664
716
  const known = new Map(c.props.map((p) => [p.name, p]));
665
717
  const errors = [];
666
718
  const warnings = [];
667
719
  if (inInstalled(c) === false) errors.push(`${c.name} was added in ${c.since}; the project has ${projectVersion}. It does not exist there.`);
668
- for (const r of c.contract.requiredProps) if (!Object.hasOwn(props, r)) errors.push(`Missing required prop "${r}".`);
720
+ for (const r of c.contract.requiredProps) if (!has(props, r)) errors.push(`Missing required prop "${r}".`);
669
721
  for (const [k, v] of Object.entries(props)) {
670
722
  const p = known.get(k);
671
723
  if (!p) { const u = unknownProp(c, k); if (u) (u.level === 'error' ? errors : warnings).push(u.message); continue; }
@@ -783,8 +835,8 @@ function handle(msg) {
783
835
  return reply(id, { result: {
784
836
  protocolVersion: asked && PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0],
785
837
  capabilities: { tools: { listChanged: false } },
786
- serverInfo: { name: 'sakaniui', title: 'SakaniUI', version: refVersion },
787
- 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}` : ''}`,
838
+ serverInfo: { name: 'sakaniui', title: 'SakaniUI', version: bundled.version }, // the server's own version, not the data's
839
+ 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); search by the name you would normally use (dialog, snackbar, navbar, date picker) and it maps to the real component; 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}` : ''}`,
788
840
  } });
789
841
  }
790
842
  case 'ping':
@@ -792,7 +844,7 @@ function handle(msg) {
792
844
  case 'tools/list':
793
845
  return reply(id, { result: { tools: Object.entries(tools).map(([name, t]) => ({ name, description: t.description, inputSchema: t.inputSchema, annotations: READ_ONLY })) } });
794
846
  case 'tools/call': {
795
- const t = typeof params?.name === 'string' && Object.hasOwn(tools, params.name) ? tools[params.name] : null;
847
+ const t = typeof params?.name === 'string' && has(tools, params.name) ? tools[params.name] : null;
796
848
  if (!t) return reply(id, { error: { code: -32602, message: `Unknown tool: ${String(params?.name).slice(0, 60)}` } });
797
849
  const args = params.arguments;
798
850
  if (args !== undefined && args !== null && (typeof args !== 'object' || Array.isArray(args))) return reply(id, { result: fail('Tool arguments must be an object.') });
@@ -811,6 +863,32 @@ function handle(msg) {
811
863
  }
812
864
  }
813
865
 
866
+ /* ---------- --version and --doctor (no protocol traffic; print and exit) ---------- */
867
+ if (argvFlag('--version', '-v')) { process.stdout.write(`${bundled.version}\n`); process.exit(0); }
868
+ if (argvFlag('--doctor')) {
869
+ const lines = [];
870
+ const row = (label, value, ok) => lines.push(` ${ok === false ? '✘' : ok === 'warn' ? '!' : ok === true ? '✔' : ' '} ${label.padEnd(20)}${value}`);
871
+ row('server version', bundled.version, true);
872
+ row('Node.js', NODE_MAJOR >= 18 ? `${process.version}` : `${process.version} (18+ is supported; 16 works but is end-of-life, please update)`, NODE_MAJOR >= 18 ? true : 'warn');
873
+ row('platform', `${process.platform} ${process.arch}`);
874
+ row('working folder', process.cwd());
875
+ row('reference data', `${manifest.components.length} components and blocks, ${Object.values(manifest.tokens.groups).reduce((a, g) => a + Object.keys(g).length, 0)} tokens, ${iconIndex.length} icons, describing @sakaniui/react ${refVersion} (${answeringFromProject ? "taken from your project's install" : 'bundled with this package'})`, manifest.components.length > 100 && iconIndex.length > 1000);
876
+ row('your project', project ? `@sakaniui/react ${project.version} at ${project.root}` : 'no @sakaniui/react found from this folder (fine for a quick check; run it from your project folder, or pass --project <dir>, to answer for your version)');
877
+ const exe = process.platform === 'win32' ? ['npx.cmd', 'npx.exe', 'npx'] : ['npx'];
878
+ const onPath = (process.env.PATH || '').split(path.delimiter).find((d) => d && exe.some((e) => fs.existsSync(path.join(d, e))));
879
+ row('npx on PATH', onPath ? onPath : 'not found on this PATH', !!onPath);
880
+ // exercise the real tools, the same code paths an editor calls
881
+ const probe = (name, args) => { try { const r = tools[name].run(args); return r.isError ? { ok: false, text: r.content[0].text } : { ok: true, text: r.content[0].text }; } catch (e) { return { ok: false, text: String(e && e.message || e) }; } };
882
+ const s1 = probe('search_components', { query: 'dialog' });
883
+ row('tool: search', s1.ok && /Modal/.test(s1.text) ? 'search_components("dialog") finds Modal' : `FAILED: ${s1.text.slice(0, 120)}`, s1.ok && /Modal/.test(s1.text));
884
+ const s2 = probe('check_code', { code: "import { Button } from '@sakaniui/react';\n<Button variant=\"danger\">x</Button>" });
885
+ row('tool: check_code', s2.ok && /not allowed/.test(s2.text) ? 'catches a wrong prop value' : `FAILED: ${s2.text.slice(0, 120)}`, s2.ok && /not allowed/.test(s2.text));
886
+ const s3 = probe('get_tokens', { group: 'radius' });
887
+ row('tool: get_tokens', s3.ok ? 'ok' : `FAILED: ${s3.text.slice(0, 120)}`, s3.ok);
888
+ process.stdout.write(`SakaniUI MCP doctor\n${lines.join('\n')}\n\nIf your editor still cannot start it: the editor may use a different PATH than this terminal\n(common with nvm and with desktop apps; on Windows use "cmd" with args ["/c","npx","-y","@sakaniui/mcp"]).\nPaste this report when asking for help: https://github.com/samzydd/Sakaniui/issues\n`);
889
+ process.exit(lines.some((l) => l.includes('✘')) ? 1 : 0);
890
+ }
891
+
814
892
  const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
815
893
  rl.on('line', (line) => {
816
894
  if (!line.trim()) return;