@grampro/kit 2.7.1 → 2.7.3

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 (80) hide show
  1. package/.gbs/skills/gbs-components/SKILL.md +233 -207
  2. package/.gbs/skills/gbs-components/references/data-grid.md +9 -0
  3. package/.gbs/skills/gbs-components/references/forms.md +12 -0
  4. package/.gbs/skills/gbs-components/references/install.md +42 -5
  5. package/CHANGELOG.md +963 -798
  6. package/README.md +13 -4
  7. package/index.cjs +250 -138
  8. package/package.json +1 -1
  9. package/source/components/accordion/passport.json +1 -1
  10. package/source/components/alert/passport.json +1 -1
  11. package/source/components/avatar/passport.json +1 -1
  12. package/source/components/badge/passport.json +1 -1
  13. package/source/components/breadcrumb/passport.json +1 -1
  14. package/source/components/button/passport.json +1 -1
  15. package/source/components/card/passport.json +1 -1
  16. package/source/components/checkbox/passport.json +2 -2
  17. package/source/components/checkbox/passport.manual.json +1 -1
  18. package/source/components/checkbox/react/Checkbox.tsx +14 -0
  19. package/source/components/combobox/passport.json +3 -2
  20. package/source/components/combobox/passport.manual.json +1 -1
  21. package/source/components/combobox/react/MultiSelect.tsx +13 -0
  22. package/source/components/combobox/react/Select.tsx +13 -0
  23. package/source/components/combobox/react/surface.ts +70 -0
  24. package/source/components/data-grid/passport.json +6 -6
  25. package/source/components/data-grid/react/DataGrid.tsx +353 -351
  26. package/source/components/date-picker/passport.json +3 -2
  27. package/source/components/date-picker/passport.manual.json +1 -1
  28. package/source/components/date-picker/react/DatePicker.tsx +16 -2
  29. package/source/components/date-picker/react/DateRangePicker.tsx +16 -2
  30. package/source/components/date-picker/react/surface.ts +68 -0
  31. package/source/components/dialog/passport.json +1 -1
  32. package/source/components/file-uploader/passport.json +1 -1
  33. package/source/components/input/passport.json +2 -2
  34. package/source/components/input/passport.manual.json +1 -1
  35. package/source/components/input/react/Input.tsx +21 -0
  36. package/source/components/menu/passport.json +1 -1
  37. package/source/components/modal/passport.json +1 -1
  38. package/source/components/number-input/passport.json +2 -2
  39. package/source/components/number-input/passport.manual.json +1 -1
  40. package/source/components/number-input/react/NumberInput.tsx +30 -0
  41. package/source/components/popover/passport.json +1 -1
  42. package/source/components/progress/passport.json +1 -1
  43. package/source/components/radio-group/passport.json +2 -2
  44. package/source/components/radio-group/passport.manual.json +1 -1
  45. package/source/components/radio-group/react/RadioGroup.tsx +33 -0
  46. package/source/components/shared/core/agent/field-executors.ts +173 -137
  47. package/source/components/shared/core/agent/index.ts +21 -0
  48. package/source/components/shared/core/agent/surface.ts +437 -0
  49. package/source/components/shared/core/agent/webmcp.ts +332 -328
  50. package/source/components/shared/core/agent/write.ts +62 -58
  51. package/source/components/shared/index.ts +7 -0
  52. package/source/components/shared/react/AgentSurface.tsx +145 -0
  53. package/source/components/shared/react/agentSurfaceContext.ts +92 -0
  54. package/source/components/shared/react/useFieldAgent.ts +62 -88
  55. package/source/components/shared/version.json +4 -4
  56. package/source/components/shared/version.ts +6 -6
  57. package/source/components/skeleton/passport.json +1 -1
  58. package/source/components/spinner/passport.json +1 -1
  59. package/source/components/switch/passport.json +2 -2
  60. package/source/components/switch/passport.manual.json +1 -1
  61. package/source/components/switch/react/Switch.tsx +16 -0
  62. package/source/components/tabs/passport.json +1 -1
  63. package/source/components/textarea/passport.json +2 -2
  64. package/source/components/textarea/passport.manual.json +1 -1
  65. package/source/components/textarea/react/Textarea.tsx +16 -0
  66. package/source/components/toaster/passport.json +1 -1
  67. package/source/components/tooltip/passport.json +1 -1
  68. package/tools/doctor/baseline.cjs +228 -0
  69. package/tools/doctor/contract.cjs +252 -0
  70. package/tools/doctor/format.cjs +160 -0
  71. package/tools/doctor/index.cjs +246 -0
  72. package/tools/doctor/usage.cjs +95 -0
  73. package/tools/jsx-tags.cjs +120 -0
  74. package/tools/mcp/server.cjs +60 -41
  75. package/tools/passport/extract.cjs +22 -40
  76. package/tools/passport/hash.cjs +56 -0
  77. package/tools/passport/index.cjs +4 -2
  78. package/tools/passport/typescript.cjs +220 -0
  79. package/tools/prompt.cjs +205 -0
  80. package/tools/ui.cjs +67 -0
package/README.md CHANGED
@@ -14,6 +14,9 @@ Figma design.
14
14
  npx @grampro/kit add DataGrid
15
15
  ```
16
16
 
17
+ Or `npx @grampro/kit add` with no names, to pick from the list: type to search,
18
+ space to select, enter to install.
19
+
17
20
  The source lands in `component-lib/`, or `src/component-lib/` in a project
18
21
  with a `src/` folder; `--dest` puts it anywhere you like, and later installs
19
22
  follow the first. There is no runtime package to depend on, and nothing to
@@ -84,7 +87,8 @@ genuinely headless, and the component READMEs document it under "Headless use".
84
87
 
85
88
  | | |
86
89
  | --- | --- |
87
- | `add <Component>...` | Copy components into the project. `--dest` chooses where; `--legacy` reaches the 1.x set. |
90
+ | `add <Component>...` | Copy components into the project, or update ones already there. A component you have edited is left alone unless you pass `--force`. `--dest` chooses where; `--legacy` reaches the 1.x set. |
91
+ | `doctor [Component...]` | What you changed in your copies, what the new version changes, and which of your call sites an update would break. `--json` for agents and CI. |
88
92
  | `list` | What is available to add. |
89
93
  | `skill` | Install the agent skill. `--for claude,codex,antigravity` narrows it. |
90
94
  | `mcp` | Run the MCP server. Agents start this themselves; you rarely type it. |
@@ -217,6 +221,10 @@ version. What is built and tested:
217
221
  application supplies. No model is bundled, downloaded or named.
218
222
  - **WebMCP** — one tool, `operate_grid`, exposing the same validated boundary to
219
223
  a browser agent. Needs no model at all.
224
+ - **`<AgentSurface>`** — wrap a page and every field and grid in it describes
225
+ itself to a browser agent, from its own props, and is operated through its own
226
+ validator. One WebMCP tool, `operate_page`, for the whole page; no tool
227
+ definitions to write
220
228
  - A model evaluation harness, with the measurements that justify both
221
229
 
222
230
  Every producer — a model, a browser agent, a form — proposes. The validator
@@ -226,9 +234,10 @@ it is why no AI path gets a second route into the component.
226
234
  Nine of the 27 are agent-operable. The executor abstraction was the open
227
235
  question — a grid is driven through an imperative API, and a field is not — and
228
236
  it is settled: Combobox and DatePicker are driven through their handles,
229
- Input, Textarea, NumberInput, Checkbox, Switch and RadioGroup through the
230
- element their ref already hands back. Neither the contract, the schemas, the
231
- validator nor the refusals know which.
237
+ Input, Textarea, Checkbox and Switch through the element their ref already
238
+ hands back, and NumberInput and RadioGroup, inside an `<AgentSurface>`, through
239
+ their own commit path — a number is not text, and a group is not one radio.
240
+ Neither the contract, the schemas, the validator nor the refusals know which.
232
241
 
233
242
  Still open: the components whose value is in what they contain rather than what
234
243
  they hold — Menu, Tabs, Accordion, Modal, FileUploader — and the question of
package/index.cjs CHANGED
@@ -24,7 +24,7 @@ const path = require("path");
24
24
  const crypto = require("crypto");
25
25
  const yargs = require("yargs/yargs");
26
26
  const { hideBin } = require("yargs/helpers");
27
- const readline = require("readline");
27
+ const ui = require("./tools/ui.cjs");
28
28
 
29
29
  // Configuration
30
30
  const CONFIG = {
@@ -391,7 +391,7 @@ const copyCommonFiles = async (destPath) => {
391
391
  const src = path.join(LEGACY_SOURCE_PATH, ...file.src);
392
392
  const dest = path.join(destPath, file.dest);
393
393
  await fs.copy(src, dest, { overwrite: true });
394
- console.log(`✓ ${file.dest} copied successfully`);
394
+ console.log(`${ui.check()} ${file.dest} copied successfully`);
395
395
  }
396
396
  };
397
397
 
@@ -467,7 +467,7 @@ const installShared = async (destPath, { force = false } = {}) => {
467
467
 
468
468
  if (order > 0) {
469
469
  console.error(
470
- `\n✗ This project has shared v${installedVersion}, newer than the v${srcVersion} in this installer.`,
470
+ `\n${ui.cross()} This project has shared v${installedVersion}, newer than the v${srcVersion} in this installer.`,
471
471
  );
472
472
  console.error(
473
473
  " Installing it would downgrade the folder and break the components already here.",
@@ -478,11 +478,17 @@ const installShared = async (destPath, { force = false } = {}) => {
478
478
  process.exit(1);
479
479
  }
480
480
 
481
- if (order === 0) {
481
+ // The same version is only "already installed" when it is the same files:
482
+ // a release that changed shared/ without bumping it must still reach the
483
+ // project, or the components it brings import files that are not there.
484
+ const sameFiles = listFiles(src).every(
485
+ (file) => manifest.files[file] === sha256(path.join(src, file)),
486
+ );
487
+ if (order === 0 && sameFiles) {
482
488
  console.log(
483
489
  edited.length > 0
484
- ? `✓ shared v${installedVersion} is already installed (keeping your ${edited.length} edited file(s))`
485
- : `✓ shared v${installedVersion} is already installed`,
490
+ ? `${ui.check()} shared v${installedVersion} is already installed (keeping your ${edited.length} edited file(s))`
491
+ : `${ui.check()} shared v${installedVersion} is already installed`,
486
492
  );
487
493
  return;
488
494
  }
@@ -490,7 +496,7 @@ const installShared = async (destPath, { force = false } = {}) => {
490
496
 
491
497
  if (edited.length > 0 && !force) {
492
498
  console.error(
493
- `\n✗ These files in component-lib/${SHARED_DIR} have local changes:`,
499
+ `\n${ui.cross()} These files in component-lib/${SHARED_DIR} have local changes:`,
494
500
  );
495
501
  edited.forEach((file) => console.error(` - ${file}`));
496
502
  console.error(
@@ -541,8 +547,8 @@ const installShared = async (destPath, { force = false } = {}) => {
541
547
 
542
548
  console.log(
543
549
  installedVersion
544
- ? `✓ shared updated ${installedVersion} → ${srcVersion}`
545
- : `✓ shared v${srcVersion} installed`,
550
+ ? `${ui.check()} shared updated ${installedVersion} → ${srcVersion}`
551
+ : `${ui.check()} shared v${srcVersion} installed`,
546
552
  );
547
553
  };
548
554
 
@@ -656,7 +662,7 @@ const writeAntigravityRule = async (root) => {
656
662
 
657
663
  /**
658
664
  * Codex: a pointer in AGENTS.md rather than the skill itself, because that file
659
- * is always in context and the skill is 5 KB. Everything outside our markers is
665
+ * is always in context and the skill is about 10 KB. Everything outside our markers is
660
666
  * left exactly as it was.
661
667
  */
662
668
  const writeAgentsPointer = async (root) => {
@@ -727,7 +733,7 @@ const installSkill = async (
727
733
  const edited = locallyEdited(destRoot, manifest);
728
734
 
729
735
  if (edited.length > 0 && !force) {
730
- console.error(`${NL}✗ These skill files have local changes:`);
736
+ console.error(`${NL}${ui.cross()} These skill files have local changes:`);
731
737
  edited.forEach((file) => console.error(` - ${file}`));
732
738
  console.error(
733
739
  NL +
@@ -793,8 +799,8 @@ const installSkill = async (
793
799
 
794
800
  console.log(
795
801
  installedVersion
796
- ? `✓ skill updated ${installedVersion} → ${PACKAGE_VERSION}`
797
- : `✓ skill v${PACKAGE_VERSION} installed`,
802
+ ? `${ui.check()} skill updated ${installedVersion} → ${PACKAGE_VERSION}`
803
+ : `${ui.check()} skill v${PACKAGE_VERSION} installed`,
798
804
  );
799
805
  console.log(` ${SKILL_DEST_DIR}/ (canonical, ${listFiles(src).length} files)`);
800
806
  if (targets.includes("claude")) {
@@ -833,6 +839,45 @@ const parseSkillTargets = (value) => {
833
839
  return wanted;
834
840
  };
835
841
 
842
+ /* ---------------------------------------------------------------- doctor */
843
+
844
+ /**
845
+ * Report on the components installed here: what was changed since install,
846
+ * what this version of the kit changes, and which call sites an update would
847
+ * break. Read-only. See tools/doctor/index.cjs.
848
+ */
849
+ const runDoctorCommand = ({ only = [], dest: requestedDest, json = false }) => {
850
+ const dest = resolveDestPath(requestedDest);
851
+ if (!fs.existsSync(dest)) {
852
+ console.error(
853
+ `No ${path.basename(dest)}/ folder here, so there is nothing to check.` +
854
+ `${NL} Install a component first: npx ${CLI_NAME} add Button`,
855
+ );
856
+ process.exitCode = 1;
857
+ return;
858
+ }
859
+
860
+ const { runDoctor } = require("./tools/doctor/index.cjs");
861
+ const { formatReport, toJson } = require("./tools/doctor/format.cjs");
862
+ const report = runDoctor({
863
+ cwd: process.cwd(),
864
+ dest,
865
+ kitRoot: SOURCE_PATH,
866
+ kitVersion: PACKAGE_VERSION,
867
+ only,
868
+ });
869
+
870
+ if (report.rows.length === 0 && report.notInstalled.length === 0) {
871
+ console.log(`No ${CLI_NAME} components found in ${path.relative(process.cwd(), dest) || dest}/.`);
872
+ return;
873
+ }
874
+
875
+ const shown = { ...report, dest: `${(path.relative(process.cwd(), dest) || ".").split(path.sep).join("/")}/` };
876
+ console.log(json ? JSON.stringify(toJson(shown), null, 2) : formatReport(shown));
877
+ // A component someone asked about that is not here is the one failure worth an exit code.
878
+ if (report.notInstalled.length) process.exitCode = 1;
879
+ };
880
+
836
881
  /* ------------------------------------------------------------- passports */
837
882
 
838
883
  /**
@@ -863,10 +908,17 @@ const runPassport = async ({ check: checkOnly, dest: requestedDest }) => {
863
908
  process.exit(1);
864
909
  }
865
910
 
866
- const pkgPath = path.join(process.cwd(), "package.json");
867
- const libraryVersion = fs.existsSync(pkgPath)
868
- ? readJson(pkgPath, {}).version || "unknown"
869
- : "unknown";
911
+ /*
912
+ * Each component is stamped with the kit version it was installed from —
913
+ * its install record, else the passport it shipped with — not with this
914
+ * project's own version. Stamping the project's version made every
915
+ * regenerated passport differ from the shipped one, so `passport --check`
916
+ * failed for components nobody had touched.
917
+ */
918
+ const libraryVersion = (name, dir) =>
919
+ readJson(path.join(dir, ".install-manifest.json"), {})?.version ||
920
+ readJson(path.join(dir, "passport.json"), {})?.source?.libraryVersion ||
921
+ PACKAGE_VERSION;
870
922
 
871
923
  const options = {
872
924
  libRoot: dest,
@@ -874,6 +926,7 @@ const runPassport = async ({ check: checkOnly, dest: requestedDest }) => {
874
926
  libraryVersion,
875
927
  includeLocal: true,
876
928
  coverageThreshold: undefined,
929
+ regenerate: `npx ${CLI_NAME} passport`,
877
930
  };
878
931
 
879
932
  try {
@@ -885,7 +938,7 @@ const runPassport = async ({ check: checkOnly, dest: requestedDest }) => {
885
938
  continue;
886
939
  }
887
940
  for (const issue of row.problems) {
888
- console.log(` ${issue.level === "error" ? "✗" : "!"} ${issue.component}: ${issue.message}`);
941
+ console.log(` ${issue.level === "error" ? ui.cross(process.stdout) : ui.c.yellow("!")} ${issue.component}: ${issue.message}`);
889
942
  }
890
943
  }
891
944
  console.log(`${NL}${report.length} component(s) · ${errors} error(s) · ${warnings} warning(s)`);
@@ -895,13 +948,13 @@ const runPassport = async ({ check: checkOnly, dest: requestedDest }) => {
895
948
 
896
949
  const { results, written } = passport.generate(options);
897
950
  const errors = results.flatMap((r) => r.issues.filter((i) => i.level === "error"));
898
- for (const issue of errors) console.error(` ✗ ${issue.component}: ${issue.message}`);
951
+ for (const issue of errors) console.error(` ${ui.cross()} ${issue.component}: ${issue.message}`);
899
952
  console.log(
900
- `${NL}✓ ${results.length} passport(s) regenerated · ${written.length} changed`,
953
+ `${NL}${ui.check()} ${results.length} passport(s) regenerated · ${written.length} changed`,
901
954
  );
902
955
  if (errors.length > 0) process.exit(1);
903
956
  } catch (error) {
904
- if (error.code === "ETYPESCRIPT_MISSING" || error.code === "EBADJSON") {
957
+ if (error.code === "ETYPESCRIPT_MISSING" || error.code === "ETYPESCRIPT_API" || error.code === "EBADJSON") {
905
958
  console.error(error.message);
906
959
  process.exit(1);
907
960
  }
@@ -932,13 +985,27 @@ const checkComponentExists = (component, destPath, current = false) => {
932
985
  return fs.existsSync(componentPath);
933
986
  };
934
987
 
935
- const copyComponent = async (component, destPath, current = false) => {
988
+ /*
989
+ * Copy one component into the project — unless that would destroy edits.
990
+ *
991
+ * Components are copied in to be edited; that is what owning the code means.
992
+ * Until 2.7.2 this overwrote the folder unconditionally, so running `add` on a
993
+ * component that was already there — by hand, from a script, or by an agent
994
+ * through the MCP server's install_component — silently replaced whatever had
995
+ * been changed in it, and printed a success message while doing so.
996
+ *
997
+ * Now an installed component is compared with the record written when it was
998
+ * installed. Untouched, it is updated. Changed — or installed before there
999
+ * were records, and different from this version — it is left alone unless
1000
+ * `--force` is passed, and the user is told what differs and how to see more.
1001
+ *
1002
+ * Returns "installed", "updated", "unchanged" or "kept".
1003
+ */
1004
+ const copyComponent = async (component, destPath, current = false, { force = false } = {}) => {
936
1005
  try {
937
- const componentSrc = path.join(
938
- getSourcePath(current),
939
- folderFor(component, current),
940
- );
941
- const componentDest = path.join(destPath, folderFor(component, current));
1006
+ const folder = folderFor(component, current);
1007
+ const componentSrc = path.join(getSourcePath(current), folder);
1008
+ const componentDest = path.join(destPath, folder);
942
1009
 
943
1010
  if (!fs.existsSync(componentSrc)) {
944
1011
  throw new Error(
@@ -946,6 +1013,25 @@ const copyComponent = async (component, destPath, current = false) => {
946
1013
  );
947
1014
  }
948
1015
 
1016
+ let outcome = "installed";
1017
+ let previous = null;
1018
+ // The 1.x set is unmaintained and keeps its old behaviour.
1019
+ if (current && fs.existsSync(componentDest)) {
1020
+ const status = doctorBaseline().inspect(componentDest, {
1021
+ folder,
1022
+ kitDir: componentSrc,
1023
+ kitVersion: PACKAGE_VERSION,
1024
+ });
1025
+ previous = status.installedVersion;
1026
+
1027
+ if (status.destructive && !force) {
1028
+ reportKept(component, status);
1029
+ process.exitCode = 1;
1030
+ return "kept";
1031
+ }
1032
+ outcome = status.state === "pristine" && status.installedVersion === PACKAGE_VERSION ? "unchanged" : "updated";
1033
+ }
1034
+
949
1035
  // The whole folder travels, which includes passport.json and
950
1036
  // passport.manual.json. The passport is part of the component contract,
951
1037
  // not an add-on — a contract you opt into is not a contract.
@@ -953,23 +1039,65 @@ const copyComponent = async (component, destPath, current = false) => {
953
1039
  overwrite: true,
954
1040
  filter: (source) => !isTestPath(path.relative(componentSrc, source)),
955
1041
  });
956
- console.log(
957
- `✓ Component ${component} installed successfully ${
958
- component === "Grid"
959
- ? "This Version of Grid will be deprecated soon. Please Install The New Data Grid Component"
960
- : ""
961
- }`,
962
- );
1042
+
1043
+ // The record `doctor` and the next `add` compare against.
1044
+ if (current) {
1045
+ doctorBaseline().writeManifest(componentDest, {
1046
+ component,
1047
+ version: PACKAGE_VERSION,
1048
+ sourceDir: componentSrc,
1049
+ });
1050
+ }
1051
+
1052
+ if (outcome === "updated") {
1053
+ console.log(
1054
+ previous && previous !== PACKAGE_VERSION
1055
+ ? `${ui.check()} Component ${component} updated ${previous} → ${PACKAGE_VERSION}`
1056
+ : `${ui.check()} Component ${component} replaced with ${PACKAGE_VERSION}${force ? " (--force)" : ""}`,
1057
+ );
1058
+ } else if (outcome === "unchanged") {
1059
+ console.log(`${ui.check()} Component ${component} already up to date (${PACKAGE_VERSION})`);
1060
+ } else {
1061
+ console.log(
1062
+ `${ui.check()} Component ${component} installed successfully ${
1063
+ component === "Grid"
1064
+ ? "This Version of Grid will be deprecated soon. Please Install The New Data Grid Component"
1065
+ : ""
1066
+ }`,
1067
+ );
1068
+ }
1069
+ return outcome;
963
1070
  } catch (error) {
964
1071
  console.error(`Error installing component ${component}:`, error.message);
965
1072
  process.exit(1);
966
1073
  }
967
1074
  };
968
1075
 
1076
+ /* Loaded on first use, so commands that never touch components don't pay for it. */
1077
+ let baselineModule = null;
1078
+ const doctorBaseline = () => (baselineModule ??= require("./tools/doctor/baseline.cjs"));
1079
+
1080
+ /** Why a component was left alone, and the two ways forward. */
1081
+ const reportKept = (component, status) => {
1082
+ const list = (label, files) =>
1083
+ files.length ? ` ${label}: ${files.slice(0, 6).join(", ")}${files.length > 6 ? `, +${files.length - 6} more` : ""}` : null;
1084
+ console.error(
1085
+ status.state === "unknown"
1086
+ ? `${ui.cross()} ${component} was not replaced: it was installed before 2.7.2 and differs from ${PACKAGE_VERSION}, so it may hold your changes.`
1087
+ : `${ui.cross()} ${component} was not replaced: it has changes made since it was installed.`,
1088
+ );
1089
+ for (const line of [list("changed", status.changed), list("deleted", status.missing)].filter(Boolean)) {
1090
+ console.error(line);
1091
+ }
1092
+ console.error(` See what you changed and what ${PACKAGE_VERSION} changes: npx ${CLI_NAME} doctor ${component}`);
1093
+ console.error(` Replace it anyway, losing those changes: npx ${CLI_NAME} add ${component} --force`);
1094
+ };
1095
+
969
1096
  const installComponentWithDependencies = async (
970
1097
  component,
971
1098
  destPath,
972
1099
  current = false,
1100
+ options = {},
973
1101
  ) => {
974
1102
  // Get dependencies for the component
975
1103
  const dependencies = CONFIG.dependencies[component] || [];
@@ -984,14 +1112,14 @@ const installComponentWithDependencies = async (
984
1112
 
985
1113
  if (pendingInstalls.length === 0) {
986
1114
  console.log(
987
- `✓ ${component} and all its dependencies are already installed.`,
1115
+ `${ui.check()} ${component} and all its dependencies are already installed.`,
988
1116
  );
989
1117
  return;
990
1118
  }
991
1119
 
992
1120
  // Install all pending components
993
1121
  for (const comp of pendingInstalls) {
994
- await copyComponent(comp, destPath, current);
1122
+ await copyComponent(comp, destPath, current, options);
995
1123
  }
996
1124
 
997
1125
  if (dependencies.length > 0) {
@@ -1010,6 +1138,7 @@ const installMultipleComponents = async (
1010
1138
  components,
1011
1139
  destPath,
1012
1140
  current = false,
1141
+ options = {},
1013
1142
  ) => {
1014
1143
  const allComponentsToInstall = new Set();
1015
1144
 
@@ -1029,7 +1158,7 @@ const installMultipleComponents = async (
1029
1158
 
1030
1159
  if (pendingInstalls.length === 0) {
1031
1160
  console.log(
1032
- "✓ All selected components and their dependencies are already installed.",
1161
+ `${ui.check()} All selected components and their dependencies are already installed.`,
1033
1162
  );
1034
1163
  return;
1035
1164
  }
@@ -1038,7 +1167,7 @@ const installMultipleComponents = async (
1038
1167
 
1039
1168
  // Install all pending components
1040
1169
  for (const comp of pendingInstalls) {
1041
- await copyComponent(comp, destPath, current);
1170
+ await copyComponent(comp, destPath, current, options);
1042
1171
  }
1043
1172
 
1044
1173
  // Show dependency information
@@ -1060,95 +1189,44 @@ const installMultipleComponents = async (
1060
1189
  console.log(`\nFor documentation visit: ${CONFIG.docs}`);
1061
1190
  };
1062
1191
 
1063
- const interactiveComponentSelector = async (current = false) => {
1064
- return new Promise((resolve) => {
1065
- const rl = readline.createInterface({
1066
- input: process.stdin,
1067
- output: process.stdout,
1068
- });
1069
-
1070
- let selectedComponents = new Set();
1071
- let currentIndex = 0;
1072
-
1073
- const renderMenu = () => {
1074
- console.clear();
1075
- console.log("🚀 Component Installer - Interactive Mode");
1076
- console.log(
1077
- "Use ↑/↓ arrow keys to navigate, SPACE to select/deselect, ENTER to install\n",
1078
- );
1079
-
1080
- const availableComponents = getAvailableComponents(current);
1081
-
1082
- availableComponents.forEach((component, index) => {
1083
- const isSelected = selectedComponents.has(component);
1084
- const isCurrentIndex = index === currentIndex;
1085
- const deps = CONFIG.dependencies[component]
1086
- ? ` (requires: ${CONFIG.dependencies[component].join(", ")})`
1087
- : "";
1088
-
1089
- const prefix = isCurrentIndex ? ">" : " ";
1090
- const checkbox = isSelected ? "☑" : "☐";
1091
- const line = `${prefix} ${checkbox} ${component}${deps}`;
1092
-
1093
- if (isCurrentIndex) {
1094
- console.log(`\x1b[36m${line}\x1b[0m`); // Cyan highlight
1095
- } else {
1096
- console.log(line);
1097
- }
1098
- });
1099
-
1100
- console.log(`\nSelected: ${selectedComponents.size} components`);
1101
- console.log("Press ENTER to install selected components, or 'q' to quit");
1102
- };
1103
-
1104
- const handleKeyPress = (key) => {
1105
- switch (key) {
1106
- case "": // Up arrow
1107
- currentIndex = Math.max(0, currentIndex - 1);
1108
- renderMenu();
1109
- break;
1110
- case "": // Down arrow
1111
- currentIndex = Math.min(
1112
- getAvailableComponents(current).length - 1,
1113
- currentIndex + 1,
1114
- );
1115
- renderMenu();
1116
- break;
1117
- case " ": // Space bar
1118
- const component = getAvailableComponents(current)[currentIndex];
1119
- if (selectedComponents.has(component)) {
1120
- selectedComponents.delete(component);
1121
- } else {
1122
- selectedComponents.add(component);
1123
- }
1124
- renderMenu();
1125
- break;
1126
- case "\r": // Enter
1127
- if (selectedComponents.size > 0) {
1128
- rl.close();
1129
- resolve(Array.from(selectedComponents));
1130
- }
1131
- break;
1132
- case "q":
1133
- rl.close();
1134
- resolve([]);
1135
- break;
1136
- }
1137
- };
1138
-
1139
- // Enable raw mode to capture arrow keys
1140
- process.stdin.setRawMode(true);
1141
- process.stdin.resume();
1142
- process.stdin.setEncoding("utf8");
1143
- process.stdin.on("data", handleKeyPress);
1144
-
1145
- renderMenu();
1192
+ /*
1193
+ * Where each component sits in the docs' navigation, shown beside it in the
1194
+ * picker and searchable: typing "overlay" narrows the list to the overlays.
1195
+ */
1196
+ const GROUPS = {
1197
+ Inputs: ["Button", "Checkbox", "Combobox", "DatePicker", "FileUploader", "Input", "NumberInput", "RadioGroup", "Switch", "Textarea"],
1198
+ Data: ["DataGrid"],
1199
+ Overlays: ["Dialog", "Menu", "Modal", "Popover", "Tooltip"],
1200
+ Feedback: ["Alert", "Progress", "Skeleton", "Spinner", "Toaster"],
1201
+ Display: ["Accordion", "Avatar", "Badge", "Card"],
1202
+ Navigation: ["Breadcrumb", "Tabs"],
1203
+ };
1204
+ const groupOf = (component) =>
1205
+ Object.keys(GROUPS).find((group) => GROUPS[group].includes(component)) || "";
1206
+
1207
+ /** The one-line purpose a component's passport gives, for the picker's hint line. */
1208
+ const purposeOf = (component, current) => {
1209
+ if (!current) return "";
1210
+ const passport = readJson(path.join(SOURCE_PATH, folderFor(component, true), "passport.json"));
1211
+ return (passport && passport.purpose) || "";
1212
+ };
1146
1213
 
1147
- rl.on("close", () => {
1148
- process.stdin.setRawMode(false);
1149
- process.stdin.pause();
1214
+ const interactiveComponentSelector = async (current = false, destPath) => {
1215
+ const { multiselect } = require("./tools/prompt.cjs");
1216
+ const options = getAvailableComponents(current)
1217
+ .map((component) => ({
1218
+ value: component,
1219
+ tag: current ? groupOf(component) : "",
1220
+ hint: purposeOf(component, current),
1221
+ note: destPath && checkComponentExists(component, destPath, current) ? "installed" : "",
1222
+ }))
1223
+ // The docs' order of groups, alphabetical within each.
1224
+ .sort((a, b) => {
1225
+ const order = Object.keys(GROUPS);
1226
+ return order.indexOf(a.tag) - order.indexOf(b.tag) || a.value.localeCompare(b.value);
1150
1227
  });
1151
- });
1228
+ const picked = await multiselect({ message: "Which components?", options });
1229
+ return picked || [];
1152
1230
  };
1153
1231
 
1154
1232
  const parseMultipleComponents = (componentString) => {
@@ -1190,7 +1268,7 @@ const validateComponents = (components, current = false) => {
1190
1268
  * projects a week install this; breaking their scripts to tidy an argument
1191
1269
  * parser is not a trade worth making.
1192
1270
  */
1193
- const COMMANDS = new Set(["add", "list", "skill", "passport", "mcp", "secret", "design"]);
1271
+ const COMMANDS = new Set(["add", "list", "skill", "passport", "mcp", "secret", "design", "doctor"]);
1194
1272
 
1195
1273
  /**
1196
1274
  * Flags that take a separate value, so the token after one is that value and
@@ -1281,7 +1359,12 @@ const main = async () => {
1281
1359
  type: "string",
1282
1360
  })
1283
1361
  .option("force", {
1284
- describe: "Replace files you have edited locally in shared/ or the skill",
1362
+ describe: "Replace files you have edited locally: a component, shared/ or the skill",
1363
+ type: "boolean",
1364
+ default: false,
1365
+ })
1366
+ .option("json", {
1367
+ describe: "With doctor: print the report as JSON, for agents and CI",
1285
1368
  type: "boolean",
1286
1369
  default: false,
1287
1370
  })
@@ -1291,6 +1374,8 @@ const main = async () => {
1291
1374
  .example("$0 skill", "Install the agent skill for every supported agent")
1292
1375
  .example("$0 skill --for claude", "Only the Claude Code copy")
1293
1376
  .example("$0 passport --check", "Fail if a passport is stale (CI)")
1377
+ .example("$0 doctor", "What you changed, what upstream changed, what an update breaks")
1378
+ .example("$0 doctor DataGrid --json", "One component, as JSON")
1294
1379
  .example("$0 add Select --legacy", "Install from the 1.x set")
1295
1380
  .example("$0 add Button --dest app/ui", "Choose where they go")
1296
1381
  .example("$0 mcp", "Serve the catalogue to a coding agent over MCP")
@@ -1298,7 +1383,7 @@ const main = async () => {
1298
1383
  .example("$0 mcp install --for codex", "Only write the Codex config")
1299
1384
  .example("$0 secret set figma", "Store a Figma token in the OS credential store")
1300
1385
  .example("$0 design ./design.json", "Split a whole-file Figma export into design/")
1301
- .example("$0 -i", "Interactive selection")
1386
+ .example("$0 add", "Pick components interactively (same as -i)")
1302
1387
  .help().argv;
1303
1388
 
1304
1389
  /*
@@ -1364,16 +1449,26 @@ const main = async () => {
1364
1449
  return;
1365
1450
  }
1366
1451
 
1452
+ if (parsed && parsed.command === "doctor") {
1453
+ runDoctorCommand({ only: parsed.operands, dest: argv.dest, json: argv.json });
1454
+ return;
1455
+ }
1456
+
1367
1457
  if (parsed) {
1368
1458
  if (parsed.command === "list") argv.list = true;
1369
1459
  if (parsed.command === "skill") argv.skill = true;
1370
1460
  if (parsed.command === "passport") argv.passport = true;
1371
1461
  if (parsed.command === "add") {
1372
1462
  if (parsed.operands.length === 0) {
1373
- console.log(`Which component? Try: ${CLI_NAME} add Button (or: ${CLI_NAME} list)`);
1374
- return;
1463
+ // In a terminal, a bare `add` opens the picker; in a script it says what to type.
1464
+ if (process.stdin.isTTY && process.stdout.isTTY) argv.interactive = true;
1465
+ else {
1466
+ console.log(`Which component? Try: ${CLI_NAME} add Button (or: ${CLI_NAME} list)`);
1467
+ return;
1468
+ }
1469
+ } else {
1470
+ argv.add = parsed.operands.join(",");
1375
1471
  }
1376
- argv.add = parsed.operands.join(",");
1377
1472
  }
1378
1473
  } else if (argv.add || argv.list || argv.skill || argv.passport) {
1379
1474
  console.log(`Tip: the shorter form is \`${CLI_NAME} add <Component>\`.`);
@@ -1405,21 +1500,38 @@ const main = async () => {
1405
1500
 
1406
1501
  // Interactive mode
1407
1502
  if (argv.interactive) {
1408
- console.log("Starting interactive component selector...\n");
1409
- const selectedComponents = await interactiveComponentSelector(current);
1503
+ const destPath = resolveDestPath(argv.dest);
1504
+ const where = path.relative(process.cwd(), destPath) || ".";
1505
+ ui.intro(`GramproKit ${PACKAGE_VERSION}`);
1506
+ let selectedComponents;
1507
+ try {
1508
+ selectedComponents = await interactiveComponentSelector(current, destPath);
1509
+ } catch (error) {
1510
+ ui.outro(ui.c.red(error.message));
1511
+ process.exitCode = 1;
1512
+ return;
1513
+ }
1410
1514
 
1411
1515
  if (selectedComponents.length === 0) {
1412
- console.log("No components selected. Exiting...");
1516
+ ui.outro(ui.c.dim("Nothing installed."));
1413
1517
  return;
1414
1518
  }
1519
+ console.log(ui.c.gray(ui.s.bar));
1520
+ console.log(`${ui.c.gray(ui.s.bar)} ${ui.c.dim(`Installing into ${where}/`)}`);
1521
+ console.log("");
1415
1522
 
1416
- const destPath = resolveDestPath(argv.dest);
1417
1523
  await fs.ensureDir(destPath);
1418
1524
 
1419
1525
  await copySupportFiles(destPath, current, { force: argv.force });
1420
1526
 
1421
1527
  // Install selected components
1422
- await installMultipleComponents(selectedComponents, destPath, current);
1528
+ await installMultipleComponents(selectedComponents, destPath, current, { force: argv.force });
1529
+ console.log("");
1530
+ ui.outro(
1531
+ process.exitCode
1532
+ ? ui.c.yellow("Done, with the warnings above.")
1533
+ : `${ui.c.green("Done.")} Import them from ${ui.c.cyan(`${where}/`)}`,
1534
+ );
1423
1535
  return;
1424
1536
  }
1425
1537
 
@@ -1470,10 +1582,10 @@ const main = async () => {
1470
1582
  // Install components
1471
1583
  if (components.length === 1) {
1472
1584
  // Single component installation (existing behavior)
1473
- await installComponentWithDependencies(components[0], destPath, current);
1585
+ await installComponentWithDependencies(components[0], destPath, current, { force: argv.force });
1474
1586
  } else {
1475
1587
  // Multiple components installation
1476
- await installMultipleComponents(components, destPath, current);
1588
+ await installMultipleComponents(components, destPath, current, { force: argv.force });
1477
1589
  }
1478
1590
  };
1479
1591