@grampro/kit 2.4.4 → 2.5.0
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/CHANGELOG.md +169 -0
- package/index.cjs +39 -4
- package/package.json +1 -1
- package/source/components/accordion/passport.json +11 -7
- package/source/components/accordion/react/Accordion.tsx +5 -0
- package/source/components/alert/passport.json +3 -3
- package/source/components/avatar/passport.json +8 -6
- package/source/components/avatar/react/Avatar.tsx +4 -0
- package/source/components/badge/passport.json +8 -6
- package/source/components/badge/react/Badge.tsx +1 -0
- package/source/components/badge/react/Tag.tsx +1 -0
- package/source/components/breadcrumb/passport.json +8 -6
- package/source/components/breadcrumb/react/Breadcrumb.tsx +1 -0
- package/source/components/button/passport.json +8 -6
- package/source/components/button/react/Button.tsx +1 -0
- package/source/components/card/passport.json +17 -9
- package/source/components/card/react/Card.tsx +5 -0
- package/source/components/card/react/Stat.tsx +5 -0
- package/source/components/checkbox/passport.json +32 -14
- package/source/components/checkbox/react/Checkbox.tsx +17 -0
- package/source/components/checkbox/react/CheckboxGroup.tsx +6 -0
- package/source/components/combobox/passport.json +36 -16
- package/source/components/combobox/react/Select.tsx +6 -1
- package/source/components/combobox/react/props.ts +23 -0
- package/source/components/data-grid/core/types.ts +53 -0
- package/source/components/data-grid/passport.json +82 -30
- package/source/components/data-grid/react/AskGrid.tsx +10 -0
- package/source/components/data-grid/react/DataGrid.tsx +16 -0
- package/source/components/date-picker/passport.json +30 -14
- package/source/components/date-picker/react/DatePicker.tsx +5 -0
- package/source/components/date-picker/react/props.ts +13 -1
- package/source/components/dialog/core/types.ts +14 -0
- package/source/components/dialog/passport.json +26 -12
- package/source/components/dialog/react/Dialog.tsx +4 -0
- package/source/components/file-uploader/passport.json +44 -18
- package/source/components/file-uploader/react/FileUploader.tsx +39 -0
- package/source/components/input/passport.json +14 -8
- package/source/components/input/react/Input.tsx +3 -0
- package/source/components/menu/passport.json +29 -13
- package/source/components/menu/react/Menu.tsx +7 -0
- package/source/components/menu/react/items.tsx +4 -0
- package/source/components/modal/passport.json +14 -8
- package/source/components/modal/react/Modal.tsx +6 -0
- package/source/components/number-input/passport.json +23 -11
- package/source/components/number-input/react/NumberInput.tsx +13 -0
- package/source/components/popover/passport.json +17 -9
- package/source/components/popover/react/Popover.tsx +7 -0
- package/source/components/progress/passport.json +3 -3
- package/source/components/radio-group/passport.json +32 -14
- package/source/components/radio-group/react/Radio.tsx +17 -0
- package/source/components/radio-group/react/RadioGroup.tsx +13 -0
- package/source/components/skeleton/passport.json +8 -6
- package/source/components/skeleton/react/Skeleton.tsx +5 -0
- package/source/components/spinner/passport.json +3 -3
- package/source/components/switch/passport.json +23 -11
- package/source/components/switch/react/Switch.tsx +14 -0
- package/source/components/tabs/passport.json +11 -7
- package/source/components/tabs/react/Tabs.tsx +2 -0
- package/source/components/textarea/passport.json +14 -8
- package/source/components/textarea/react/Textarea.tsx +3 -0
- package/source/components/toaster/passport.json +8 -6
- package/source/components/toaster/react/Toaster.tsx +4 -0
- package/source/components/tooltip/passport.json +3 -3
- package/tools/mcp/README.md +126 -34
- package/tools/mcp/figma.cjs +84 -27
- package/tools/mcp/install.cjs +270 -0
- package/tools/mcp/secret-cli.cjs +174 -0
- package/tools/mcp/secret.cjs +289 -0
- package/tools/mcp/server.cjs +53 -14
- package/tools/passport/cli.cjs +8 -1
- package/tools/passport/extract.cjs +7 -3
- package/tools/passport/merge.cjs +24 -4
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
]
|
|
13
13
|
},
|
|
14
14
|
"source": {
|
|
15
|
-
"libraryVersion": "2.
|
|
15
|
+
"libraryVersion": "2.5.0",
|
|
16
16
|
"files": [
|
|
17
17
|
"core/index.ts",
|
|
18
18
|
"core/navigation.ts",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"react/Tabs.tsx",
|
|
22
22
|
"react/context.ts"
|
|
23
23
|
],
|
|
24
|
-
"sourceHash": "sha256:
|
|
24
|
+
"sourceHash": "sha256:3d8e29857c59101e547f234c45735db68cc87ee143cc87d841372ec612e2c663"
|
|
25
25
|
},
|
|
26
26
|
"props": [
|
|
27
27
|
{
|
|
@@ -124,7 +124,9 @@
|
|
|
124
124
|
"type": "other",
|
|
125
125
|
"typeText": "ReactNode",
|
|
126
126
|
"required": false,
|
|
127
|
-
"origin": "derived"
|
|
127
|
+
"origin": "derived",
|
|
128
|
+
"description": "An icon before the tab's text.",
|
|
129
|
+
"descriptionOrigin": "jsdoc"
|
|
128
130
|
},
|
|
129
131
|
{
|
|
130
132
|
"name": "id",
|
|
@@ -155,7 +157,9 @@
|
|
|
155
157
|
"type": "function",
|
|
156
158
|
"signature": "((value: string) => void) | undefined",
|
|
157
159
|
"required": false,
|
|
158
|
-
"origin": "derived"
|
|
160
|
+
"origin": "derived",
|
|
161
|
+
"description": "The newly selected tab. Not fired when the same tab is clicked again.",
|
|
162
|
+
"descriptionOrigin": "jsdoc"
|
|
159
163
|
},
|
|
160
164
|
{
|
|
161
165
|
"name": "orientation",
|
|
@@ -305,9 +309,9 @@
|
|
|
305
309
|
"safeMutations": {},
|
|
306
310
|
"examples": [],
|
|
307
311
|
"coverage": {
|
|
308
|
-
"propsTotal":
|
|
309
|
-
"propsDescribed":
|
|
310
|
-
"describedPct":
|
|
312
|
+
"propsTotal": 11,
|
|
313
|
+
"propsDescribed": 9,
|
|
314
|
+
"describedPct": 82,
|
|
311
315
|
"authoredSections": [
|
|
312
316
|
"accessibility"
|
|
313
317
|
]
|
|
@@ -33,6 +33,7 @@ export interface TabsProps extends Omit<
|
|
|
33
33
|
value?: string;
|
|
34
34
|
/** Selected tab at first. Without either, the first enabled tab is selected. */
|
|
35
35
|
defaultValue?: string;
|
|
36
|
+
/** The newly selected tab. Not fired when the same tab is clicked again. */
|
|
36
37
|
onValueChange?(value: string): void;
|
|
37
38
|
/** Default `horizontal`. */
|
|
38
39
|
orientation?: TabsOrientation;
|
|
@@ -221,6 +222,7 @@ export interface TabProps extends Omit<
|
|
|
221
222
|
> {
|
|
222
223
|
/** Identifies the tab and its panel. */
|
|
223
224
|
value: string;
|
|
225
|
+
/** An icon before the tab's text. */
|
|
224
226
|
icon?: ReactNode;
|
|
225
227
|
/** A small count or label after the text, e.g. unread items. */
|
|
226
228
|
badge?: ReactNode;
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
]
|
|
9
9
|
},
|
|
10
10
|
"source": {
|
|
11
|
-
"libraryVersion": "2.
|
|
11
|
+
"libraryVersion": "2.5.0",
|
|
12
12
|
"files": [
|
|
13
13
|
"core/count.ts",
|
|
14
14
|
"core/index.ts",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"react/locale.ts",
|
|
20
20
|
"react/props.ts"
|
|
21
21
|
],
|
|
22
|
-
"sourceHash": "sha256:
|
|
22
|
+
"sourceHash": "sha256:a586004558fc7c4059756daa252c36028f3f9b1f4cf55fcccb9b5af2ce1e21e9"
|
|
23
23
|
},
|
|
24
24
|
"props": [
|
|
25
25
|
{
|
|
@@ -93,7 +93,9 @@
|
|
|
93
93
|
"type": "other",
|
|
94
94
|
"typeText": "ReactNode",
|
|
95
95
|
"required": false,
|
|
96
|
-
"origin": "derived"
|
|
96
|
+
"origin": "derived",
|
|
97
|
+
"description": "A hint under the label, linked as the field's description.",
|
|
98
|
+
"descriptionOrigin": "jsdoc"
|
|
97
99
|
},
|
|
98
100
|
{
|
|
99
101
|
"name": "disabled",
|
|
@@ -137,7 +139,9 @@
|
|
|
137
139
|
"type": "other",
|
|
138
140
|
"typeText": "ReactNode",
|
|
139
141
|
"required": false,
|
|
140
|
-
"origin": "derived"
|
|
142
|
+
"origin": "derived",
|
|
143
|
+
"description": "The field's name, shown above it and linked to it.",
|
|
144
|
+
"descriptionOrigin": "jsdoc"
|
|
141
145
|
},
|
|
142
146
|
{
|
|
143
147
|
"name": "localeText",
|
|
@@ -267,7 +271,9 @@
|
|
|
267
271
|
"typeText": "FieldSize | undefined",
|
|
268
272
|
"required": false,
|
|
269
273
|
"default": "md",
|
|
270
|
-
"origin": "derived"
|
|
274
|
+
"origin": "derived",
|
|
275
|
+
"description": "Default `md`.",
|
|
276
|
+
"descriptionOrigin": "jsdoc"
|
|
271
277
|
},
|
|
272
278
|
{
|
|
273
279
|
"name": "style",
|
|
@@ -372,9 +378,9 @@
|
|
|
372
378
|
"safeMutations": {},
|
|
373
379
|
"examples": [],
|
|
374
380
|
"coverage": {
|
|
375
|
-
"propsTotal":
|
|
376
|
-
"propsDescribed":
|
|
377
|
-
"describedPct":
|
|
381
|
+
"propsTotal": 12,
|
|
382
|
+
"propsDescribed": 10,
|
|
383
|
+
"describedPct": 83,
|
|
378
384
|
"authoredSections": [
|
|
379
385
|
"accessibility"
|
|
380
386
|
]
|
|
@@ -28,10 +28,13 @@ export interface TextareaProps
|
|
|
28
28
|
defaultValue?: string;
|
|
29
29
|
/** The new text on every change. The native `onChange` fires as well. */
|
|
30
30
|
onValueChange?(value: string): void;
|
|
31
|
+
/** The field's name, shown above it and linked to it. */
|
|
31
32
|
label?: ReactNode;
|
|
33
|
+
/** A hint under the label, linked as the field's description. */
|
|
32
34
|
description?: ReactNode;
|
|
33
35
|
/** Message below the field; also marks it invalid. */
|
|
34
36
|
error?: ReactNode;
|
|
37
|
+
/** Default `md`. */
|
|
35
38
|
size?: FieldSize;
|
|
36
39
|
/** Grow and shrink with the text, between `minRows` and `maxRows`. Default false. */
|
|
37
40
|
autoResize?: boolean;
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
]
|
|
12
12
|
},
|
|
13
13
|
"source": {
|
|
14
|
-
"libraryVersion": "2.
|
|
14
|
+
"libraryVersion": "2.5.0",
|
|
15
15
|
"files": [
|
|
16
16
|
"core/api.ts",
|
|
17
17
|
"core/index.ts",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"react/props.ts",
|
|
27
27
|
"react/useToasts.ts"
|
|
28
28
|
],
|
|
29
|
-
"sourceHash": "sha256:
|
|
29
|
+
"sourceHash": "sha256:976553d5d606c12b441caba76badad73812eea6dde400b51eb3ee17d05db7871"
|
|
30
30
|
},
|
|
31
31
|
"props": [
|
|
32
32
|
{
|
|
@@ -63,7 +63,9 @@
|
|
|
63
63
|
],
|
|
64
64
|
"typeText": "'ltr' | 'rtl' | 'auto' | undefined",
|
|
65
65
|
"required": false,
|
|
66
|
-
"origin": "derived"
|
|
66
|
+
"origin": "derived",
|
|
67
|
+
"description": "Writing direction, which decides which corner `position` means.\nDefault `auto`, taken from the document.",
|
|
68
|
+
"descriptionOrigin": "jsdoc"
|
|
67
69
|
},
|
|
68
70
|
{
|
|
69
71
|
"name": "duration",
|
|
@@ -211,9 +213,9 @@
|
|
|
211
213
|
"safeMutations": {},
|
|
212
214
|
"examples": [],
|
|
213
215
|
"coverage": {
|
|
214
|
-
"propsTotal":
|
|
215
|
-
"propsDescribed":
|
|
216
|
-
"describedPct":
|
|
216
|
+
"propsTotal": 8,
|
|
217
|
+
"propsDescribed": 8,
|
|
218
|
+
"describedPct": 100,
|
|
217
219
|
"authoredSections": [
|
|
218
220
|
"accessibility"
|
|
219
221
|
]
|
|
@@ -36,6 +36,10 @@ export interface ToasterProps {
|
|
|
36
36
|
icons?: Partial<Record<ToastType, ReactNode>>;
|
|
37
37
|
/** A store from `createToastStore()`. Defaults to the one behind `toast()`. */
|
|
38
38
|
store?: ToastStore;
|
|
39
|
+
/**
|
|
40
|
+
* Writing direction, which decides which corner `position` means.
|
|
41
|
+
* Default `auto`, taken from the document.
|
|
42
|
+
*/
|
|
39
43
|
dir?: "ltr" | "rtl" | "auto";
|
|
40
44
|
className?: string;
|
|
41
45
|
classNames?: Partial<Record<ToasterSlot, string>>;
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
]
|
|
9
9
|
},
|
|
10
10
|
"source": {
|
|
11
|
-
"libraryVersion": "2.
|
|
11
|
+
"libraryVersion": "2.5.0",
|
|
12
12
|
"files": [
|
|
13
13
|
"core/index.ts",
|
|
14
14
|
"core/types.ts",
|
|
@@ -160,9 +160,9 @@
|
|
|
160
160
|
"safeMutations": {},
|
|
161
161
|
"examples": [],
|
|
162
162
|
"coverage": {
|
|
163
|
-
"propsTotal":
|
|
163
|
+
"propsTotal": 9,
|
|
164
164
|
"propsDescribed": 7,
|
|
165
|
-
"describedPct":
|
|
165
|
+
"describedPct": 78,
|
|
166
166
|
"authoredSections": [
|
|
167
167
|
"accessibility"
|
|
168
168
|
]
|
package/tools/mcp/README.md
CHANGED
|
@@ -4,31 +4,90 @@ Serves the component catalogue to a coding agent, so it writes against the
|
|
|
4
4
|
real contract instead of recalling one.
|
|
5
5
|
|
|
6
6
|
> **Beta.** The tools work and are tested, but their names and shapes may
|
|
7
|
-
> change before this settles.
|
|
8
|
-
|
|
7
|
+
> change before this settles.
|
|
8
|
+
|
|
9
|
+
## Setup
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx @grampro/kit mcp install
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
That writes the configuration for every agent it knows about — Claude Code,
|
|
16
|
+
Codex and Cursor — and tells you which files it touched. Restrict it with
|
|
17
|
+
`--for codex` (or `claude`, `cursor`, or a comma-separated list).
|
|
18
|
+
|
|
19
|
+
It is worth using rather than editing the files by hand: it writes UTF-8
|
|
20
|
+
without a byte-order mark and with LF endings, which is the one thing that
|
|
21
|
+
reliably goes wrong. PowerShell's `Out-File -Encoding utf8` adds a BOM, and
|
|
22
|
+
every JSON parser then rejects the file with an error that names the first
|
|
23
|
+
character and explains nothing.
|
|
24
|
+
|
|
25
|
+
Running it again is safe. It merges into whatever is already there, leaves
|
|
26
|
+
other servers alone, and says `unchanged` when there is nothing to do.
|
|
27
|
+
|
|
28
|
+
It writes to the project root — the nearest folder up from you with a `.git`
|
|
29
|
+
or a `package.json` — and prints which one it chose, because an agent reads
|
|
30
|
+
these files from the folder it has open and writing to a subdirectory is the
|
|
31
|
+
one way this can quietly do nothing.
|
|
32
|
+
|
|
33
|
+
### If the server does not appear
|
|
34
|
+
|
|
35
|
+
The file being written is not the same as the server being loaded, and in
|
|
36
|
+
Claude Code two things stand between them.
|
|
37
|
+
|
|
38
|
+
**It needs approving.** A `.mcp.json` server is project-scoped, and Claude
|
|
39
|
+
Code asks before trusting one — until you say yes it sits at `Pending
|
|
40
|
+
approval`, and nothing asks twice. `claude mcp reset-project-choices` brings
|
|
41
|
+
the prompt back if it was dismissed.
|
|
42
|
+
|
|
43
|
+
**It needs a fresh session.** The configuration is read when a session
|
|
44
|
+
starts, so an editor window that was already open sees nothing. In the VS
|
|
45
|
+
Code extension, run *Developer: Reload Window*.
|
|
46
|
+
|
|
47
|
+
`/mcp` lists the servers and their state, which is the quickest way to tell
|
|
48
|
+
"not configured" from "configured, not approved".
|
|
49
|
+
|
|
50
|
+
To skip both, install it for yourself rather than for the project:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
claude mcp add -s user grampro -- npx -y @grampro/kit mcp
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
User scope lives in `~/.claude.json`, loads in every project, and needs no
|
|
57
|
+
approval because it is yours rather than the repository's. Project scope is
|
|
58
|
+
the better choice when you want the rest of the team to get it from a commit.
|
|
59
|
+
|
|
60
|
+
### What it writes
|
|
61
|
+
|
|
62
|
+
| agent | file | format |
|
|
63
|
+
| --- | --- | --- |
|
|
64
|
+
| Claude Code | `.mcp.json` | `mcpServers.grampro` |
|
|
65
|
+
| Codex | `.codex/config.toml` | `[mcp_servers.grampro]` |
|
|
66
|
+
| Cursor | `.cursor/mcp.json` | `mcpServers.grampro` |
|
|
67
|
+
|
|
68
|
+
The Codex config is project-scoped. For every project instead, put the same
|
|
69
|
+
`[mcp_servers.grampro]` block in `~/.codex/config.toml`.
|
|
70
|
+
|
|
71
|
+
A project that already has the package installed gets a direct `node`
|
|
72
|
+
invocation; anywhere else the config uses `npx -y @grampro/kit mcp`, which
|
|
73
|
+
works without an install but re-resolves the package on every start.
|
|
74
|
+
|
|
75
|
+
### By hand, if you prefer
|
|
9
76
|
|
|
10
77
|
```bash
|
|
11
78
|
claude mcp add grampro -- npx -y @grampro/kit mcp
|
|
12
79
|
```
|
|
13
80
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
{
|
|
20
|
-
"mcpServers": {
|
|
21
|
-
"grampro": {
|
|
22
|
-
"command": "node",
|
|
23
|
-
"args": ["node_modules/@grampro/kit/index.cjs", "mcp"]
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
}
|
|
81
|
+
```toml
|
|
82
|
+
# ~/.codex/config.toml
|
|
83
|
+
[mcp_servers.grampro]
|
|
84
|
+
command = "npx"
|
|
85
|
+
args = ["-y", "@grampro/kit", "mcp"]
|
|
27
86
|
```
|
|
28
87
|
|
|
29
88
|
MCP servers are not invoked by name. There is no `/grampro`; the model calls
|
|
30
|
-
the tools itself when they are relevant.
|
|
31
|
-
connected.
|
|
89
|
+
the tools itself when they are relevant. In Claude Code, `/mcp` shows whether
|
|
90
|
+
the server connected.
|
|
32
91
|
|
|
33
92
|
## Why
|
|
34
93
|
|
|
@@ -72,12 +131,14 @@ Two routes, and the second needs no credentials.
|
|
|
72
131
|
|
|
73
132
|
**By URL.** Pass a `figma.com/design/...` link. Called with just the URL it
|
|
74
133
|
lists the frames in the file; called again with `frame` it returns that
|
|
75
|
-
screen.
|
|
134
|
+
screen. A link that already carries a `node-id` — which is what Figma copies
|
|
135
|
+
when you right-click a frame — skips straight to that frame in one call.
|
|
136
|
+
Needs a token.
|
|
76
137
|
|
|
77
138
|
**By plugin export.** In Figma, run **Grampro Kit → Export selection for an
|
|
78
139
|
agent**, then pass the JSON as `design`. The plugin reads the scene graph
|
|
79
|
-
directly, so this works on any file you can open
|
|
80
|
-
|
|
140
|
+
directly, so this works on any file you can open, asks for no token, and
|
|
141
|
+
never touches the REST API — which also means it cannot be rate limited.
|
|
81
142
|
|
|
82
143
|
Either way the answer is the same shape: the structure, the text, the
|
|
83
144
|
regions whose names repeat — eight `Status cell` nodes are a column — and
|
|
@@ -94,21 +155,51 @@ is the body text, and a near-black navy scores high on saturation, so both
|
|
|
94
155
|
"most common" and "most saturated" pick the wrong thing. Lightness and
|
|
95
156
|
saturation together find the accent.
|
|
96
157
|
|
|
158
|
+
### Rate limits
|
|
159
|
+
|
|
160
|
+
Figma's limits on file reads are cost-based, and the cost is the size of the
|
|
161
|
+
response. Asking for a whole file to answer a question about one frame is
|
|
162
|
+
what earns a 429, and the lockout is long — a `Retry-After` of 395274 has
|
|
163
|
+
been observed on a single token.
|
|
164
|
+
|
|
165
|
+
So nothing here fetches a whole file. Listing the frames uses `depth=1`,
|
|
166
|
+
which returns the document's outline rather than its contents, and the frame
|
|
167
|
+
itself is then fetched by id. A URL carrying a `node-id` skips the listing
|
|
168
|
+
altogether. Responses are held for five minutes, so the natural two-call
|
|
169
|
+
flow — list, then read — costs one listing and one frame.
|
|
170
|
+
|
|
171
|
+
If a token is already locked out, the plugin export route still works.
|
|
172
|
+
|
|
97
173
|
## Credentials
|
|
98
174
|
|
|
99
|
-
|
|
175
|
+
The URL route needs a Figma personal access token. Store it where the
|
|
176
|
+
operating system keeps credentials:
|
|
100
177
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
178
|
+
```bash
|
|
179
|
+
npx @grampro/kit secret set figma
|
|
180
|
+
```
|
|
104
181
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
182
|
+
It prompts, with the echo off, and never takes the value as an argument —
|
|
183
|
+
arguments land in shell history and in every process listing on the machine.
|
|
184
|
+
Piping works too, for a script: `echo "$TOKEN" | npx @grampro/kit secret set figma`.
|
|
108
185
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
186
|
+
| platform | where it goes |
|
|
187
|
+
| --- | --- |
|
|
188
|
+
| Windows | Data Protection API, tied to your Windows account |
|
|
189
|
+
| macOS | the keychain |
|
|
190
|
+
| Linux | Secret Service (GNOME Keyring, KWallet) |
|
|
191
|
+
| anything else | a 0600 file in your config directory, and it says so |
|
|
192
|
+
|
|
193
|
+
`secret list` shows what is stored, masked, and names the backend in use.
|
|
194
|
+
`secret remove figma` deletes it. Create the token at Figma → Settings →
|
|
195
|
+
Security → Personal access tokens, with `File content: read` and nothing
|
|
196
|
+
else.
|
|
197
|
+
|
|
198
|
+
The token is read from `FIGMA_TOKEN` in the environment first, then the
|
|
199
|
+
store, then a `.env` — the last only so that an upgrade does not break a
|
|
200
|
+
project set up the old way. Reading from a `.env` prints a note saying to
|
|
201
|
+
move it. A credential in a file inside a working tree is one `git add -A`
|
|
202
|
+
away from being published, which is why this moved.
|
|
112
203
|
|
|
113
204
|
## Suggested use
|
|
114
205
|
|
|
@@ -123,10 +214,11 @@ No dependencies. MCP is newline-delimited JSON-RPC over stdio, which is a
|
|
|
123
214
|
few hundred lines, and a server shipped inside a library that every consumer
|
|
124
215
|
installs should not drag an SDK along with it.
|
|
125
216
|
|
|
217
|
+
Start-up is about the cost of an empty Node process. An agent spawns the
|
|
218
|
+
server once per session, and `index.cjs` routes `mcp` before loading the
|
|
219
|
+
CLI's own dependencies, which are no use to a JSON-RPC loop and cost around
|
|
220
|
+
70ms. The passports are indexed on the first question and not re-read after.
|
|
221
|
+
|
|
126
222
|
The catalogue it serves is the one in this package, not the components
|
|
127
223
|
copied into your project — so an agent can see a component before you
|
|
128
224
|
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.
|
package/tools/mcp/figma.cjs
CHANGED
|
@@ -24,18 +24,33 @@ const MAX_DEPTH = 10;
|
|
|
24
24
|
// ------------------------------------------------------------------ token
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
* The token, from the environment or a
|
|
27
|
+
* The token, from the environment, the OS secret store, or a `.env`.
|
|
28
28
|
*
|
|
29
|
-
*
|
|
30
|
-
* that
|
|
29
|
+
* The store is where it belongs and `.env` is kept only so that anyone who
|
|
30
|
+
* set it up that way is not broken by an upgrade. Reading from a `.env` says
|
|
31
|
+
* so once, on stderr — stdout carries JSON-RPC and nothing else may touch it.
|
|
31
32
|
*/
|
|
33
|
+
let warnedAboutDotenv = false;
|
|
34
|
+
|
|
32
35
|
function figmaToken() {
|
|
33
36
|
if (process.env.FIGMA_TOKEN) return process.env.FIGMA_TOKEN.trim();
|
|
37
|
+
|
|
38
|
+
const stored = require("./secret.cjs").getSecret("figma");
|
|
39
|
+
if (stored) return stored;
|
|
40
|
+
|
|
34
41
|
for (const dir of [process.cwd(), path.join(__dirname, "..", "..")]) {
|
|
35
42
|
const file = path.join(dir, ".env");
|
|
36
43
|
if (!fs.existsSync(file)) continue;
|
|
37
44
|
const match = fs.readFileSync(file, "utf8").match(/^\s*FIGMA_TOKEN\s*=\s*(.+)$/m);
|
|
38
|
-
if (match)
|
|
45
|
+
if (!match) continue;
|
|
46
|
+
if (!warnedAboutDotenv) {
|
|
47
|
+
warnedAboutDotenv = true;
|
|
48
|
+
process.stderr.write(
|
|
49
|
+
"grampro-kit: read FIGMA_TOKEN from .env. Move it out of the repository with " +
|
|
50
|
+
"`npx @grampro/kit secret set figma`, then delete the line.\n",
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
return match[1].trim().replace(/^["']|["']$/g, "");
|
|
39
54
|
}
|
|
40
55
|
return null;
|
|
41
56
|
}
|
|
@@ -73,8 +88,27 @@ async function figmaGet(pathname, token) {
|
|
|
73
88
|
);
|
|
74
89
|
}
|
|
75
90
|
if (res.status === 429) {
|
|
91
|
+
/*
|
|
92
|
+
* Figma's file-read limit is cost-based, and once a token is over it the
|
|
93
|
+
* window is long — tens of minutes, not the "try again shortly" that
|
|
94
|
+
* this message used to promise. The plugin route is the honest advice,
|
|
95
|
+
* because it does not touch the API at all.
|
|
96
|
+
*/
|
|
97
|
+
/*
|
|
98
|
+
* Retry-After is meant to be seconds, and Figma has been seen to return
|
|
99
|
+
* 395274 on a file read — four and a half days, or six minutes if the
|
|
100
|
+
* value is milliseconds. Rather than state a figure that may be wrong by
|
|
101
|
+
* three orders of magnitude, the number is passed through as given.
|
|
102
|
+
*/
|
|
103
|
+
const after = res.headers.get("retry-after");
|
|
104
|
+
const when = after
|
|
105
|
+
? `Figma's Retry-After says ${after} (nominally seconds).`
|
|
106
|
+
: "That window is usually tens of minutes, not seconds.";
|
|
76
107
|
throw new Error(
|
|
77
|
-
|
|
108
|
+
`Figma is rate limiting this token. ${when} Nothing here can shorten it. ` +
|
|
109
|
+
"To carry on now, use the kit's Figma plugin → 'Export selection for an agent' " +
|
|
110
|
+
"and pass the result as `design` — that route reads the file from inside Figma " +
|
|
111
|
+
"and never calls the API.",
|
|
78
112
|
);
|
|
79
113
|
}
|
|
80
114
|
throw new Error(`Figma returned ${res.status}: ${body.slice(0, 160)}`);
|
|
@@ -300,27 +334,63 @@ async function figmaFrame({ url, design, frame, depth }) {
|
|
|
300
334
|
if (!token) {
|
|
301
335
|
return {
|
|
302
336
|
error:
|
|
303
|
-
"No
|
|
337
|
+
"No Figma token. Two ways forward, and the first needs no credentials: " +
|
|
338
|
+
"run the kit's Figma plugin → 'Export selection for an agent' and pass the result as `design`. " +
|
|
339
|
+
"Otherwise create a token at Figma → Settings → Security → Personal access tokens " +
|
|
340
|
+
"(scope: File content, read) and store it with `npx @grampro/kit secret set figma`.",
|
|
304
341
|
};
|
|
305
342
|
}
|
|
306
343
|
|
|
307
344
|
const { fileKey, nodeId, error } = parseFigmaUrl(url);
|
|
308
345
|
if (error) return { error };
|
|
309
346
|
|
|
347
|
+
/*
|
|
348
|
+
* Three shapes of request, cheapest first.
|
|
349
|
+
*
|
|
350
|
+
* A URL carrying a node-id — which is what Figma gives you when you copy a
|
|
351
|
+
* link to a frame — is one targeted call and nothing else. Otherwise the
|
|
352
|
+
* frames are listed with depth=1, which returns the outline of the document
|
|
353
|
+
* rather than the document, and the chosen frame is then fetched by id.
|
|
354
|
+
* Figma's limits are cost-based and the cost is the payload, so pulling the
|
|
355
|
+
* whole file to answer either question is what earns a 429.
|
|
356
|
+
*/
|
|
310
357
|
let file;
|
|
358
|
+
let roots;
|
|
311
359
|
try {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
360
|
+
if (nodeId) {
|
|
361
|
+
file = await figmaGet(`/files/${fileKey}/nodes?ids=${encodeURIComponent(nodeId)}`, token);
|
|
362
|
+
roots = Object.values(file.nodes || {}).filter(Boolean).map((n) => n.document);
|
|
363
|
+
} else {
|
|
364
|
+
const outline = await figmaGet(`/files/${fileKey}?depth=1`, token);
|
|
365
|
+
const tops = (outline.document.children || []).flatMap((page) => page.children || []);
|
|
366
|
+
|
|
367
|
+
if (!frame) {
|
|
368
|
+
return {
|
|
369
|
+
file: outline.name,
|
|
370
|
+
frames: tops.map((f) => ({
|
|
371
|
+
name: f.name,
|
|
372
|
+
id: f.id,
|
|
373
|
+
size: f.absoluteBoundingBox
|
|
374
|
+
? [Math.round(f.absoluteBoundingBox.width), Math.round(f.absoluteBoundingBox.height)]
|
|
375
|
+
: undefined,
|
|
376
|
+
})),
|
|
377
|
+
next: "Call again with `frame` set to the one you want to build. Passing a URL that already carries a node-id skips this step.",
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const match = tops.find((f) => f.name.toLowerCase() === String(frame).toLowerCase());
|
|
382
|
+
if (!match) {
|
|
383
|
+
return { error: `No frame called "${frame}".`, frames: tops.map((f) => f.name) };
|
|
384
|
+
}
|
|
385
|
+
file = await figmaGet(`/files/${fileKey}/nodes?ids=${encodeURIComponent(match.id)}`, token);
|
|
386
|
+
roots = Object.values(file.nodes || {}).filter(Boolean).map((n) => n.document);
|
|
387
|
+
file.name = outline.name;
|
|
388
|
+
}
|
|
315
389
|
} catch (e) {
|
|
316
390
|
return { error: e.message };
|
|
317
391
|
}
|
|
318
392
|
|
|
319
|
-
|
|
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." };
|
|
393
|
+
if (!roots || roots.length === 0) return { error: "Nothing to read at that URL." };
|
|
324
394
|
|
|
325
395
|
/*
|
|
326
396
|
* Without a frame named, the whole file would arrive at once — hundreds of
|
|
@@ -331,19 +401,6 @@ async function figmaFrame({ url, design, frame, depth }) {
|
|
|
331
401
|
? roots.filter((r) => r.name.toLowerCase() === String(frame).toLowerCase())
|
|
332
402
|
: roots;
|
|
333
403
|
|
|
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
404
|
if (wanted.length === 0) {
|
|
348
405
|
return { error: `No frame called "${frame}".`, frames: roots.map((r) => r.name) };
|
|
349
406
|
}
|