@grampro/kit 2.4.4 → 2.6.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 +257 -0
- package/README.md +25 -5
- package/index.cjs +70 -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 +163 -36
- package/tools/mcp/design.cjs +154 -0
- package/tools/mcp/figma.cjs +136 -29
- 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 +60 -16
- package/tools/passport/cli.cjs +8 -1
- package/tools/passport/extract.cjs +7 -3
- package/tools/passport/merge.cjs +24 -4
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)}`);
|
|
@@ -289,38 +323,124 @@ function fromPluginExport(design) {
|
|
|
289
323
|
builtFromLibrary: instances.length > 0,
|
|
290
324
|
componentsNamed: [...new Set(instances)],
|
|
291
325
|
tree: parsed.nodes,
|
|
326
|
+
/*
|
|
327
|
+
* The same closing instruction the URL route gives. Without it an agent
|
|
328
|
+
* arriving by the plugin — now the route the documentation leads with —
|
|
329
|
+
* got the tree and no word on what to do when nothing names a component,
|
|
330
|
+
* which is exactly when it starts approximating.
|
|
331
|
+
*/
|
|
332
|
+
howToUse: instances.length
|
|
333
|
+
? "This design uses component instances; `component` names the master for each."
|
|
334
|
+
: "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.",
|
|
292
335
|
};
|
|
293
336
|
}
|
|
294
337
|
|
|
295
|
-
|
|
296
|
-
|
|
338
|
+
/**
|
|
339
|
+
* The plugin's export, read from a file.
|
|
340
|
+
*
|
|
341
|
+
* Worth preferring over `design` for anything but a small frame, because a
|
|
342
|
+
* chat input caps at around fifty thousand characters and a whole screen
|
|
343
|
+
* passes that.
|
|
344
|
+
*
|
|
345
|
+
* It halves the context too, though not more: pasted, the export is in the
|
|
346
|
+
* conversation twice — once as the argument and again inside the tool call
|
|
347
|
+
* — where from a file it appears only in the answer. The tree still comes
|
|
348
|
+
* back in full, because the tree is what the agent builds from. The plugin
|
|
349
|
+
* has already condensed it; there is nothing further to drop that the
|
|
350
|
+
* screen does not need.
|
|
351
|
+
*/
|
|
352
|
+
function fromFile(file) {
|
|
353
|
+
let resolved;
|
|
354
|
+
try {
|
|
355
|
+
resolved = path.resolve(file);
|
|
356
|
+
} catch (error) {
|
|
357
|
+
return { error: `Not a usable path: ${file}` };
|
|
358
|
+
}
|
|
359
|
+
if (!fs.existsSync(resolved)) {
|
|
360
|
+
return {
|
|
361
|
+
error:
|
|
362
|
+
`No file at ${resolved}. Save the plugin's export first — in Figma, ` +
|
|
363
|
+
"Grampro Kit -> Export selection for an agent, then Save .json.",
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
let body;
|
|
367
|
+
try {
|
|
368
|
+
body = fs.readFileSync(resolved, "utf8");
|
|
369
|
+
} catch (error) {
|
|
370
|
+
return { error: `Could not read ${resolved}: ${error.message}` };
|
|
371
|
+
}
|
|
372
|
+
// A byte-order mark survives a round trip through some editors and is not
|
|
373
|
+
// valid JSON, so it goes before parsing rather than after a confusing throw.
|
|
374
|
+
return fromPluginExport(body.replace(/^\uFEFF/, ""));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function figmaFrame({ url, design, designFile, frame, depth }) {
|
|
378
|
+
// Both export routes need no token, so they are offered before one is
|
|
379
|
+
// demanded. The file comes first: it is the one that scales.
|
|
380
|
+
if (designFile) return fromFile(designFile);
|
|
297
381
|
if (design) return fromPluginExport(design);
|
|
298
382
|
|
|
299
383
|
const token = figmaToken();
|
|
300
384
|
if (!token) {
|
|
301
385
|
return {
|
|
302
386
|
error:
|
|
303
|
-
"No
|
|
387
|
+
"No Figma token. Two ways forward, and the first needs no credentials: " +
|
|
388
|
+
"run the kit's Figma plugin → 'Export selection for an agent' and pass the result as `design`. " +
|
|
389
|
+
"Otherwise create a token at Figma → Settings → Security → Personal access tokens " +
|
|
390
|
+
"(scope: File content, read) and store it with `npx @grampro/kit secret set figma`.",
|
|
304
391
|
};
|
|
305
392
|
}
|
|
306
393
|
|
|
307
394
|
const { fileKey, nodeId, error } = parseFigmaUrl(url);
|
|
308
395
|
if (error) return { error };
|
|
309
396
|
|
|
397
|
+
/*
|
|
398
|
+
* Three shapes of request, cheapest first.
|
|
399
|
+
*
|
|
400
|
+
* A URL carrying a node-id — which is what Figma gives you when you copy a
|
|
401
|
+
* link to a frame — is one targeted call and nothing else. Otherwise the
|
|
402
|
+
* frames are listed with depth=1, which returns the outline of the document
|
|
403
|
+
* rather than the document, and the chosen frame is then fetched by id.
|
|
404
|
+
* Figma's limits are cost-based and the cost is the payload, so pulling the
|
|
405
|
+
* whole file to answer either question is what earns a 429.
|
|
406
|
+
*/
|
|
310
407
|
let file;
|
|
408
|
+
let roots;
|
|
311
409
|
try {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
410
|
+
if (nodeId) {
|
|
411
|
+
file = await figmaGet(`/files/${fileKey}/nodes?ids=${encodeURIComponent(nodeId)}`, token);
|
|
412
|
+
roots = Object.values(file.nodes || {}).filter(Boolean).map((n) => n.document);
|
|
413
|
+
} else {
|
|
414
|
+
const outline = await figmaGet(`/files/${fileKey}?depth=1`, token);
|
|
415
|
+
const tops = (outline.document.children || []).flatMap((page) => page.children || []);
|
|
416
|
+
|
|
417
|
+
if (!frame) {
|
|
418
|
+
return {
|
|
419
|
+
file: outline.name,
|
|
420
|
+
frames: tops.map((f) => ({
|
|
421
|
+
name: f.name,
|
|
422
|
+
id: f.id,
|
|
423
|
+
size: f.absoluteBoundingBox
|
|
424
|
+
? [Math.round(f.absoluteBoundingBox.width), Math.round(f.absoluteBoundingBox.height)]
|
|
425
|
+
: undefined,
|
|
426
|
+
})),
|
|
427
|
+
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.",
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const match = tops.find((f) => f.name.toLowerCase() === String(frame).toLowerCase());
|
|
432
|
+
if (!match) {
|
|
433
|
+
return { error: `No frame called "${frame}".`, frames: tops.map((f) => f.name) };
|
|
434
|
+
}
|
|
435
|
+
file = await figmaGet(`/files/${fileKey}/nodes?ids=${encodeURIComponent(match.id)}`, token);
|
|
436
|
+
roots = Object.values(file.nodes || {}).filter(Boolean).map((n) => n.document);
|
|
437
|
+
file.name = outline.name;
|
|
438
|
+
}
|
|
315
439
|
} catch (e) {
|
|
316
440
|
return { error: e.message };
|
|
317
441
|
}
|
|
318
442
|
|
|
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." };
|
|
443
|
+
if (!roots || roots.length === 0) return { error: "Nothing to read at that URL." };
|
|
324
444
|
|
|
325
445
|
/*
|
|
326
446
|
* Without a frame named, the whole file would arrive at once — hundreds of
|
|
@@ -331,19 +451,6 @@ async function figmaFrame({ url, design, frame, depth }) {
|
|
|
331
451
|
? roots.filter((r) => r.name.toLowerCase() === String(frame).toLowerCase())
|
|
332
452
|
: roots;
|
|
333
453
|
|
|
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
454
|
if (wanted.length === 0) {
|
|
348
455
|
return { error: `No frame called "${frame}".`, frames: roots.map((r) => r.name) };
|
|
349
456
|
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* `grampro-kit mcp install` — register the server with whichever agent you use.
|
|
3
|
+
*
|
|
4
|
+
* This exists because the configuration is three lines and still goes wrong.
|
|
5
|
+
* Hand-written config files pick up a byte-order mark from PowerShell's
|
|
6
|
+
* `Out-File`, which every JSON parser rejects with an error that names the
|
|
7
|
+
* first character and explains nothing. Writing the file from here means it
|
|
8
|
+
* is UTF-8 without a BOM, with LF endings, every time.
|
|
9
|
+
*
|
|
10
|
+
* Each agent is given the same stdio server; only the file format differs.
|
|
11
|
+
*/
|
|
12
|
+
"use strict";
|
|
13
|
+
|
|
14
|
+
const fs = require("fs");
|
|
15
|
+
const os = require("os");
|
|
16
|
+
const path = require("path");
|
|
17
|
+
|
|
18
|
+
const PACKAGE = "@grampro/kit";
|
|
19
|
+
const SERVER = "grampro";
|
|
20
|
+
|
|
21
|
+
/*
|
|
22
|
+
* Where the project starts, which is not necessarily where you ran this.
|
|
23
|
+
*
|
|
24
|
+
* An agent looks for `.mcp.json` at the root of the folder it opened. Writing
|
|
25
|
+
* to the working directory means that running this from `src/` puts the
|
|
26
|
+
* configuration somewhere nothing will ever read, and the command still
|
|
27
|
+
* reports success — the one way this can quietly do nothing.
|
|
28
|
+
*
|
|
29
|
+
* The nearest marker of either kind wins, rather than `.git` in preference to
|
|
30
|
+
* `package.json`. Someone who opens one workspace of a monorepo should get
|
|
31
|
+
* that workspace, and searching for `.git` first would walk straight past it
|
|
32
|
+
* and write above the folder their agent has open.
|
|
33
|
+
*/
|
|
34
|
+
function projectRoot(from) {
|
|
35
|
+
let dir = path.resolve(from);
|
|
36
|
+
for (;;) {
|
|
37
|
+
for (const mark of [".git", "package.json"]) {
|
|
38
|
+
if (fs.existsSync(path.join(dir, mark))) return dir;
|
|
39
|
+
}
|
|
40
|
+
const up = path.dirname(dir);
|
|
41
|
+
if (up === dir) return path.resolve(from);
|
|
42
|
+
dir = up;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* How the agent should start the server.
|
|
48
|
+
*
|
|
49
|
+
* A project that already has the package gets a direct `node` invocation:
|
|
50
|
+
* `npx` would re-resolve the package on every start, which costs a second and
|
|
51
|
+
* needs the network. Anywhere else, `npx -y` is the only thing that works
|
|
52
|
+
* without an install step.
|
|
53
|
+
*/
|
|
54
|
+
function launcher(cwd) {
|
|
55
|
+
const local = path.join(cwd, "node_modules", PACKAGE, "index.cjs");
|
|
56
|
+
if (fs.existsSync(local)) {
|
|
57
|
+
// Relative, so the config survives being committed and cloned elsewhere.
|
|
58
|
+
return { command: "node", args: [`node_modules/${PACKAGE}/index.cjs`, "mcp"] };
|
|
59
|
+
}
|
|
60
|
+
return { command: "npx", args: ["-y", PACKAGE, "mcp"] };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** UTF-8, no BOM, LF. The three things that go wrong by hand. */
|
|
64
|
+
function writeText(file, text) {
|
|
65
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
66
|
+
fs.writeFileSync(file, text.replace(/\r\n/g, "\n"), { encoding: "utf8" });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readText(file) {
|
|
70
|
+
if (!fs.existsSync(file)) return null;
|
|
71
|
+
// Strip a BOM if one is already there, rather than preserving the bug.
|
|
72
|
+
return fs.readFileSync(file, "utf8").replace(/^/, "");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ------------------------------------------------------------------- json
|
|
76
|
+
|
|
77
|
+
/*
|
|
78
|
+
* Claude Code and Cursor both take `mcpServers` in a JSON file; they differ
|
|
79
|
+
* only in where it lives. An existing file is merged into, because it may
|
|
80
|
+
* already hold servers that are nothing to do with this one.
|
|
81
|
+
*/
|
|
82
|
+
function installJson(file, cwd) {
|
|
83
|
+
const existing = readText(file);
|
|
84
|
+
let config = {};
|
|
85
|
+
if (existing && existing.trim()) {
|
|
86
|
+
try {
|
|
87
|
+
config = JSON.parse(existing);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
return { file, status: "failed", detail: `${file} is not valid JSON (${error.message}). Left alone.` };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
config.mcpServers = config.mcpServers || {};
|
|
94
|
+
const before = JSON.stringify(config.mcpServers[SERVER]);
|
|
95
|
+
config.mcpServers[SERVER] = launcher(cwd);
|
|
96
|
+
const after = JSON.stringify(config.mcpServers[SERVER]);
|
|
97
|
+
|
|
98
|
+
writeText(file, `${JSON.stringify(config, null, 2)}\n`);
|
|
99
|
+
return { file, status: before === after ? "unchanged" : before ? "updated" : "added" };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ------------------------------------------------------------------- toml
|
|
103
|
+
|
|
104
|
+
const tomlString = (value) => `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
105
|
+
|
|
106
|
+
function tomlBlock({ command, args }) {
|
|
107
|
+
return [
|
|
108
|
+
`[mcp_servers.${SERVER}]`,
|
|
109
|
+
`command = ${tomlString(command)}`,
|
|
110
|
+
`args = [${args.map(tomlString).join(", ")}]`,
|
|
111
|
+
].join("\n");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/*
|
|
115
|
+
* Codex reads TOML, and this edits it as text.
|
|
116
|
+
*
|
|
117
|
+
* Bringing in a TOML parser to add one table is not a trade worth making for
|
|
118
|
+
* a library that ships with no dependencies. The risk in text editing is
|
|
119
|
+
* clobbering something, so the only region touched is the span from our own
|
|
120
|
+
* table header to the next one — and if that table is already right, the file
|
|
121
|
+
* is not rewritten at all.
|
|
122
|
+
*/
|
|
123
|
+
function installToml(file, cwd) {
|
|
124
|
+
const launch = launcher(cwd);
|
|
125
|
+
const block = tomlBlock(launch);
|
|
126
|
+
const existing = readText(file);
|
|
127
|
+
|
|
128
|
+
if (!existing || !existing.trim()) {
|
|
129
|
+
writeText(file, `${block}\n`);
|
|
130
|
+
return { file, status: "added" };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const text = existing.replace(/\r\n/g, "\n");
|
|
134
|
+
const header = new RegExp(`^\\[mcp_servers\\.${SERVER}\\]\\s*$`, "m");
|
|
135
|
+
const match = header.exec(text);
|
|
136
|
+
|
|
137
|
+
if (!match) {
|
|
138
|
+
const separator = text.endsWith("\n\n") ? "" : text.endsWith("\n") ? "\n" : "\n\n";
|
|
139
|
+
writeText(file, `${text}${separator}${block}\n`);
|
|
140
|
+
return { file, status: "added" };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// From our header to the start of the next table, which is where our keys end.
|
|
144
|
+
const rest = text.slice(match.index + match[0].length);
|
|
145
|
+
const next = rest.search(/^\s*\[/m);
|
|
146
|
+
const end = next === -1 ? text.length : match.index + match[0].length + next;
|
|
147
|
+
const current = text.slice(match.index, end).trim();
|
|
148
|
+
|
|
149
|
+
if (current === block) return { file, status: "unchanged" };
|
|
150
|
+
|
|
151
|
+
const tail = text.slice(end);
|
|
152
|
+
writeText(file, `${text.slice(0, match.index)}${block}\n${tail.startsWith("\n") ? "" : "\n"}${tail}`);
|
|
153
|
+
return { file, status: "updated" };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------------- targets
|
|
157
|
+
|
|
158
|
+
const TARGETS = {
|
|
159
|
+
claude: {
|
|
160
|
+
label: "Claude Code",
|
|
161
|
+
where: (cwd) => path.join(cwd, ".mcp.json"),
|
|
162
|
+
apply: installJson,
|
|
163
|
+
/*
|
|
164
|
+
* A `.mcp.json` server is project-scoped, and Claude Code will not load
|
|
165
|
+
* one without being asked first — it sits at "Pending approval" until
|
|
166
|
+
* you say yes, and nothing asks again. That prompt only appears at the
|
|
167
|
+
* start of a session, so an editor window already open sees nothing at
|
|
168
|
+
* all. Both are worth saying out loud, because the file is written
|
|
169
|
+
* either way and the command otherwise looks like it worked.
|
|
170
|
+
*/
|
|
171
|
+
note:
|
|
172
|
+
"Reload the window (VS Code: Developer: Reload Window), approve the server\n" +
|
|
173
|
+
" when asked, then `/mcp` should show it connected. Prefer it in every\n" +
|
|
174
|
+
" project instead? `claude mcp add -s user grampro -- npx -y @grampro/kit mcp`\n" +
|
|
175
|
+
" skips the approval, since user scope is yours rather than the repo's.",
|
|
176
|
+
},
|
|
177
|
+
codex: {
|
|
178
|
+
label: "Codex",
|
|
179
|
+
where: (cwd) => path.join(cwd, ".codex", "config.toml"),
|
|
180
|
+
apply: installToml,
|
|
181
|
+
note: `Project-scoped. For every project instead, put the same block in ${path.join(os.homedir(), ".codex", "config.toml")}.`,
|
|
182
|
+
},
|
|
183
|
+
cursor: {
|
|
184
|
+
label: "Cursor",
|
|
185
|
+
where: (cwd) => path.join(cwd, ".cursor", "mcp.json"),
|
|
186
|
+
apply: installJson,
|
|
187
|
+
note: "Settings → MCP shows whether it connected.",
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const NAMES = Object.keys(TARGETS);
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Writes the configuration for one or more agents.
|
|
195
|
+
*
|
|
196
|
+
* `for` is a comma-separated list of agent names, or "all". With nothing
|
|
197
|
+
* given it writes for every agent, because the cost of an unused config file
|
|
198
|
+
* is nil and guessing wrong wastes a round trip with the user.
|
|
199
|
+
*/
|
|
200
|
+
function install({ cwd = projectRoot(process.cwd()), for: which } = {}) {
|
|
201
|
+
const wanted = !which || which === "all"
|
|
202
|
+
? NAMES
|
|
203
|
+
: String(which)
|
|
204
|
+
.split(",")
|
|
205
|
+
.map((name) => name.trim().toLowerCase())
|
|
206
|
+
.filter(Boolean);
|
|
207
|
+
|
|
208
|
+
const unknown = wanted.filter((name) => !TARGETS[name]);
|
|
209
|
+
if (unknown.length) {
|
|
210
|
+
return { error: `No such agent: ${unknown.join(", ")}. Known: ${NAMES.join(", ")}.` };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const launch = launcher(cwd);
|
|
214
|
+
const results = wanted.map((name) => {
|
|
215
|
+
const target = TARGETS[name];
|
|
216
|
+
try {
|
|
217
|
+
return { agent: name, label: target.label, note: target.note, ...target.apply(target.where(cwd), cwd) };
|
|
218
|
+
} catch (error) {
|
|
219
|
+
return { agent: name, label: target.label, status: "failed", detail: error.message };
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
return { launch, results, root: cwd };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The CLI face of the above. */
|
|
227
|
+
function main(operands, options = {}) {
|
|
228
|
+
const root = projectRoot(process.cwd());
|
|
229
|
+
const outcome = install({ cwd: root, for: options.for });
|
|
230
|
+
if (outcome.error) {
|
|
231
|
+
console.log(outcome.error);
|
|
232
|
+
process.exitCode = 1;
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const { launch, results } = outcome;
|
|
237
|
+
|
|
238
|
+
// Say which directory, always. An agent reads these files from the folder
|
|
239
|
+
// it opened, so the wrong directory is the failure that looks like success.
|
|
240
|
+
console.log(`Project root: ${root}`);
|
|
241
|
+
if (path.resolve(process.cwd()) !== root) {
|
|
242
|
+
console.log(` (you ran this in ${process.cwd()})`);
|
|
243
|
+
}
|
|
244
|
+
console.log(`Server command: ${launch.command} ${launch.args.join(" ")}\n`);
|
|
245
|
+
|
|
246
|
+
for (const result of results) {
|
|
247
|
+
const rel = path.relative(root, result.file) || result.file;
|
|
248
|
+
if (result.status === "failed") {
|
|
249
|
+
console.log(` ${result.label}: ${result.detail}`);
|
|
250
|
+
process.exitCode = 1;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
console.log(` ${result.label}: ${result.status} ${rel}`);
|
|
254
|
+
if (result.status !== "unchanged") console.log(` ${result.note}`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
console.log(
|
|
258
|
+
"\nAn MCP server is not called by name — there is no slash command for it.\n" +
|
|
259
|
+
"The agent calls its tools when they are relevant. Ask for a component and watch.",
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
if (launch.command === "npx") {
|
|
263
|
+
console.log(
|
|
264
|
+
`\nThis project does not have ${PACKAGE} installed, so the config uses npx.\n` +
|
|
265
|
+
`Installing it (\`npm i -D ${PACKAGE}\`) and running this again gives a faster start.`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
module.exports = { install, main, NAMES };
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* `grampro-kit secret …` — put a credential where the operating system keeps
|
|
3
|
+
* credentials, and tell the truth about where that is.
|
|
4
|
+
*
|
|
5
|
+
* The value is never passed as an argument. It is typed at a prompt with the
|
|
6
|
+
* echo off, or piped in, because an argument lands in the shell history and
|
|
7
|
+
* in every process listing on the machine for as long as the command runs.
|
|
8
|
+
*/
|
|
9
|
+
"use strict";
|
|
10
|
+
|
|
11
|
+
const readline = require("readline");
|
|
12
|
+
const secrets = require("./secret.cjs");
|
|
13
|
+
|
|
14
|
+
/** What each name is for, so `secret list` is readable without the docs. */
|
|
15
|
+
const PURPOSE = {
|
|
16
|
+
figma: "Figma personal access token, for reading a design by URL",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** figd_1234…wxyz — enough to tell two tokens apart, not enough to use one. */
|
|
20
|
+
function mask(value) {
|
|
21
|
+
if (value.length <= 12) return `${value.slice(0, 2)}${"*".repeat(value.length - 2)}`;
|
|
22
|
+
return `${value.slice(0, 7)}${"*".repeat(8)}${value.slice(-4)}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Reads everything piped in, for `echo $TOKEN | grampro-kit secret set figma`. */
|
|
26
|
+
function readPiped() {
|
|
27
|
+
return new Promise((resolve) => {
|
|
28
|
+
let input = "";
|
|
29
|
+
process.stdin.setEncoding("utf8");
|
|
30
|
+
process.stdin.on("data", (chunk) => {
|
|
31
|
+
input += chunk;
|
|
32
|
+
});
|
|
33
|
+
process.stdin.on("end", () => resolve(input.trim()));
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Reads one line with the echo off. */
|
|
38
|
+
function prompt(question) {
|
|
39
|
+
return new Promise((resolve) => {
|
|
40
|
+
const rl = readline.createInterface({
|
|
41
|
+
input: process.stdin,
|
|
42
|
+
output: process.stdout,
|
|
43
|
+
terminal: true,
|
|
44
|
+
});
|
|
45
|
+
let muted = false;
|
|
46
|
+
// readline writes each keystroke back to the terminal; this is where to
|
|
47
|
+
// stop it. There is no supported option for a password prompt.
|
|
48
|
+
rl._writeToOutput = (chunk) => {
|
|
49
|
+
if (!muted) rl.output.write(chunk);
|
|
50
|
+
};
|
|
51
|
+
rl.question(question, (answer) => {
|
|
52
|
+
rl.close();
|
|
53
|
+
process.stdout.write("\n");
|
|
54
|
+
resolve(answer.trim());
|
|
55
|
+
});
|
|
56
|
+
muted = true;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const usage = () =>
|
|
61
|
+
[
|
|
62
|
+
"Usage:",
|
|
63
|
+
" grampro-kit secret set <name> store a credential (prompts; echo off)",
|
|
64
|
+
" grampro-kit secret get <name> show whether one is stored, masked",
|
|
65
|
+
" grampro-kit secret remove <name> delete it",
|
|
66
|
+
" grampro-kit secret list what is stored, and where",
|
|
67
|
+
"",
|
|
68
|
+
`Names: ${secrets.KNOWN.join(", ")}`,
|
|
69
|
+
].join("\n");
|
|
70
|
+
|
|
71
|
+
async function main(operands) {
|
|
72
|
+
const [action, name] = operands;
|
|
73
|
+
|
|
74
|
+
if (!action || action === "help") {
|
|
75
|
+
console.log(usage());
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (action === "list") {
|
|
80
|
+
const stored = secrets.listSecrets();
|
|
81
|
+
console.log(`Stored in: ${secrets.describeStore()}`);
|
|
82
|
+
if (!secrets.isEncrypted()) {
|
|
83
|
+
console.log(
|
|
84
|
+
"\nThis machine offers no credential store, so the file is only protected by\n" +
|
|
85
|
+
"its permissions. It is outside your repository, which is the point.",
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
console.log("");
|
|
89
|
+
if (stored.length === 0) {
|
|
90
|
+
console.log("Nothing stored yet. Try: grampro-kit secret set figma");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
for (const key of stored) {
|
|
94
|
+
const value = secrets.getSecret(key);
|
|
95
|
+
const purpose = PURPOSE[key] ? ` — ${PURPOSE[key]}` : "";
|
|
96
|
+
console.log(` ${key} ${value ? mask(value) : "(unreadable)"}${purpose}`);
|
|
97
|
+
}
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!name) {
|
|
102
|
+
console.log(`Which secret? Try: grampro-kit secret ${action} figma`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (action === "set") {
|
|
107
|
+
/*
|
|
108
|
+
* An unknown name is accepted rather than refused — this package is not
|
|
109
|
+
* the only thing that may want a store — but it is worth a word, because
|
|
110
|
+
* the usual reason for one is a typo.
|
|
111
|
+
*/
|
|
112
|
+
if (!secrets.KNOWN.includes(name)) {
|
|
113
|
+
console.log(`Note: "${name}" is not a name this package reads (${secrets.KNOWN.join(", ")}).`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const value = process.stdin.isTTY
|
|
117
|
+
? await prompt(`Value for "${name}" (not shown): `)
|
|
118
|
+
: await readPiped();
|
|
119
|
+
|
|
120
|
+
if (!value) {
|
|
121
|
+
console.log("Nothing entered; nothing stored.");
|
|
122
|
+
process.exitCode = 1;
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
secrets.setSecret(name, value);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
console.log(`Could not store it: ${error.message}`);
|
|
130
|
+
process.exitCode = 1;
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Read it back. A store that accepts a write and cannot return it is
|
|
135
|
+
// worse than one that fails loudly, and that has happened.
|
|
136
|
+
if (secrets.getSecret(name) !== value) {
|
|
137
|
+
console.log("Stored, but it did not read back correctly. Treat it as not set.");
|
|
138
|
+
process.exitCode = 1;
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
console.log(`Stored "${name}" (${mask(value)}) in ${secrets.describeStore()}.`);
|
|
143
|
+
if (name === "figma") {
|
|
144
|
+
console.log(
|
|
145
|
+
"\nThe MCP server will find it from any project. If you have FIGMA_TOKEN in a\n" +
|
|
146
|
+
".env, delete that line — the environment still wins over the store.",
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (action === "get") {
|
|
153
|
+
const value = secrets.getSecret(name);
|
|
154
|
+
if (!value) {
|
|
155
|
+
console.log(`No "${name}" stored in ${secrets.describeStore()}.`);
|
|
156
|
+
process.exitCode = 1;
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
console.log(`${name}: ${mask(value)} (${value.length} characters, in ${secrets.describeStore()})`);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (action === "remove" || action === "delete") {
|
|
164
|
+
console.log(
|
|
165
|
+
secrets.removeSecret(name) ? `Removed "${name}".` : `There was no "${name}" to remove.`,
|
|
166
|
+
);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
console.log(`No such action "${action}".\n\n${usage()}`);
|
|
171
|
+
process.exitCode = 1;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
module.exports = { main, mask };
|