@sakaniui/react 0.8.9 → 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.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +41 -0
- package/README.md +29 -3
- package/bin/sakaniui-mcp.mjs +126 -31
- package/dist/icon-index.json +1 -1
- package/dist/lib/ChartTooltip.d.ts +20 -0
- package/dist/lib/LiquidGlass.d.ts +93 -0
- package/dist/lib/SakaniLogo.d.ts +3 -0
- package/dist/lib/chartPalette.d.ts +12 -0
- package/dist/lib/getContrastColor.d.ts +23 -0
- package/dist/lib/iconStrokeWidth.d.ts +13 -0
- package/dist/lib/useChartTokens.d.ts +31 -0
- package/dist/lib/useHoverHighlight.d.ts +17 -0
- package/dist/lib/usePortalThemeClass.d.ts +31 -0
- package/dist/lib/useThemeTick.d.ts +10 -0
- package/dist/manifest.json +212 -32
- package/package.json +4 -4
package/AGENTS.md
CHANGED
|
@@ -16,7 +16,7 @@ import manifest from '@sakaniui/react/manifest.json';
|
|
|
16
16
|
const button = manifest.components.find((c) => c.name === 'Button'); // props[], contract.requiredProps
|
|
17
17
|
```
|
|
18
18
|
Editors can query the same data through the bundled MCP server
|
|
19
|
-
(`npx -y
|
|
19
|
+
(`npx -y @sakaniui/mcp`): `search_components`, `get_component`, `search_icons`,
|
|
20
20
|
`check_code`, `check_usage`, `get_tokens`, `get_guide`, `check_compatibility`, `get_changelog`.
|
|
21
21
|
|
|
22
22
|
**Start from the example, check before you answer.** Every component has `example`: its imports and the
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,47 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@sakaniui/react` are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.8.10
|
|
6
|
+
|
|
7
|
+
Readiness release: everything below was found by testing the package the way a new user, an editor
|
|
8
|
+
and an AI agent would, and every fix is now covered by a check that fails the build.
|
|
9
|
+
|
|
10
|
+
**Installing the library**
|
|
11
|
+
- **Fixed: installing `@sakaniui/react` installed Next.js.** The `geist` and `@fontsource-variable/geist`
|
|
12
|
+
font packages were runtime dependencies, though the library never loads them, and `geist` requires
|
|
13
|
+
Next.js as a peer, so every app (Vite included) got Next.js 16. Both are now dev-only; the only runtime
|
|
14
|
+
dependency is `recharts`. Load Geist yourself if you want it (`@fontsource-variable/geist`); otherwise
|
|
15
|
+
the system font is used, as before.
|
|
16
|
+
- **Fixed: missing type definitions.** `dist/lib/` was left out of the generated `.d.ts` files, so
|
|
17
|
+
`LiquidGlass`, `LiquidBackdrop`, `HoverGroup`, `useHoverHighlight`, `useLiquidGlass`, `getContrastColor`
|
|
18
|
+
and `iconStrokeWidth` had no types: `<LiquidBackdrop />` compiled without its required `src`. The build
|
|
19
|
+
now fails if any relative import in a shipped `.d.ts` does not resolve.
|
|
20
|
+
- **Fixed docs: `style.css` is required.** The README and the website's Installation page only imported
|
|
21
|
+
`tokens.css`, which leaves components unstyled. Both now import both stylesheets.
|
|
22
|
+
|
|
23
|
+
**The MCP server**
|
|
24
|
+
- **New package `@sakaniui/mcp`** (about 190 kB, no dependencies): `npx -y @sakaniui/mcp` starts in
|
|
25
|
+
about 2 s on a first run. Through `@sakaniui/react` it took ~30 s and ~675 MB, long enough for an editor
|
|
26
|
+
to give up. The copy in `@sakaniui/react` still works.
|
|
27
|
+
- **`check_code`: no false positives** on 685 real elements across three apps (it reported 77). Aliased
|
|
28
|
+
and default imports, code inside template strings, native attributes and the non-icon exports of
|
|
29
|
+
`@sakaniui/react/icons` are handled; it still catches every deliberate mistake in the test set.
|
|
30
|
+
- **Versions per prop and per value.** Each prop added after its component has its own `since` (for
|
|
31
|
+
example `Switch.variant`, 0.8.4), and values added later are listed in `valuesSince`. `since` now means
|
|
32
|
+
"first release you could import it", taken from the package's public exports at every release (the
|
|
33
|
+
social icons are 0.8.6, not 0.3.0). `check_code`, `check_usage` and `get_component` flag props and
|
|
34
|
+
values the project's installed version does not have.
|
|
35
|
+
- **Invented props are errors.** A prop that is neither the component's own nor an HTML attribute (React's
|
|
36
|
+
own attribute list now ships in `manifest.json` as `nativeAttributes`) fails `check_code` and
|
|
37
|
+
`check_usage`, with the real prop list and a suggestion (`leftIcon` -> "did you mean leadingIcon?").
|
|
38
|
+
Real HTML attributes (`placeholder`, `href`, `name`...) pass silently; `color` gets a note that it does
|
|
39
|
+
not style the component.
|
|
40
|
+
- **Protocol:** negotiates a supported protocol version, answers `resources/list`, `prompts/list` and
|
|
41
|
+
batches, exits when the editor disconnects (flushing replies first), and survives a closed pipe.
|
|
42
|
+
`get_guide` accepts `query`. Tested on Node 18, 20, 22 and 26, with the official MCP SDK client and the
|
|
43
|
+
MCP Inspector, and documented for Windows (`cmd /c npx ...`).
|
|
44
|
+
- Removed an unresolvable `$schema` link from `manifest.json`.
|
|
45
|
+
|
|
5
46
|
## 0.8.9
|
|
6
47
|
|
|
7
48
|
Stops assistants from inventing code. Found by testing the MCP server in Claude Code: it validated a
|
package/README.md
CHANGED
|
@@ -43,12 +43,33 @@ accepts Lucide or Tabler React icons. See `THIRD_PARTY_NOTICES.md` for the MIT l
|
|
|
43
43
|
npm install @sakaniui/react
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
Then import the
|
|
46
|
+
Then import the two stylesheets once, at your app's entry point (both are required: `tokens.css` defines
|
|
47
|
+
the design tokens, `style.css` styles the components), and use any component from the package root:
|
|
47
48
|
|
|
48
49
|
```tsx
|
|
49
50
|
import '@sakaniui/react/tokens.css';
|
|
51
|
+
import '@sakaniui/react/style.css';
|
|
52
|
+
|
|
53
|
+
import { Button } from '@sakaniui/react';
|
|
54
|
+
|
|
55
|
+
export function Example() {
|
|
56
|
+
return <Button variant="primary">Get started</Button>;
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Requires React 19. Typography is set in **Geist**; load the font yourself if you want it (otherwise the
|
|
61
|
+
system font is used):
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
npm install @fontsource-variable/geist
|
|
50
65
|
```
|
|
51
66
|
|
|
67
|
+
```tsx
|
|
68
|
+
import '@fontsource-variable/geist';
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Charts (`AreaChart`, `BarChart`, ...) use `recharts`, which is installed with the package.
|
|
72
|
+
|
|
52
73
|
> **Building with AI?** `AGENTS.md` (shipped in this package) explains *why* components behave as they do and what to do when nothing fits. Index for agents: https://www.sakaniui.com/llms.txt
|
|
53
74
|
|
|
54
75
|
## Quick start (contributing / browsing the source)
|
|
@@ -108,12 +129,17 @@ up instead of guessing prop names.
|
|
|
108
129
|
```json
|
|
109
130
|
{
|
|
110
131
|
"mcpServers": {
|
|
111
|
-
"sakaniui": { "command": "npx", "args": ["-y", "
|
|
132
|
+
"sakaniui": { "command": "npx", "args": ["-y", "@sakaniui/mcp"] }
|
|
112
133
|
}
|
|
113
134
|
}
|
|
114
135
|
```
|
|
115
136
|
|
|
116
|
-
Claude Code: `claude mcp add sakaniui -- npx -y
|
|
137
|
+
Claude Code: `claude mcp add sakaniui -- npx -y @sakaniui/mcp`
|
|
138
|
+
|
|
139
|
+
On Windows, launch `npx` through `cmd /c` (`"command": "cmd", "args": ["/c", "npx", "-y", "@sakaniui/mcp"]`,
|
|
140
|
+
or `claude mcp add sakaniui -- cmd /c npx -y @sakaniui/mcp`). Needs Node.js 18 or later.
|
|
141
|
+
|
|
142
|
+
The server is its own tiny package, [`@sakaniui/mcp`](https://www.npmjs.com/package/@sakaniui/mcp) (about 1 MB, starts in a couple of seconds; it does not install the component library). The copy bundled in `@sakaniui/react` (`npx -y -p @sakaniui/react sakaniui-mcp`) still works but downloads far more.
|
|
117
143
|
|
|
118
144
|
Tools: `search_components`, `get_component` (with a type-checked `example`), `search_icons`,
|
|
119
145
|
`check_code` (checks a whole snippet: imports, required props, values, icon names), `check_usage`,
|
package/bin/sakaniui-mcp.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
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
|
|
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
|
*
|
|
@@ -47,7 +47,8 @@ function findProjectInstall() {
|
|
|
47
47
|
return null;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
|
|
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'));
|
|
51
52
|
if (!bundled || !bundled.manifest) {
|
|
52
53
|
process.stderr.write('sakaniui-mcp: dist/manifest.json not found. Run `npm run build:lib` (or install a published version).\n');
|
|
53
54
|
process.exit(1);
|
|
@@ -68,6 +69,22 @@ const changelog = active.changelog || bundled.changelog || '';
|
|
|
68
69
|
const iconIndex = (active.icons && active.icons.icons) || (bundled.icons && bundled.icons.icons) || [];
|
|
69
70
|
const projectVersion = project ? project.version : null;
|
|
70
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
|
+
|
|
71
88
|
/* ---------- versions ---------- */
|
|
72
89
|
const SEMVER = /^\d+\.\d+\.\d+/;
|
|
73
90
|
const parse = (v) => String(v).split('-')[0].split('.').map(Number);
|
|
@@ -93,6 +110,15 @@ function inInstalled(c, installed = projectVersion) {
|
|
|
93
110
|
return cmp(c.since, installed) <= 0;
|
|
94
111
|
}
|
|
95
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
|
+
|
|
96
122
|
/* ---------- helpers ---------- */
|
|
97
123
|
const byName = new Map(manifest.components.map((c) => [c.name.toLowerCase(), c]));
|
|
98
124
|
const brief = (c) => {
|
|
@@ -157,6 +183,7 @@ function closest(name) {
|
|
|
157
183
|
/* ---------- icons ---------- */
|
|
158
184
|
const iconNames = new Set();
|
|
159
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)) || []);
|
|
160
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();
|
|
161
188
|
|
|
162
189
|
// The conventional icon for common UI intents, ranked first (tags alone rank e.g. IconHttpDelete above IconTrash for "delete").
|
|
@@ -207,7 +234,7 @@ function searchIcons(query, limit = 12) {
|
|
|
207
234
|
}
|
|
208
235
|
|
|
209
236
|
/** Checks a value given for an icon prop. Returns an error string, or null. */
|
|
210
|
-
function iconProblem(propName, raw, importedElsewhere = new Set()) {
|
|
237
|
+
function iconProblem(propName, raw, importedElsewhere = new Set(), localNames = new Set()) {
|
|
211
238
|
const v = String(raw).trim().replace(/^\{|\}$/g, '').trim();
|
|
212
239
|
if (/^</.test(v)) {
|
|
213
240
|
const tag = (/^<\s*([A-Za-z][\w.]*)/.exec(v) || [])[1] || 'Icon';
|
|
@@ -216,12 +243,15 @@ function iconProblem(propName, raw, importedElsewhere = new Set()) {
|
|
|
216
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.' : ''}`;
|
|
217
244
|
}
|
|
218
245
|
if (!/^[A-Za-z_$][\w$]*$/.test(v)) return null; // an expression we cannot judge
|
|
219
|
-
if (iconNames.has(v) || importedElsewhere.has(v)) return null;
|
|
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;
|
|
220
249
|
const hint = searchIcons(v, 3).map((i) => i.name);
|
|
221
250
|
return `"${v}" is not a SakaniUI icon.${hint.length ? ` Did you mean: ${hint.join(', ')}?` : ''} Use search_icons to find the real name.`;
|
|
222
251
|
}
|
|
223
252
|
|
|
224
|
-
|
|
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)$/;
|
|
225
255
|
|
|
226
256
|
/** Release notes: [{version, body}] newest first, from the CHANGELOG that matches the answering install. */
|
|
227
257
|
function releases() {
|
|
@@ -333,14 +363,38 @@ function classify(expr) {
|
|
|
333
363
|
|
|
334
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)$/;
|
|
335
365
|
|
|
336
|
-
|
|
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);
|
|
337
388
|
lineAt = ((idx) => (_c, pos) => idx(pos))(lineIndex(code));
|
|
338
389
|
const errors = [];
|
|
339
390
|
const warnings = [];
|
|
340
391
|
const imports = parseImports(code);
|
|
341
392
|
const importedFrom = new Map(); // local name -> module
|
|
342
|
-
for (const imp of imports) for (const n of imp.names) importedFrom.set(n.local, imp.from);
|
|
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); }
|
|
343
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);
|
|
344
398
|
|
|
345
399
|
for (const imp of imports) {
|
|
346
400
|
if (!imp.from.startsWith('@sakaniui/react')) continue;
|
|
@@ -355,7 +409,7 @@ function checkCode(code) {
|
|
|
355
409
|
const c = byName.get(n.imported.toLowerCase());
|
|
356
410
|
const isUtility = manifest.utilities?.some((u) => u.name === n.imported);
|
|
357
411
|
if (entry === '@sakaniui/react/icons') {
|
|
358
|
-
if (!iconNames.has(n.imported)) {
|
|
412
|
+
if (!iconNames.has(n.imported) && !iconOtherExports.has(n.imported)) {
|
|
359
413
|
const hint = searchIcons(n.imported, 3).map((i) => i.name);
|
|
360
414
|
errors.push({ line: imp.line, message: `${n.imported} is not exported by @sakaniui/react/icons.${hint.length ? ` Did you mean: ${hint.join(', ')}?` : ''}` });
|
|
361
415
|
}
|
|
@@ -383,6 +437,7 @@ function checkCode(code) {
|
|
|
383
437
|
const c = byName.get(el.tag.toLowerCase());
|
|
384
438
|
if (!c) continue;
|
|
385
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.`));
|
|
386
441
|
if (!importedFrom.has(el.tag)) warnings.push(at(`not imported. Add: import { ${c.name} } from '${c.import}';`));
|
|
387
442
|
const given = new Map(el.attrs.map((a) => [a.name, a]));
|
|
388
443
|
if (!el.selfClosing) given.set('children', { kind: 'expression' });
|
|
@@ -394,16 +449,17 @@ function checkCode(code) {
|
|
|
394
449
|
const known = new Map(c.props.map((p) => [p.name, p]));
|
|
395
450
|
for (const a of el.attrs) {
|
|
396
451
|
const p = known.get(a.name);
|
|
397
|
-
if (!p) {
|
|
452
|
+
if (!p) { const u = unknownProp(c, a.name); if (u) (u.level === 'error' ? errors : warnings).push(at(u.message)); continue; }
|
|
398
453
|
if (p.values && ['string', 'number', 'boolean'].includes(a.kind) && !p.values.includes(a.value)) {
|
|
399
454
|
errors.push(at(`${a.name}=${JSON.stringify(a.value)} is not allowed. Use one of: ${p.values.map((x) => JSON.stringify(x)).join(', ')}.`));
|
|
400
455
|
}
|
|
456
|
+
{ const tooNew = propTooNew(c, p, a.kind === 'string' ? a.value : undefined); if (tooNew) errors.push(at(tooNew)); }
|
|
401
457
|
if (p.type === 'IconComponent') {
|
|
402
458
|
if (a.kind === 'string') errors.push(at(`"${a.name}" takes an icon component, not a string: ${a.name}={IconName}.`));
|
|
403
459
|
else if (a.kind === 'element' || a.kind === 'identifier') {
|
|
404
|
-
const e = iconProblem(a.name, a.raw, importedElsewhere);
|
|
460
|
+
const e = iconProblem(a.name, a.raw, importedElsewhere, localNames);
|
|
405
461
|
if (e) errors.push(at(e));
|
|
406
|
-
else if (a.kind === 'identifier' && iconNames.has(a.value) && importedFrom.
|
|
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';`));
|
|
407
463
|
}
|
|
408
464
|
}
|
|
409
465
|
}
|
|
@@ -449,6 +505,17 @@ const tools = {
|
|
|
449
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.';
|
|
450
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.';
|
|
451
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
|
+
}
|
|
452
519
|
const note = versionNote();
|
|
453
520
|
if (note) out.versionNote = note;
|
|
454
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).`;
|
|
@@ -505,11 +572,12 @@ const tools = {
|
|
|
505
572
|
for (const r of c.contract.requiredProps) if (!Object.hasOwn(props, r)) errors.push(`Missing required prop "${r}".`);
|
|
506
573
|
for (const [k, v] of Object.entries(props)) {
|
|
507
574
|
const p = known.get(k);
|
|
508
|
-
if (!p) {
|
|
575
|
+
if (!p) { const u = unknownProp(c, k); if (u) (u.level === 'error' ? errors : warnings).push(u.message); continue; }
|
|
509
576
|
if (p.values && (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') && !p.values.includes(v)) {
|
|
510
577
|
errors.push(`"${k}" = ${JSON.stringify(v)} is not allowed. Use one of: ${p.values.map((x) => JSON.stringify(x)).join(', ')}.`);
|
|
511
578
|
}
|
|
512
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);
|
|
513
581
|
}
|
|
514
582
|
if (c.contract.requiresAccessibleName && !errors.length) warnings.push('This control has no visible text of its own: keep its accessible-name prop set.');
|
|
515
583
|
const out = { component: c.name, ok: errors.length === 0, errors, warnings };
|
|
@@ -580,9 +648,10 @@ const tools = {
|
|
|
580
648
|
},
|
|
581
649
|
get_guide: {
|
|
582
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.',
|
|
583
|
-
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").' } } },
|
|
584
652
|
run(args) {
|
|
585
|
-
|
|
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);
|
|
586
655
|
if (!guide) return fail('AGENTS.md is not available in this install.');
|
|
587
656
|
const parts = guide.split(/^(?=## )/m).filter((p) => p.startsWith('## '));
|
|
588
657
|
const heads = parts.map((p) => p.split('\n')[0].replace(/^## /, ''));
|
|
@@ -595,45 +664,71 @@ const tools = {
|
|
|
595
664
|
};
|
|
596
665
|
|
|
597
666
|
/* ---------- JSON-RPC over stdio ---------- */
|
|
598
|
-
|
|
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'];
|
|
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 });
|
|
599
674
|
|
|
675
|
+
/** Handles one message. Returns the response, or null for notifications. */
|
|
600
676
|
function handle(msg) {
|
|
601
|
-
if (!msg || typeof msg !== 'object' || Array.isArray(msg)) return
|
|
677
|
+
if (!msg || typeof msg !== 'object' || Array.isArray(msg)) return reply(null, { error: { code: -32600, message: 'Invalid request' } });
|
|
602
678
|
const { id, method, params } = msg;
|
|
603
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
|
|
604
682
|
try {
|
|
605
683
|
switch (method) {
|
|
606
684
|
case 'initialize': {
|
|
607
685
|
const note = versionNote();
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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}` : ''}`,
|
|
613
692
|
} });
|
|
614
693
|
}
|
|
615
694
|
case 'ping':
|
|
616
|
-
return
|
|
695
|
+
return reply(id, { result: {} });
|
|
617
696
|
case 'tools/list':
|
|
618
|
-
return
|
|
697
|
+
return reply(id, { result: { tools: Object.entries(tools).map(([name, t]) => ({ name, description: t.description, inputSchema: t.inputSchema, annotations: READ_ONLY })) } });
|
|
619
698
|
case 'tools/call': {
|
|
620
699
|
const t = typeof params?.name === 'string' && Object.hasOwn(tools, params.name) ? tools[params.name] : null;
|
|
621
|
-
if (!t) return
|
|
700
|
+
if (!t) return reply(id, { error: { code: -32602, message: `Unknown tool: ${String(params?.name).slice(0, 60)}` } });
|
|
622
701
|
const args = params.arguments;
|
|
623
|
-
if (args !== undefined && args !== null && (typeof args !== 'object' || Array.isArray(args))) return
|
|
624
|
-
return
|
|
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 || {}) });
|
|
625
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: {} });
|
|
626
710
|
default:
|
|
627
|
-
|
|
711
|
+
return reply(id, { error: { code: -32601, message: `Method not found: ${method.slice(0, 60)}` } });
|
|
628
712
|
}
|
|
629
713
|
} catch (e) {
|
|
630
|
-
|
|
714
|
+
return reply(id, { error: { code: -32603, message: String(e?.message || e) } });
|
|
631
715
|
}
|
|
632
716
|
}
|
|
633
717
|
|
|
634
|
-
readline.createInterface({ input: process.stdin
|
|
718
|
+
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
719
|
+
rl.on('line', (line) => {
|
|
635
720
|
if (!line.trim()) return;
|
|
636
721
|
let msg;
|
|
637
|
-
try { msg = JSON.parse(line); } catch { return
|
|
638
|
-
|
|
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);
|
|
639
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)); });
|