@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.
Files changed (74) hide show
  1. package/CHANGELOG.md +257 -0
  2. package/README.md +25 -5
  3. package/index.cjs +70 -4
  4. package/package.json +1 -1
  5. package/source/components/accordion/passport.json +11 -7
  6. package/source/components/accordion/react/Accordion.tsx +5 -0
  7. package/source/components/alert/passport.json +3 -3
  8. package/source/components/avatar/passport.json +8 -6
  9. package/source/components/avatar/react/Avatar.tsx +4 -0
  10. package/source/components/badge/passport.json +8 -6
  11. package/source/components/badge/react/Badge.tsx +1 -0
  12. package/source/components/badge/react/Tag.tsx +1 -0
  13. package/source/components/breadcrumb/passport.json +8 -6
  14. package/source/components/breadcrumb/react/Breadcrumb.tsx +1 -0
  15. package/source/components/button/passport.json +8 -6
  16. package/source/components/button/react/Button.tsx +1 -0
  17. package/source/components/card/passport.json +17 -9
  18. package/source/components/card/react/Card.tsx +5 -0
  19. package/source/components/card/react/Stat.tsx +5 -0
  20. package/source/components/checkbox/passport.json +32 -14
  21. package/source/components/checkbox/react/Checkbox.tsx +17 -0
  22. package/source/components/checkbox/react/CheckboxGroup.tsx +6 -0
  23. package/source/components/combobox/passport.json +36 -16
  24. package/source/components/combobox/react/Select.tsx +6 -1
  25. package/source/components/combobox/react/props.ts +23 -0
  26. package/source/components/data-grid/core/types.ts +53 -0
  27. package/source/components/data-grid/passport.json +82 -30
  28. package/source/components/data-grid/react/AskGrid.tsx +10 -0
  29. package/source/components/data-grid/react/DataGrid.tsx +16 -0
  30. package/source/components/date-picker/passport.json +30 -14
  31. package/source/components/date-picker/react/DatePicker.tsx +5 -0
  32. package/source/components/date-picker/react/props.ts +13 -1
  33. package/source/components/dialog/core/types.ts +14 -0
  34. package/source/components/dialog/passport.json +26 -12
  35. package/source/components/dialog/react/Dialog.tsx +4 -0
  36. package/source/components/file-uploader/passport.json +44 -18
  37. package/source/components/file-uploader/react/FileUploader.tsx +39 -0
  38. package/source/components/input/passport.json +14 -8
  39. package/source/components/input/react/Input.tsx +3 -0
  40. package/source/components/menu/passport.json +29 -13
  41. package/source/components/menu/react/Menu.tsx +7 -0
  42. package/source/components/menu/react/items.tsx +4 -0
  43. package/source/components/modal/passport.json +14 -8
  44. package/source/components/modal/react/Modal.tsx +6 -0
  45. package/source/components/number-input/passport.json +23 -11
  46. package/source/components/number-input/react/NumberInput.tsx +13 -0
  47. package/source/components/popover/passport.json +17 -9
  48. package/source/components/popover/react/Popover.tsx +7 -0
  49. package/source/components/progress/passport.json +3 -3
  50. package/source/components/radio-group/passport.json +32 -14
  51. package/source/components/radio-group/react/Radio.tsx +17 -0
  52. package/source/components/radio-group/react/RadioGroup.tsx +13 -0
  53. package/source/components/skeleton/passport.json +8 -6
  54. package/source/components/skeleton/react/Skeleton.tsx +5 -0
  55. package/source/components/spinner/passport.json +3 -3
  56. package/source/components/switch/passport.json +23 -11
  57. package/source/components/switch/react/Switch.tsx +14 -0
  58. package/source/components/tabs/passport.json +11 -7
  59. package/source/components/tabs/react/Tabs.tsx +2 -0
  60. package/source/components/textarea/passport.json +14 -8
  61. package/source/components/textarea/react/Textarea.tsx +3 -0
  62. package/source/components/toaster/passport.json +8 -6
  63. package/source/components/toaster/react/Toaster.tsx +4 -0
  64. package/source/components/tooltip/passport.json +3 -3
  65. package/tools/mcp/README.md +163 -36
  66. package/tools/mcp/design.cjs +154 -0
  67. package/tools/mcp/figma.cjs +136 -29
  68. package/tools/mcp/install.cjs +270 -0
  69. package/tools/mcp/secret-cli.cjs +174 -0
  70. package/tools/mcp/secret.cjs +289 -0
  71. package/tools/mcp/server.cjs +60 -16
  72. package/tools/passport/cli.cjs +8 -1
  73. package/tools/passport/extract.cjs +7 -3
  74. package/tools/passport/merge.cjs +24 -4
@@ -24,18 +24,33 @@ const MAX_DEPTH = 10;
24
24
  // ------------------------------------------------------------------ token
25
25
 
26
26
  /**
27
- * The token, from the environment or a .env beside the project.
27
+ * The token, from the environment, the OS secret store, or a `.env`.
28
28
  *
29
- * MCP clients pass environment through unevenly, so a .env is the fallback
30
- * that works everywhere. It is read, never logged.
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) return match[1].trim().replace(/^["']|["']$/g, "");
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
- "Figma is rate limiting this token. Wait a minute and try again; the file is cached for five minutes once it arrives.",
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
- async function figmaFrame({ url, design, frame, depth }) {
296
- // The export needs no token, so it is offered before one is demanded.
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 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.",
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
- file = nodeId
313
- ? await figmaGet(`/files/${fileKey}/nodes?ids=${encodeURIComponent(nodeId)}`, token)
314
- : await figmaGet(`/files/${fileKey}`, token);
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
- 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." };
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 };