@grampro/kit 2.4.3 → 2.4.4
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/README.md +12 -0
- package/index.cjs +8 -1
- package/package.json +1 -1
- package/source/components/accordion/passport.json +1 -1
- package/source/components/alert/passport.json +1 -1
- package/source/components/avatar/passport.json +1 -1
- package/source/components/badge/passport.json +1 -1
- package/source/components/breadcrumb/passport.json +1 -1
- package/source/components/button/passport.json +1 -1
- package/source/components/card/passport.json +1 -1
- package/source/components/checkbox/passport.json +1 -1
- package/source/components/combobox/passport.json +1 -1
- package/source/components/data-grid/passport.json +1 -1
- package/source/components/date-picker/passport.json +1 -1
- package/source/components/dialog/passport.json +1 -1
- package/source/components/file-uploader/passport.json +1 -1
- package/source/components/input/passport.json +1 -1
- package/source/components/menu/passport.json +1 -1
- package/source/components/modal/passport.json +1 -1
- package/source/components/number-input/passport.json +1 -1
- package/source/components/popover/passport.json +1 -1
- package/source/components/progress/passport.json +1 -1
- package/source/components/radio-group/passport.json +1 -1
- package/source/components/skeleton/passport.json +1 -1
- package/source/components/spinner/passport.json +1 -1
- package/source/components/switch/passport.json +1 -1
- package/source/components/tabs/passport.json +1 -1
- package/source/components/textarea/passport.json +1 -1
- package/source/components/toaster/passport.json +1 -1
- package/source/components/tooltip/passport.json +1 -1
- package/tools/mcp/README.md +132 -0
- package/tools/mcp/figma.cjs +388 -0
- package/tools/mcp/server.cjs +458 -0
package/README.md
CHANGED
|
@@ -90,6 +90,18 @@ In this repository:
|
|
|
90
90
|
| [`docs/grid-agent-evaluation.md`](docs/grid-agent-evaluation.md) | the 230-case corpus and how a model gets measured against it |
|
|
91
91
|
| [`docs/agent-native-architecture.md`](docs/agent-native-architecture.md) | where this is going, and which parts are still proposal |
|
|
92
92
|
|
|
93
|
+
## MCP server (beta)
|
|
94
|
+
|
|
95
|
+
For agents that speak MCP, the catalogue is also served on demand:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
claude mcp add grampro -- npx -y @grampro/kit mcp
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
It reads a Figma design, installs what it needs, answers what exists, each component's full contract, working examples, and
|
|
102
|
+
whether the JSX an agent just wrote actually matches the component it used.
|
|
103
|
+
No dependencies, no configuration. See `tools/mcp/README.md`.
|
|
104
|
+
|
|
93
105
|
## Agent skill
|
|
94
106
|
|
|
95
107
|
Install the skill once per project so a coding agent uses these components
|
package/index.cjs
CHANGED
|
@@ -1171,7 +1171,7 @@ const validateComponents = (components, beta = false) => {
|
|
|
1171
1171
|
* projects a week install this; breaking their scripts to tidy an argument
|
|
1172
1172
|
* parser is not a trade worth making.
|
|
1173
1173
|
*/
|
|
1174
|
-
const COMMANDS = new Set(["add", "list", "skill", "passport"]);
|
|
1174
|
+
const COMMANDS = new Set(["add", "list", "skill", "passport", "mcp"]);
|
|
1175
1175
|
|
|
1176
1176
|
/**
|
|
1177
1177
|
* Flags that take a separate value, so the token after one is that value and
|
|
@@ -1274,6 +1274,7 @@ const main = async () => {
|
|
|
1274
1274
|
.example("$0 passport --check", "Fail if a passport is stale (CI)")
|
|
1275
1275
|
.example("$0 add Select --legacy", "Install from the 1.x set")
|
|
1276
1276
|
.example("$0 add Button --dest app/ui", "Choose where they go")
|
|
1277
|
+
.example("$0 mcp", "Serve the catalogue to a coding agent over MCP")
|
|
1277
1278
|
.example("$0 -i", "Interactive selection")
|
|
1278
1279
|
.help().argv;
|
|
1279
1280
|
|
|
@@ -1291,6 +1292,12 @@ const main = async () => {
|
|
|
1291
1292
|
);
|
|
1292
1293
|
}
|
|
1293
1294
|
|
|
1295
|
+
if (parsed && parsed.command === "mcp") {
|
|
1296
|
+
// Speaks JSON-RPC on stdout; nothing else may write there.
|
|
1297
|
+
require("./tools/mcp/server.cjs").main();
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1294
1301
|
if (parsed) {
|
|
1295
1302
|
if (parsed.command === "list") argv.list = true;
|
|
1296
1303
|
if (parsed.command === "skill") argv.skill = true;
|
package/package.json
CHANGED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# GramproKit MCP server — beta
|
|
2
|
+
|
|
3
|
+
Serves the component catalogue to a coding agent, so it writes against the
|
|
4
|
+
real contract instead of recalling one.
|
|
5
|
+
|
|
6
|
+
> **Beta.** The tools work and are tested, but their names and shapes may
|
|
7
|
+
> change before this settles. The Figma token handling in particular is not
|
|
8
|
+
> where it should be — see *Credentials* below.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
claude mcp add grampro -- npx -y @grampro/kit mcp
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Codex, Cursor and anything else that speaks MCP take the same command; it is
|
|
15
|
+
a stdio server with no configuration. Without the CLI, write `.mcp.json` in
|
|
16
|
+
your project:
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
{
|
|
20
|
+
"mcpServers": {
|
|
21
|
+
"grampro": {
|
|
22
|
+
"command": "node",
|
|
23
|
+
"args": ["node_modules/@grampro/kit/index.cjs", "mcp"]
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
MCP servers are not invoked by name. There is no `/grampro`; the model calls
|
|
30
|
+
the tools itself when they are relevant. Use `/mcp` to check the server is
|
|
31
|
+
connected.
|
|
32
|
+
|
|
33
|
+
## Why
|
|
34
|
+
|
|
35
|
+
An agent asked to build a page has two ways to learn a component's API: read
|
|
36
|
+
the source, which costs a great deal of context and still invites invention,
|
|
37
|
+
or be told. This tells it — one component at a time, from the passport that
|
|
38
|
+
was generated from the source and so cannot drift from it.
|
|
39
|
+
|
|
40
|
+
The catalogue is 280 KB of passports. A page needs two or three components,
|
|
41
|
+
which is a few KB. That difference is the whole argument for a server over a
|
|
42
|
+
document pasted into the prompt.
|
|
43
|
+
|
|
44
|
+
## Tools
|
|
45
|
+
|
|
46
|
+
| tool | what it answers |
|
|
47
|
+
| --- | --- |
|
|
48
|
+
| `list_components` | What exists, and what each one is for |
|
|
49
|
+
| `get_component` | Props with types and allowed values, which are required, handler signatures, slots, states, composition rules |
|
|
50
|
+
| `get_example` | Working usage from the component's README |
|
|
51
|
+
| `get_guidance` | Authored notes on forms, overlays, the grid, pickers, styling, accessibility |
|
|
52
|
+
| `figma_frame` | A design's structure, text, repeated regions and theme variables |
|
|
53
|
+
| `install_component` | Copies components into the project being worked in |
|
|
54
|
+
| `validate_usage` | Whether the JSX an agent wrote matches the passports |
|
|
55
|
+
|
|
56
|
+
`validate_usage` is the one that earns its place. In the experiment that
|
|
57
|
+
justified building this, a page written from passports alone compiled on the
|
|
58
|
+
first attempt apart from a single prop the model had remembered from
|
|
59
|
+
elsewhere rather than read here. Every type, union value and handler
|
|
60
|
+
signature the passport carried came out right. So the remaining failure is
|
|
61
|
+
invention, and this catches it:
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
<Alert variant="info" title="Hi" dismissible onDismiss={…} />
|
|
65
|
+
|
|
66
|
+
dismissible — not a prop of this component. Did you mean: onDismiss
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## From a Figma design
|
|
70
|
+
|
|
71
|
+
Two routes, and the second needs no credentials.
|
|
72
|
+
|
|
73
|
+
**By URL.** Pass a `figma.com/design/...` link. Called with just the URL it
|
|
74
|
+
lists the frames in the file; called again with `frame` it returns that
|
|
75
|
+
screen. Needs a token.
|
|
76
|
+
|
|
77
|
+
**By plugin export.** In Figma, run **Grampro Kit → Export selection for an
|
|
78
|
+
agent**, then pass the JSON as `design`. The plugin reads the scene graph
|
|
79
|
+
directly, so this works on any file you can open and asks for no token at
|
|
80
|
+
all.
|
|
81
|
+
|
|
82
|
+
Either way the answer is the same shape: the structure, the text, the
|
|
83
|
+
regions whose names repeat — eight `Status cell` nodes are a column — and
|
|
84
|
+
the `--gbs-*` variables the design's own colours and radii map onto.
|
|
85
|
+
|
|
86
|
+
It does not decide which component each region is. A design drawn without
|
|
87
|
+
the component library has no component instances to look up, so that call is
|
|
88
|
+
inference, and an agent holding the catalogue and the designer's own names
|
|
89
|
+
infers better than string matching in a tool. What is not a kit component is
|
|
90
|
+
reported as such rather than approximated.
|
|
91
|
+
|
|
92
|
+
Colours are given roles rather than ranked. The most-used colour in a design
|
|
93
|
+
is the body text, and a near-black navy scores high on saturation, so both
|
|
94
|
+
"most common" and "most saturated" pick the wrong thing. Lightness and
|
|
95
|
+
saturation together find the accent.
|
|
96
|
+
|
|
97
|
+
## Credentials
|
|
98
|
+
|
|
99
|
+
**This is the weakest part of the current design, and it is temporary.**
|
|
100
|
+
|
|
101
|
+
The URL route reads `FIGMA_TOKEN` from the environment, or from a `.env`
|
|
102
|
+
file beside the project. That means a long-lived credential sitting in a
|
|
103
|
+
file, which is not where it belongs.
|
|
104
|
+
|
|
105
|
+
A future release will keep it in the editor's own secret storage — VS Code's
|
|
106
|
+
`SecretStorage` for the extension, and the equivalent elsewhere — so the
|
|
107
|
+
token is held by the host rather than the repository. Until then:
|
|
108
|
+
|
|
109
|
+
- prefer the plugin export route, which needs no token at all;
|
|
110
|
+
- if you do use a token, give it only `File content: read`;
|
|
111
|
+
- keep `.env` out of version control.
|
|
112
|
+
|
|
113
|
+
## Suggested use
|
|
114
|
+
|
|
115
|
+
Tell the agent to call `get_component` before writing JSX for a component,
|
|
116
|
+
and `validate_usage` on what it wrote before reporting back. Both are cheap,
|
|
117
|
+
and together they close the gap between code that looks right and code that
|
|
118
|
+
compiles.
|
|
119
|
+
|
|
120
|
+
## Notes
|
|
121
|
+
|
|
122
|
+
No dependencies. MCP is newline-delimited JSON-RPC over stdio, which is a
|
|
123
|
+
few hundred lines, and a server shipped inside a library that every consumer
|
|
124
|
+
installs should not drag an SDK along with it.
|
|
125
|
+
|
|
126
|
+
The catalogue it serves is the one in this package, not the components
|
|
127
|
+
copied into your project — so an agent can see a component before you
|
|
128
|
+
install it, and `install_component` puts it in place.
|
|
129
|
+
|
|
130
|
+
A Figma file is fetched once and held for five minutes. The natural flow is
|
|
131
|
+
two calls, list then read, and refetching a large file in between is what
|
|
132
|
+
earns a rate limit.
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Read a Figma design through the REST API and condense it to what a coding
|
|
3
|
+
* agent can act on.
|
|
4
|
+
*
|
|
5
|
+
* Written against a real response rather than the documentation: the field
|
|
6
|
+
* names here were confirmed by fetching a live file, and they match the
|
|
7
|
+
* plugin API's scene graph exactly — `layoutMode`, `itemSpacing`, `padding*`,
|
|
8
|
+
* `cornerRadius`, `fills`, `characters`, `primaryAxisAlignItems`.
|
|
9
|
+
*
|
|
10
|
+
* What it deliberately does not do is decide which component each region is.
|
|
11
|
+
* A design drawn without the component library has no `componentId` to look
|
|
12
|
+
* up, so that call is inference — and the agent, holding the catalogue and
|
|
13
|
+
* the designer's own names for things, infers better than string matching in
|
|
14
|
+
* a tool. This supplies structure, names, text and tokens; the agent decides.
|
|
15
|
+
*/
|
|
16
|
+
"use strict";
|
|
17
|
+
|
|
18
|
+
const fs = require("fs");
|
|
19
|
+
const path = require("path");
|
|
20
|
+
|
|
21
|
+
/** Figma's own limit is generous; a frame beyond this is unreadable anyway. */
|
|
22
|
+
const MAX_DEPTH = 10;
|
|
23
|
+
|
|
24
|
+
// ------------------------------------------------------------------ token
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The token, from the environment or a .env beside the project.
|
|
28
|
+
*
|
|
29
|
+
* MCP clients pass environment through unevenly, so a .env is the fallback
|
|
30
|
+
* that works everywhere. It is read, never logged.
|
|
31
|
+
*/
|
|
32
|
+
function figmaToken() {
|
|
33
|
+
if (process.env.FIGMA_TOKEN) return process.env.FIGMA_TOKEN.trim();
|
|
34
|
+
for (const dir of [process.cwd(), path.join(__dirname, "..", "..")]) {
|
|
35
|
+
const file = path.join(dir, ".env");
|
|
36
|
+
if (!fs.existsSync(file)) continue;
|
|
37
|
+
const match = fs.readFileSync(file, "utf8").match(/^\s*FIGMA_TOKEN\s*=\s*(.+)$/m);
|
|
38
|
+
if (match) return match[1].trim().replace(/^["']|["']$/g, "");
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** figma.com/design/<key>/<name>?node-id=1-23 → { fileKey, nodeId } */
|
|
44
|
+
function parseFigmaUrl(url) {
|
|
45
|
+
const key = String(url).match(/figma\.com\/(?:file|design|proto)\/([A-Za-z0-9]+)/);
|
|
46
|
+
if (!key) return { error: "That does not look like a Figma file URL." };
|
|
47
|
+
const node = String(url).match(/[?&]node-id=([0-9]+[-:][0-9]+)/);
|
|
48
|
+
return { fileKey: key[1], nodeId: node ? node[1].replace("-", ":") : null };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/*
|
|
52
|
+
* A file is fetched once and held briefly.
|
|
53
|
+
*
|
|
54
|
+
* The natural flow is two calls — list the frames, then read one — and a
|
|
55
|
+
* file of any size is hundreds of KB. Fetching it twice in a row is what
|
|
56
|
+
* earns a 429, which is how this cache came to exist.
|
|
57
|
+
*/
|
|
58
|
+
const CACHE_MS = 5 * 60 * 1000;
|
|
59
|
+
const cache = new Map();
|
|
60
|
+
|
|
61
|
+
async function figmaGet(pathname, token) {
|
|
62
|
+
const hit = cache.get(pathname);
|
|
63
|
+
if (hit && Date.now() - hit.at < CACHE_MS) return hit.body;
|
|
64
|
+
|
|
65
|
+
const res = await fetch(`https://api.figma.com/v1${pathname}`, {
|
|
66
|
+
headers: { "X-Figma-Token": token },
|
|
67
|
+
});
|
|
68
|
+
if (!res.ok) {
|
|
69
|
+
const body = await res.text();
|
|
70
|
+
if (res.status === 403) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
"Figma refused the token. Check FIGMA_TOKEN has 'File content: read' scope and can see this file.",
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
if (res.status === 429) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
"Figma is rate limiting this token. Wait a minute and try again; the file is cached for five minutes once it arrives.",
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
throw new Error(`Figma returned ${res.status}: ${body.slice(0, 160)}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const body = await res.json();
|
|
84
|
+
cache.set(pathname, { at: Date.now(), body });
|
|
85
|
+
return body;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ------------------------------------------------------------- condensing
|
|
89
|
+
|
|
90
|
+
const hex = (c) => Math.round(c * 255).toString(16).padStart(2, "0");
|
|
91
|
+
|
|
92
|
+
function solidFill(node) {
|
|
93
|
+
const fill = (node.fills || []).find((f) => f.type === "SOLID" && f.visible !== false);
|
|
94
|
+
return fill ? `#${hex(fill.color.r)}${hex(fill.color.g)}${hex(fill.color.b)}` : undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function layoutOf(node) {
|
|
98
|
+
if (!node.layoutMode || node.layoutMode === "NONE") return undefined;
|
|
99
|
+
const pad = [node.paddingTop, node.paddingRight, node.paddingBottom, node.paddingLeft];
|
|
100
|
+
return {
|
|
101
|
+
direction: node.layoutMode === "HORIZONTAL" ? "row" : "column",
|
|
102
|
+
gap: node.itemSpacing || undefined,
|
|
103
|
+
padding: pad.some((v) => v) ? pad.map((v) => v || 0) : undefined,
|
|
104
|
+
align: node.counterAxisAlignItems,
|
|
105
|
+
justify: node.primaryAxisAlignItems,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/*
|
|
110
|
+
* Text is lifted to the parent when a node's only content is one label.
|
|
111
|
+
* Figma wraps nearly every string in its own frame, and passing that
|
|
112
|
+
* structure through verbatim buries the design in scaffolding.
|
|
113
|
+
*/
|
|
114
|
+
function condense(node, depth) {
|
|
115
|
+
const out = { name: node.name, type: node.type };
|
|
116
|
+
|
|
117
|
+
const layout = layoutOf(node);
|
|
118
|
+
if (layout) out.layout = layout;
|
|
119
|
+
|
|
120
|
+
if (node.type === "TEXT" && node.characters && node.characters.trim()) {
|
|
121
|
+
out.text = node.characters.trim();
|
|
122
|
+
if (node.style) {
|
|
123
|
+
out.font = { size: node.style.fontSize, weight: node.style.fontWeight };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// An instance is a lookup, when the design was built from a library.
|
|
128
|
+
if (node.type === "INSTANCE" && node.componentId) out.instanceOf = node.componentId;
|
|
129
|
+
|
|
130
|
+
const style = {};
|
|
131
|
+
const fill = solidFill(node);
|
|
132
|
+
if (fill && node.type !== "TEXT") style.fill = fill;
|
|
133
|
+
if (typeof node.cornerRadius === "number" && node.cornerRadius) style.radius = node.cornerRadius;
|
|
134
|
+
if (Object.keys(style).length) out.style = style;
|
|
135
|
+
|
|
136
|
+
if (node.absoluteBoundingBox) {
|
|
137
|
+
out.size = [Math.round(node.absoluteBoundingBox.width), Math.round(node.absoluteBoundingBox.height)];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const kids = (node.children || []).filter((c) => c.visible !== false);
|
|
141
|
+
if (kids.length && depth < MAX_DEPTH) {
|
|
142
|
+
const children = kids.map((c) => condense(c, depth + 1));
|
|
143
|
+
// A frame that exists only to hold one text node is noise.
|
|
144
|
+
if (children.length === 1 && children[0].text && !children[0].children && !out.text) {
|
|
145
|
+
out.text = children[0].text;
|
|
146
|
+
out.font = children[0].font;
|
|
147
|
+
} else {
|
|
148
|
+
out.children = children;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------- tokens
|
|
156
|
+
|
|
157
|
+
const luminance = (h) => {
|
|
158
|
+
const [r, g, b] = [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16) / 255);
|
|
159
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const saturation = (h) => {
|
|
163
|
+
const [r, g, b] = [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16) / 255);
|
|
164
|
+
const max = Math.max(r, g, b);
|
|
165
|
+
const min = Math.min(r, g, b);
|
|
166
|
+
return max === 0 ? 0 : (max - min) / max;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/*
|
|
170
|
+
* Colours get roles, not ranks.
|
|
171
|
+
*
|
|
172
|
+
* The most-used colour in a design is almost always the body text, and the
|
|
173
|
+
* accent is used a handful of times on the one button that matters. Sorting
|
|
174
|
+
* by frequency alone would theme the whole library the colour of its
|
|
175
|
+
* paragraphs.
|
|
176
|
+
*/
|
|
177
|
+
function themeFrom(colours, radii, fontSizes) {
|
|
178
|
+
const used = [...colours.entries()].sort((a, b) => b[1] - a[1]);
|
|
179
|
+
const theme = [];
|
|
180
|
+
const take = (variable, entry, note) => {
|
|
181
|
+
if (entry) theme.push({ variable, value: entry[0], seenOn: entry[1], why: note });
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/*
|
|
185
|
+
* A near-black navy reads as highly saturated by the HSV measure — #192435
|
|
186
|
+
* scores 0.53 — so saturation alone picks the body text as the brand
|
|
187
|
+
* colour. Requiring real lightness as well is what separates an accent
|
|
188
|
+
* from a dark neutral.
|
|
189
|
+
*/
|
|
190
|
+
const saturated = used.filter(
|
|
191
|
+
([h]) => saturation(h) > 0.45 && luminance(h) > 0.2 && luminance(h) < 0.8,
|
|
192
|
+
);
|
|
193
|
+
const darkest = used.filter(([h]) => luminance(h) < 0.25).sort((a, b) => luminance(a[0]) - luminance(b[0]));
|
|
194
|
+
const lightest = used.filter(([h]) => luminance(h) > 0.9).sort((a, b) => b[1] - a[1]);
|
|
195
|
+
const mid = used.filter(([h]) => luminance(h) >= 0.25 && luminance(h) <= 0.9 && saturation(h) <= 0.35);
|
|
196
|
+
|
|
197
|
+
take("--gbs-accent", saturated[0], "the most-used saturated colour");
|
|
198
|
+
take("--gbs-fg", darkest[0], "the darkest colour, used for text");
|
|
199
|
+
take("--gbs-bg", lightest[0], "the lightest colour, used for surfaces");
|
|
200
|
+
take("--gbs-muted", mid[0], "a desaturated mid tone");
|
|
201
|
+
|
|
202
|
+
const radius = [...radii.entries()].sort((a, b) => b[1] - a[1])[0];
|
|
203
|
+
if (radius) theme.push({ variable: "--gbs-radius", value: `${radius[0]}px`, seenOn: radius[1] });
|
|
204
|
+
|
|
205
|
+
// The body size is the most-used that a person could actually read.
|
|
206
|
+
const body = [...fontSizes.entries()]
|
|
207
|
+
.filter(([size]) => size >= 12)
|
|
208
|
+
.sort((a, b) => b[1] - a[1])[0];
|
|
209
|
+
if (body) theme.push({ variable: "--gbs-font-size", value: `${body[0]}px`, seenOn: body[1] });
|
|
210
|
+
|
|
211
|
+
return theme;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function collectTokens(node, colours, radii, fontSizes) {
|
|
215
|
+
const fill = solidFill(node);
|
|
216
|
+
if (fill) colours.set(fill, (colours.get(fill) || 0) + 1);
|
|
217
|
+
if (typeof node.cornerRadius === "number" && node.cornerRadius) {
|
|
218
|
+
radii.set(node.cornerRadius, (radii.get(node.cornerRadius) || 0) + 1);
|
|
219
|
+
}
|
|
220
|
+
if (node.style && node.style.fontSize) {
|
|
221
|
+
fontSizes.set(node.style.fontSize, (fontSizes.get(node.style.fontSize) || 0) + 1);
|
|
222
|
+
}
|
|
223
|
+
for (const child of node.children || []) collectTokens(child, colours, radii, fontSizes);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Names the designer repeated are the ones that mean something. */
|
|
227
|
+
function repeatedNames(node, counts) {
|
|
228
|
+
counts.set(node.name, (counts.get(node.name) || 0) + 1);
|
|
229
|
+
for (const child of node.children || []) repeatedNames(child, counts);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ------------------------------------------------------------------- tool
|
|
233
|
+
|
|
234
|
+
/*
|
|
235
|
+
* A selection exported by the plugin, which has already done the condensing
|
|
236
|
+
* inside Figma. The work left is the part that needs no file access: giving
|
|
237
|
+
* the colours roles and counting the names that repeat.
|
|
238
|
+
*
|
|
239
|
+
* This is the path for anyone who would rather not hold an API token, and
|
|
240
|
+
* for files the token cannot reach.
|
|
241
|
+
*/
|
|
242
|
+
function fromPluginExport(design) {
|
|
243
|
+
let parsed;
|
|
244
|
+
try {
|
|
245
|
+
parsed = typeof design === "string" ? JSON.parse(design) : design;
|
|
246
|
+
} catch (error) {
|
|
247
|
+
return { error: `That is not JSON: ${error.message}` };
|
|
248
|
+
}
|
|
249
|
+
if (!parsed || !Array.isArray(parsed.nodes)) {
|
|
250
|
+
return {
|
|
251
|
+
error:
|
|
252
|
+
"Expected the output of the Figma plugin's 'Export selection for an agent' command.",
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const colours = new Map();
|
|
257
|
+
const radii = new Map();
|
|
258
|
+
const fontSizes = new Map();
|
|
259
|
+
const names = new Map();
|
|
260
|
+
const visit = (node) => {
|
|
261
|
+
names.set(node.name, (names.get(node.name) || 0) + 1);
|
|
262
|
+
if (node.style) {
|
|
263
|
+
if (node.style.fill) colours.set(node.style.fill, (colours.get(node.style.fill) || 0) + 1);
|
|
264
|
+
if (node.style.radius) radii.set(node.style.radius, (radii.get(node.style.radius) || 0) + 1);
|
|
265
|
+
if (node.style.fontSize) {
|
|
266
|
+
fontSizes.set(node.style.fontSize, (fontSizes.get(node.style.fontSize) || 0) + 1);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
for (const child of node.children || []) visit(child);
|
|
270
|
+
};
|
|
271
|
+
for (const node of parsed.nodes) visit(node);
|
|
272
|
+
|
|
273
|
+
const instances = [];
|
|
274
|
+
const findInstances = (n) => {
|
|
275
|
+
if (n.component) instances.push(n.component);
|
|
276
|
+
for (const c of n.children || []) findInstances(c);
|
|
277
|
+
};
|
|
278
|
+
for (const node of parsed.nodes) findInstances(node);
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
source: "figma plugin export",
|
|
282
|
+
frame: parsed.frame,
|
|
283
|
+
theme: themeFrom(colours, radii, fontSizes),
|
|
284
|
+
repeatedRegions: [...names.entries()]
|
|
285
|
+
.filter(([, n]) => n > 2)
|
|
286
|
+
.sort((a, b) => b[1] - a[1])
|
|
287
|
+
.slice(0, 20)
|
|
288
|
+
.map(([name, n]) => ({ name, times: n })),
|
|
289
|
+
builtFromLibrary: instances.length > 0,
|
|
290
|
+
componentsNamed: [...new Set(instances)],
|
|
291
|
+
tree: parsed.nodes,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function figmaFrame({ url, design, frame, depth }) {
|
|
296
|
+
// The export needs no token, so it is offered before one is demanded.
|
|
297
|
+
if (design) return fromPluginExport(design);
|
|
298
|
+
|
|
299
|
+
const token = figmaToken();
|
|
300
|
+
if (!token) {
|
|
301
|
+
return {
|
|
302
|
+
error:
|
|
303
|
+
"No FIGMA_TOKEN. Either run the kit's Figma plugin → 'Export selection for an agent' and pass the result as `design`, or create a token at Figma → Settings → Security → Personal access tokens (scope: File content, read) and set FIGMA_TOKEN.",
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const { fileKey, nodeId, error } = parseFigmaUrl(url);
|
|
308
|
+
if (error) return { error };
|
|
309
|
+
|
|
310
|
+
let file;
|
|
311
|
+
try {
|
|
312
|
+
file = nodeId
|
|
313
|
+
? await figmaGet(`/files/${fileKey}/nodes?ids=${encodeURIComponent(nodeId)}`, token)
|
|
314
|
+
: await figmaGet(`/files/${fileKey}`, token);
|
|
315
|
+
} catch (e) {
|
|
316
|
+
return { error: e.message };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const roots = nodeId
|
|
320
|
+
? Object.values(file.nodes || {}).filter(Boolean).map((n) => n.document)
|
|
321
|
+
: (file.document.children || []).flatMap((page) => page.children || []);
|
|
322
|
+
|
|
323
|
+
if (roots.length === 0) return { error: "Nothing to read at that URL." };
|
|
324
|
+
|
|
325
|
+
/*
|
|
326
|
+
* Without a frame named, the whole file would arrive at once — hundreds of
|
|
327
|
+
* KB that buries the screen the agent was asked about. So the first call
|
|
328
|
+
* lists what is there and the second asks for one.
|
|
329
|
+
*/
|
|
330
|
+
const wanted = frame
|
|
331
|
+
? roots.filter((r) => r.name.toLowerCase() === String(frame).toLowerCase())
|
|
332
|
+
: roots;
|
|
333
|
+
|
|
334
|
+
if (!frame && roots.length > 1) {
|
|
335
|
+
return {
|
|
336
|
+
file: file.name,
|
|
337
|
+
frames: roots.map((r) => ({
|
|
338
|
+
name: r.name,
|
|
339
|
+
size: r.absoluteBoundingBox
|
|
340
|
+
? [Math.round(r.absoluteBoundingBox.width), Math.round(r.absoluteBoundingBox.height)]
|
|
341
|
+
: undefined,
|
|
342
|
+
})),
|
|
343
|
+
next: "Call again with `frame` set to the one you want to build.",
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (wanted.length === 0) {
|
|
348
|
+
return { error: `No frame called "${frame}".`, frames: roots.map((r) => r.name) };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const target = wanted[0];
|
|
352
|
+
const colours = new Map();
|
|
353
|
+
const radii = new Map();
|
|
354
|
+
const fontSizes = new Map();
|
|
355
|
+
const names = new Map();
|
|
356
|
+
collectTokens(target, colours, radii, fontSizes);
|
|
357
|
+
repeatedNames(target, names);
|
|
358
|
+
|
|
359
|
+
const limit = Math.min(Number(depth) || MAX_DEPTH, MAX_DEPTH);
|
|
360
|
+
const tree = condense(target, MAX_DEPTH - limit);
|
|
361
|
+
|
|
362
|
+
const instances = [];
|
|
363
|
+
const findInstances = (n) => {
|
|
364
|
+
if (n.instanceOf) instances.push(n.name);
|
|
365
|
+
for (const c of n.children || []) findInstances(c);
|
|
366
|
+
};
|
|
367
|
+
findInstances(tree);
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
file: file.name,
|
|
371
|
+
frame: target.name,
|
|
372
|
+
size: tree.size,
|
|
373
|
+
theme: themeFrom(colours, radii, fontSizes),
|
|
374
|
+
/* Repetition is the signal: eight "Status cell" nodes are a column. */
|
|
375
|
+
repeatedRegions: [...names.entries()]
|
|
376
|
+
.filter(([, n]) => n > 2)
|
|
377
|
+
.sort((a, b) => b[1] - a[1])
|
|
378
|
+
.slice(0, 20)
|
|
379
|
+
.map(([name, n]) => ({ name, times: n })),
|
|
380
|
+
builtFromLibrary: instances.length > 0,
|
|
381
|
+
tree,
|
|
382
|
+
howToUse: instances.length
|
|
383
|
+
? "This design uses component instances; `instanceOf` names the master for each."
|
|
384
|
+
: "This design was drawn without the component library, so nothing names a component directly. Use the region names and structure to decide which catalogue component each part is, then call get_component before writing it.",
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
module.exports = { figmaFrame, parseFigmaUrl, themeFrom };
|
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* An MCP server over the component catalogue.
|
|
4
|
+
*
|
|
5
|
+
* A coding agent asked to build a page has two ways to learn an API: read the
|
|
6
|
+
* source, which costs a lot of context and invites invention, or be told. This
|
|
7
|
+
* tells it — one component at a time, from the passport that was generated
|
|
8
|
+
* from the source and cannot drift from it.
|
|
9
|
+
*
|
|
10
|
+
* It also marks its own homework. `validate_usage` checks written JSX against
|
|
11
|
+
* the passports, which catches the failure that matters: a prop the model
|
|
12
|
+
* remembered from somewhere rather than read here. That was the only mistake
|
|
13
|
+
* in the experiment that justified building this — every type, union value and
|
|
14
|
+
* handler signature the passport carried came out right, and the one error was
|
|
15
|
+
* an invented prop.
|
|
16
|
+
*
|
|
17
|
+
* No dependencies on purpose. MCP is newline-delimited JSON-RPC over stdio,
|
|
18
|
+
* which is a few hundred lines, and a server that ships inside a library every
|
|
19
|
+
* consumer installs should not drag an SDK along for the ride.
|
|
20
|
+
*/
|
|
21
|
+
"use strict";
|
|
22
|
+
|
|
23
|
+
const fs = require("fs");
|
|
24
|
+
const path = require("path");
|
|
25
|
+
const { execFileSync } = require("child_process");
|
|
26
|
+
const { figmaFrame } = require("./figma.cjs");
|
|
27
|
+
|
|
28
|
+
const CATALOGUE = path.join(__dirname, "..", "..", "source", "components");
|
|
29
|
+
const REFERENCES = path.join(__dirname, "..", "..", ".gbs", "skills", "gbs-components", "references");
|
|
30
|
+
|
|
31
|
+
/** The newest protocol revision this server knows. Older clients get theirs back. */
|
|
32
|
+
const PROTOCOL_VERSION = "2025-06-18";
|
|
33
|
+
const SUPPORTED_PROTOCOLS = new Set(["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"]);
|
|
34
|
+
|
|
35
|
+
// --------------------------------------------------------------- catalogue
|
|
36
|
+
|
|
37
|
+
const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
|
|
38
|
+
|
|
39
|
+
const componentFolders = () =>
|
|
40
|
+
fs
|
|
41
|
+
.readdirSync(CATALOGUE, { withFileTypes: true })
|
|
42
|
+
.filter((e) => e.isDirectory() && fs.existsSync(path.join(CATALOGUE, e.name, "passport.json")))
|
|
43
|
+
.map((e) => e.name)
|
|
44
|
+
.sort();
|
|
45
|
+
|
|
46
|
+
/** Resolve a name the way an agent would write it: "DataGrid", "data-grid", "datagrid". */
|
|
47
|
+
function findFolder(name) {
|
|
48
|
+
const wanted = String(name || "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
49
|
+
for (const folder of componentFolders()) {
|
|
50
|
+
if (folder.replace(/[^a-z0-9]/g, "") === wanted) return folder;
|
|
51
|
+
const passport = readJson(path.join(CATALOGUE, folder, "passport.json"));
|
|
52
|
+
const names = [passport.identity.component, ...(passport.identity.exports || [])];
|
|
53
|
+
if (names.some((n) => String(n).toLowerCase() === wanted)) return folder;
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/*
|
|
59
|
+
* Most passports carry no `purpose`, so the README's opening line stands in.
|
|
60
|
+
* It is the sentence a human wrote about what the component is for, which is
|
|
61
|
+
* exactly what an agent needs to pick one.
|
|
62
|
+
*/
|
|
63
|
+
function purposeOf(folder, passport) {
|
|
64
|
+
if (passport.purpose && passport.purpose.trim()) return passport.purpose.trim();
|
|
65
|
+
const readme = path.join(CATALOGUE, folder, "README.md");
|
|
66
|
+
if (!fs.existsSync(readme)) return "";
|
|
67
|
+
const line = fs
|
|
68
|
+
.readFileSync(readme, "utf8")
|
|
69
|
+
.split("\n")
|
|
70
|
+
.map((l) => l.trim())
|
|
71
|
+
.find((l) => l && !l.startsWith("#") && !l.startsWith("<!--"));
|
|
72
|
+
return line ? line.replace(/\s+/g, " ") : "";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// -------------------------------------------------------------------- tools
|
|
76
|
+
|
|
77
|
+
function listComponents() {
|
|
78
|
+
return componentFolders().map((folder) => {
|
|
79
|
+
const passport = readJson(path.join(CATALOGUE, folder, "passport.json"));
|
|
80
|
+
return {
|
|
81
|
+
component: passport.identity.component,
|
|
82
|
+
folder,
|
|
83
|
+
exports: passport.identity.exports || [],
|
|
84
|
+
purpose: purposeOf(folder, passport),
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The passport, trimmed to what writing code needs.
|
|
91
|
+
*
|
|
92
|
+
* The whole file averages 8.6 KB and runs to 26 KB; most of that is provenance
|
|
93
|
+
* an agent cannot act on. What it can act on is the shape: names, types, the
|
|
94
|
+
* union values for enum props, which are required, and the handler signatures.
|
|
95
|
+
*/
|
|
96
|
+
function getComponent(name) {
|
|
97
|
+
const folder = findFolder(name);
|
|
98
|
+
if (!folder) {
|
|
99
|
+
return { error: `No component named "${name}". Call list_components for the catalogue.` };
|
|
100
|
+
}
|
|
101
|
+
const p = readJson(path.join(CATALOGUE, folder, "passport.json"));
|
|
102
|
+
return {
|
|
103
|
+
component: p.identity.component,
|
|
104
|
+
folder,
|
|
105
|
+
exports: p.identity.exports || [],
|
|
106
|
+
importFrom: `@/component-lib/${folder}`,
|
|
107
|
+
purpose: purposeOf(folder, p),
|
|
108
|
+
props: (p.props || []).map((prop) => ({
|
|
109
|
+
name: prop.name,
|
|
110
|
+
type: prop.typeText || prop.type,
|
|
111
|
+
values: prop.values,
|
|
112
|
+
required: prop.required || undefined,
|
|
113
|
+
default: prop.default,
|
|
114
|
+
description: prop.description,
|
|
115
|
+
})),
|
|
116
|
+
events: (p.events || []).map((e) => ({
|
|
117
|
+
name: e.name,
|
|
118
|
+
signature: e.signature,
|
|
119
|
+
description: e.description,
|
|
120
|
+
})),
|
|
121
|
+
slots: p.slots || [],
|
|
122
|
+
states: p.states || [],
|
|
123
|
+
composition: p.composition || {},
|
|
124
|
+
accessibility: p.accessibility || {},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The README's code fences: how the component is meant to be used. */
|
|
129
|
+
function getExample(name) {
|
|
130
|
+
const folder = findFolder(name);
|
|
131
|
+
if (!folder) return { error: `No component named "${name}".` };
|
|
132
|
+
const readme = path.join(CATALOGUE, folder, "README.md");
|
|
133
|
+
if (!fs.existsSync(readme)) return { error: `No README for ${folder}.` };
|
|
134
|
+
|
|
135
|
+
const blocks = [];
|
|
136
|
+
const text = fs.readFileSync(readme, "utf8");
|
|
137
|
+
const fence = /```(\w*)\n([\s\S]*?)```/g;
|
|
138
|
+
let match;
|
|
139
|
+
while ((match = fence.exec(text))) blocks.push({ language: match[1] || "txt", code: match[2].trim() });
|
|
140
|
+
return { component: folder, examples: blocks };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The authored guidance that ships with the agent skill. */
|
|
144
|
+
function getGuidance(topic) {
|
|
145
|
+
const available = fs.existsSync(REFERENCES)
|
|
146
|
+
? fs.readdirSync(REFERENCES).filter((f) => f.endsWith(".md")).map((f) => f.replace(/\.md$/, ""))
|
|
147
|
+
: [];
|
|
148
|
+
if (!topic) return { topics: available };
|
|
149
|
+
const file = path.join(REFERENCES, `${topic}.md`);
|
|
150
|
+
if (!fs.existsSync(file)) return { error: `No guidance for "${topic}".`, topics: available };
|
|
151
|
+
return { topic, text: fs.readFileSync(file, "utf8") };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/*
|
|
155
|
+
* Check written JSX against the passports.
|
|
156
|
+
*
|
|
157
|
+
* Deliberately tolerant parsing rather than a real one: the agent hands over a
|
|
158
|
+
* fragment, not a file, and the failure worth catching is a prop that does not
|
|
159
|
+
* exist. Spread props and expressions are skipped rather than guessed at — a
|
|
160
|
+
* validator that invents errors is worse than none.
|
|
161
|
+
*/
|
|
162
|
+
function validateUsage(code) {
|
|
163
|
+
const source = String(code || "");
|
|
164
|
+
const known = new Map();
|
|
165
|
+
for (const folder of componentFolders()) {
|
|
166
|
+
const p = readJson(path.join(CATALOGUE, folder, "passport.json"));
|
|
167
|
+
const props = new Set((p.props || []).map((x) => x.name));
|
|
168
|
+
for (const e of p.events || []) props.add(e.name);
|
|
169
|
+
const required = (p.props || []).filter((x) => x.required).map((x) => x.name);
|
|
170
|
+
for (const exported of [p.identity.component, ...(p.identity.exports || [])]) {
|
|
171
|
+
known.set(exported, { folder, props, required, values: p.props || [] });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const issues = [];
|
|
176
|
+
const used = [];
|
|
177
|
+
const opening = /<([A-Z][A-Za-z0-9]*)(?=[\s/>])/g;
|
|
178
|
+
let match;
|
|
179
|
+
|
|
180
|
+
while ((match = opening.exec(source))) {
|
|
181
|
+
const [, name] = match;
|
|
182
|
+
const entry = known.get(name);
|
|
183
|
+
if (!entry) continue; // Not ours; the agent may compose with its own components.
|
|
184
|
+
|
|
185
|
+
/*
|
|
186
|
+
* Scan to the tag's own `>` rather than matching one. An attribute can
|
|
187
|
+
* hold an arrow function — `onDismiss={() => {}}` — so the first `>` is
|
|
188
|
+
* usually inside a brace, and a regex that stops there finds nothing.
|
|
189
|
+
*/
|
|
190
|
+
let depth = 0;
|
|
191
|
+
let quote = null;
|
|
192
|
+
let end = -1;
|
|
193
|
+
for (let i = match.index + match[0].length; i < source.length; i += 1) {
|
|
194
|
+
const ch = source[i];
|
|
195
|
+
if (quote) {
|
|
196
|
+
if (ch === quote) quote = null;
|
|
197
|
+
} else if (ch === '"' || ch === "'" || ch === "`") {
|
|
198
|
+
quote = ch;
|
|
199
|
+
} else if (ch === "{") {
|
|
200
|
+
depth += 1;
|
|
201
|
+
} else if (ch === "}") {
|
|
202
|
+
depth -= 1;
|
|
203
|
+
} else if (ch === ">" && depth === 0) {
|
|
204
|
+
end = i;
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (end === -1) continue; // Unterminated fragment; nothing reliable to say.
|
|
209
|
+
|
|
210
|
+
const rawAttrs = source.slice(match.index + match[0].length, end);
|
|
211
|
+
const attrs = [...rawAttrs.matchAll(/(?:^|\s)([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s*=|\s|$)/g)].map(
|
|
212
|
+
(m) => m[1],
|
|
213
|
+
);
|
|
214
|
+
used.push({ component: name, props: attrs });
|
|
215
|
+
|
|
216
|
+
for (const attr of attrs) {
|
|
217
|
+
if (entry.props.has(attr)) continue;
|
|
218
|
+
if (attr.startsWith("data-") || attr.startsWith("aria-") || attr === "key") continue;
|
|
219
|
+
/*
|
|
220
|
+
* A wrong prop is usually a near miss for a real one — `dismissible`
|
|
221
|
+
* for `onDismiss`, `onChange` for `onValueChange`. Matching on a shared
|
|
222
|
+
* stem in either direction finds those; matching on a prefix does not,
|
|
223
|
+
* because the handler's name starts with `on`.
|
|
224
|
+
*/
|
|
225
|
+
const low = attr.toLowerCase();
|
|
226
|
+
const stem = low.slice(0, Math.max(4, Math.round(low.length * 0.6)));
|
|
227
|
+
const near = [...entry.props].filter((p) => {
|
|
228
|
+
const other = p.toLowerCase();
|
|
229
|
+
return other.includes(stem) || low.includes(other.replace(/^on/, "").slice(0, 5));
|
|
230
|
+
});
|
|
231
|
+
issues.push({
|
|
232
|
+
component: name,
|
|
233
|
+
prop: attr,
|
|
234
|
+
problem: "not a prop of this component",
|
|
235
|
+
didYouMean: near.slice(0, 4),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
for (const req of entry.required) {
|
|
240
|
+
if (!attrs.includes(req) && !/\{\s*\.\.\./.test(rawAttrs)) {
|
|
241
|
+
issues.push({ component: name, prop: req, problem: "required prop is missing" });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
checked: used,
|
|
248
|
+
issues,
|
|
249
|
+
ok: issues.length === 0,
|
|
250
|
+
summary: issues.length
|
|
251
|
+
? `${issues.length} problem(s) across ${used.length} element(s).`
|
|
252
|
+
: `${used.length} element(s) check out against their passports.`,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/*
|
|
257
|
+
* Put components into the project the agent is working in.
|
|
258
|
+
*
|
|
259
|
+
* The CLI already knows where they belong — it finds an existing install or
|
|
260
|
+
* follows the project's own layout — so this runs it rather than
|
|
261
|
+
* reimplementing that judgement.
|
|
262
|
+
*/
|
|
263
|
+
function installComponents(names) {
|
|
264
|
+
const list = Array.isArray(names) ? names : String(names || "").split(/[\s,]+/);
|
|
265
|
+
const wanted = list.map((n) => String(n).trim()).filter(Boolean);
|
|
266
|
+
if (wanted.length === 0) return { error: "Name at least one component." };
|
|
267
|
+
|
|
268
|
+
const unknown = wanted.filter((n) => !findFolder(n));
|
|
269
|
+
if (unknown.length) {
|
|
270
|
+
return {
|
|
271
|
+
error: `Not in the catalogue: ${unknown.join(", ")}. Call list_components.`,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const cli = path.join(__dirname, "..", "..", "index.cjs");
|
|
276
|
+
try {
|
|
277
|
+
const output = execFileSync(process.execPath, [cli, "add", ...wanted], {
|
|
278
|
+
cwd: process.cwd(),
|
|
279
|
+
encoding: "utf8",
|
|
280
|
+
timeout: 120000,
|
|
281
|
+
});
|
|
282
|
+
// The installer prints the one manual step; the agent needs to see it.
|
|
283
|
+
const importLine = (output.match(/^\s*@import .+$/gm) || []).map((l) => l.trim());
|
|
284
|
+
return { installed: wanted, stylesheet: importLine, output: output.trim().slice(-1200) };
|
|
285
|
+
} catch (error) {
|
|
286
|
+
return { error: `Install failed: ${(error.stdout || error.message || "").toString().slice(-600)}` };
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const TOOLS = [
|
|
291
|
+
{
|
|
292
|
+
name: "list_components",
|
|
293
|
+
description:
|
|
294
|
+
"Every component in the catalogue with its exports and what it is for. Call this first when deciding which component a design calls for.",
|
|
295
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
296
|
+
run: () => listComponents(),
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
name: "get_component",
|
|
300
|
+
description:
|
|
301
|
+
"A component's full contract: props with types and allowed values, which are required, event handler signatures, slots, states and composition rules. Read this before writing any JSX that uses it, rather than recalling the API.",
|
|
302
|
+
inputSchema: {
|
|
303
|
+
type: "object",
|
|
304
|
+
properties: { name: { type: "string", description: 'Component or folder name, e.g. "DataGrid".' } },
|
|
305
|
+
required: ["name"],
|
|
306
|
+
additionalProperties: false,
|
|
307
|
+
},
|
|
308
|
+
run: (args) => getComponent(args.name),
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
name: "get_example",
|
|
312
|
+
description: "Working usage taken from the component's own README.",
|
|
313
|
+
inputSchema: {
|
|
314
|
+
type: "object",
|
|
315
|
+
properties: { name: { type: "string" } },
|
|
316
|
+
required: ["name"],
|
|
317
|
+
additionalProperties: false,
|
|
318
|
+
},
|
|
319
|
+
run: (args) => getExample(args.name),
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
name: "get_guidance",
|
|
323
|
+
description:
|
|
324
|
+
"Authored guidance on a topic: forms, overlays, data-grid, pickers, styling, accessibility, data-display, toaster, install. Omit the topic to list them.",
|
|
325
|
+
inputSchema: {
|
|
326
|
+
type: "object",
|
|
327
|
+
properties: { topic: { type: "string" } },
|
|
328
|
+
additionalProperties: false,
|
|
329
|
+
},
|
|
330
|
+
run: (args) => getGuidance(args.topic),
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
name: "figma_frame",
|
|
334
|
+
description:
|
|
335
|
+
"BETA. Read a Figma design, from a URL (needs FIGMA_TOKEN) or from the plugin export (no token). Called with just a URL it lists the frames in the file; called with `frame` it returns that screen's structure, text, the repeated regions that indicate lists and tables, and the theme variables its colours and radii map onto. Needs FIGMA_TOKEN.",
|
|
336
|
+
inputSchema: {
|
|
337
|
+
type: "object",
|
|
338
|
+
properties: {
|
|
339
|
+
url: { type: "string", description: "A figma.com/design/... URL. Needs FIGMA_TOKEN." },
|
|
340
|
+
design: {
|
|
341
|
+
type: "string",
|
|
342
|
+
description:
|
|
343
|
+
"Alternative to url, needing no token: JSON from the kit's Figma plugin, 'Export selection for an agent'.",
|
|
344
|
+
},
|
|
345
|
+
frame: { type: "string", description: "Which frame to read, from the listing." },
|
|
346
|
+
depth: { type: "number", description: "How deep to descend. Lower it if the payload is large." },
|
|
347
|
+
},
|
|
348
|
+
required: [],
|
|
349
|
+
additionalProperties: false,
|
|
350
|
+
},
|
|
351
|
+
run: (args) => figmaFrame(args),
|
|
352
|
+
},
|
|
353
|
+
{
|
|
354
|
+
name: "install_component",
|
|
355
|
+
description:
|
|
356
|
+
"Copy components into the project being worked in, with their shared folder and stylesheet. Run this before importing them.",
|
|
357
|
+
inputSchema: {
|
|
358
|
+
type: "object",
|
|
359
|
+
properties: {
|
|
360
|
+
names: {
|
|
361
|
+
type: "array",
|
|
362
|
+
items: { type: "string" },
|
|
363
|
+
description: 'Component names, e.g. ["DataGrid", "Button"].',
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
required: ["names"],
|
|
367
|
+
additionalProperties: false,
|
|
368
|
+
},
|
|
369
|
+
run: (args) => installComponents(args.names),
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
name: "validate_usage",
|
|
373
|
+
description:
|
|
374
|
+
"Check JSX against the passports and report props that do not exist or required props left out. Run this on code you have written before telling the user it is done.",
|
|
375
|
+
inputSchema: {
|
|
376
|
+
type: "object",
|
|
377
|
+
properties: { code: { type: "string", description: "JSX source to check." } },
|
|
378
|
+
required: ["code"],
|
|
379
|
+
additionalProperties: false,
|
|
380
|
+
},
|
|
381
|
+
run: (args) => validateUsage(args.code),
|
|
382
|
+
},
|
|
383
|
+
];
|
|
384
|
+
|
|
385
|
+
// ------------------------------------------------------------- the protocol
|
|
386
|
+
|
|
387
|
+
const send = (message) => process.stdout.write(JSON.stringify(message) + "\n");
|
|
388
|
+
const reply = (id, result) => send({ jsonrpc: "2.0", id, result });
|
|
389
|
+
const fail = (id, code, message) => send({ jsonrpc: "2.0", id, error: { code, message } });
|
|
390
|
+
|
|
391
|
+
function handle(request) {
|
|
392
|
+
const { id, method, params } = request;
|
|
393
|
+
|
|
394
|
+
if (method === "initialize") {
|
|
395
|
+
const asked = params && params.protocolVersion;
|
|
396
|
+
return reply(id, {
|
|
397
|
+
protocolVersion: SUPPORTED_PROTOCOLS.has(asked) ? asked : PROTOCOL_VERSION,
|
|
398
|
+
capabilities: { tools: {} },
|
|
399
|
+
serverInfo: { name: "grampro-kit", version: require("../../package.json").version },
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (method === "notifications/initialized") return; // A notification; no reply.
|
|
404
|
+
if (method === "ping") return reply(id, {});
|
|
405
|
+
|
|
406
|
+
if (method === "tools/list") {
|
|
407
|
+
return reply(id, {
|
|
408
|
+
tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })),
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (method === "tools/call") {
|
|
413
|
+
const tool = TOOLS.find((t) => t.name === (params && params.name));
|
|
414
|
+
if (!tool) return fail(id, -32602, `Unknown tool: ${params && params.name}`);
|
|
415
|
+
try {
|
|
416
|
+
// Some tools reach the network, so a result may be a promise.
|
|
417
|
+
const result = tool.run((params && params.arguments) || {});
|
|
418
|
+
return Promise.resolve(result).then(
|
|
419
|
+
(value) => reply(id, { content: [{ type: "text", text: JSON.stringify(value, null, 2) }] }),
|
|
420
|
+
(error) =>
|
|
421
|
+
reply(id, { content: [{ type: "text", text: `Tool failed: ${error.message}` }], isError: true }),
|
|
422
|
+
);
|
|
423
|
+
} catch (error) {
|
|
424
|
+
// Reported as a result, not a protocol error: the model should see it and retry.
|
|
425
|
+
return reply(id, {
|
|
426
|
+
content: [{ type: "text", text: `Tool failed: ${error.message}` }],
|
|
427
|
+
isError: true,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (id !== undefined) fail(id, -32601, `Method not found: ${method}`);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function main() {
|
|
436
|
+
let buffer = "";
|
|
437
|
+
process.stdin.setEncoding("utf8");
|
|
438
|
+
process.stdin.on("data", (chunk) => {
|
|
439
|
+
buffer += chunk;
|
|
440
|
+
// Newline-delimited JSON: a message may arrive split across chunks.
|
|
441
|
+
let newline;
|
|
442
|
+
while ((newline = buffer.indexOf("\n")) !== -1) {
|
|
443
|
+
const line = buffer.slice(0, newline).trim();
|
|
444
|
+
buffer = buffer.slice(newline + 1);
|
|
445
|
+
if (!line) continue;
|
|
446
|
+
try {
|
|
447
|
+
handle(JSON.parse(line));
|
|
448
|
+
} catch {
|
|
449
|
+
fail(null, -32700, "Parse error");
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
process.stdin.on("end", () => process.exit(0));
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (require.main === module) main();
|
|
457
|
+
|
|
458
|
+
module.exports = { main, listComponents, getComponent, getExample, getGuidance, validateUsage, installComponents, TOOLS };
|