react-doctor 0.0.35 → 0.0.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -40,13 +40,15 @@ npx -y react-doctor@latest . --verbose
40
40
 
41
41
  ## Install for your coding agent
42
42
 
43
- Teach your coding agent all 47+ React best practice rules:
43
+ Teach your coding agent all 47+ React best practice rules. Run this at your project root:
44
44
 
45
45
  ```bash
46
- curl -fsSL https://react.doctor/install-skill.sh | bash
46
+ npx -y react-doctor@latest install
47
47
  ```
48
48
 
49
- Supports Cursor, Claude Code, Amp Code, Codex, Gemini CLI, OpenCode, Windsurf, and Antigravity.
49
+ You'll be prompted to pick which detected agents to install for. Pass `--yes` to skip prompts and install for every detected agent.
50
+
51
+ Supports Claude Code, Codex, GitHub Copilot, Gemini CLI, Cursor, OpenCode, Factory Droid, and Pi.
50
52
 
51
53
  ## GitHub Actions
52
54
 
package/dist/cli.js CHANGED
@@ -1,19 +1,281 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "node:module";
3
- import fs, { existsSync, mkdirSync, mkdtempSync, readdirSync, writeFileSync } from "node:fs";
3
+ import fs, { accessSync, constants, cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
4
4
  import os, { tmpdir } from "node:os";
5
5
  import path, { join } from "node:path";
6
6
  import { Command } from "commander";
7
+ import { fileURLToPath } from "node:url";
8
+ import pc from "picocolors";
9
+ import basePrompts from "prompts";
10
+ import ora from "ora";
7
11
  import { randomUUID } from "node:crypto";
8
12
  import { performance } from "node:perf_hooks";
9
13
  import { execSync, spawn, spawnSync } from "node:child_process";
10
- import pc from "picocolors";
11
- import basePrompts from "prompts";
12
14
  import { main } from "knip";
13
15
  import { createOptions } from "knip/session";
14
- import { fileURLToPath } from "node:url";
15
- import ora from "ora";
16
16
 
17
+ //#region src/utils/detect-agents.ts
18
+ const SUPPORTED_AGENTS = {
19
+ claude: {
20
+ binaries: ["claude"],
21
+ displayName: "Claude Code",
22
+ skillDir: ".claude/skills"
23
+ },
24
+ codex: {
25
+ binaries: ["codex"],
26
+ displayName: "Codex",
27
+ skillDir: ".codex/skills"
28
+ },
29
+ copilot: {
30
+ binaries: ["copilot"],
31
+ displayName: "GitHub Copilot",
32
+ skillDir: ".github/copilot/skills"
33
+ },
34
+ gemini: {
35
+ binaries: ["gemini"],
36
+ displayName: "Gemini CLI",
37
+ skillDir: ".gemini/skills"
38
+ },
39
+ cursor: {
40
+ binaries: ["cursor", "agent"],
41
+ displayName: "Cursor",
42
+ skillDir: ".cursor/skills"
43
+ },
44
+ opencode: {
45
+ binaries: ["opencode"],
46
+ displayName: "OpenCode",
47
+ skillDir: ".opencode/skills"
48
+ },
49
+ droid: {
50
+ binaries: ["droid"],
51
+ displayName: "Factory Droid",
52
+ skillDir: ".droid/skills"
53
+ },
54
+ pi: {
55
+ binaries: ["pi", "omegon"],
56
+ displayName: "Pi",
57
+ skillDir: ".pi/skills"
58
+ }
59
+ };
60
+ const ALL_SUPPORTED_AGENTS = Object.keys(SUPPORTED_AGENTS);
61
+ const isCommandAvailable = (command) => {
62
+ const pathDirectories = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean);
63
+ for (const directory of pathDirectories) {
64
+ const binaryPath = path.join(directory, command);
65
+ try {
66
+ if (statSync(binaryPath).isFile()) {
67
+ accessSync(binaryPath, constants.X_OK);
68
+ return true;
69
+ }
70
+ } catch {}
71
+ }
72
+ return false;
73
+ };
74
+ const detectAvailableAgents = () => ALL_SUPPORTED_AGENTS.filter((agent) => SUPPORTED_AGENTS[agent].binaries.some(isCommandAvailable));
75
+ const toDisplayName = (agent) => SUPPORTED_AGENTS[agent].displayName;
76
+ const toSkillDir = (agent) => SUPPORTED_AGENTS[agent].skillDir;
77
+
78
+ //#endregion
79
+ //#region src/utils/highlighter.ts
80
+ const highlighter = {
81
+ error: pc.red,
82
+ warn: pc.yellow,
83
+ info: pc.cyan,
84
+ success: pc.green,
85
+ dim: pc.dim
86
+ };
87
+
88
+ //#endregion
89
+ //#region src/utils/install-skill-for-agent.ts
90
+ const installSkillForAgent = (projectRoot, agent, skillSourceDirectory, skillName) => {
91
+ const installedSkillDirectory = path.join(projectRoot, toSkillDir(agent), skillName);
92
+ rmSync(installedSkillDirectory, {
93
+ recursive: true,
94
+ force: true
95
+ });
96
+ mkdirSync(path.dirname(installedSkillDirectory), { recursive: true });
97
+ cpSync(skillSourceDirectory, installedSkillDirectory, { recursive: true });
98
+ return installedSkillDirectory;
99
+ };
100
+
101
+ //#endregion
102
+ //#region src/utils/logger.ts
103
+ const logger = {
104
+ error(...args) {
105
+ console.log(highlighter.error(args.join(" ")));
106
+ },
107
+ warn(...args) {
108
+ console.log(highlighter.warn(args.join(" ")));
109
+ },
110
+ info(...args) {
111
+ console.log(highlighter.info(args.join(" ")));
112
+ },
113
+ success(...args) {
114
+ console.log(highlighter.success(args.join(" ")));
115
+ },
116
+ dim(...args) {
117
+ console.log(highlighter.dim(args.join(" ")));
118
+ },
119
+ log(...args) {
120
+ console.log(args.join(" "));
121
+ },
122
+ break() {
123
+ console.log("");
124
+ }
125
+ };
126
+
127
+ //#endregion
128
+ //#region src/utils/should-auto-select-current-choice.ts
129
+ const shouldAutoSelectCurrentChoice = (choiceStates, cursor) => {
130
+ if (choiceStates.some((choiceState) => choiceState.selected)) return false;
131
+ const currentChoice = choiceStates[cursor];
132
+ return Boolean(currentChoice) && !currentChoice.disabled;
133
+ };
134
+
135
+ //#endregion
136
+ //#region src/utils/should-select-all-choices.ts
137
+ const shouldSelectAllChoices = (choiceStates) => {
138
+ return choiceStates.filter((choiceState) => !choiceState.disabled).some((choiceState) => choiceState.selected !== true);
139
+ };
140
+
141
+ //#endregion
142
+ //#region src/utils/prompts.ts
143
+ const require = createRequire(import.meta.url);
144
+ const PROMPTS_MULTISELECT_MODULE_PATH = "prompts/lib/elements/multiselect";
145
+ const PROMPTS_SELECT_MODULE_PATH = "prompts/lib/elements/select";
146
+ let didPatchMultiselectToggleAll = false;
147
+ let didPatchMultiselectSubmit = false;
148
+ let didPatchSelectBanner = false;
149
+ const selectBannerMap = /* @__PURE__ */ new Map();
150
+ const onCancel = () => {
151
+ logger.break();
152
+ logger.log("Cancelled.");
153
+ logger.dim("Run `npx react-doctor@latest --fix` to fix issues.");
154
+ logger.break();
155
+ process.exit(0);
156
+ };
157
+ const patchMultiselectToggleAll = () => {
158
+ if (didPatchMultiselectToggleAll) return;
159
+ didPatchMultiselectToggleAll = true;
160
+ const multiselectPromptConstructor = require(PROMPTS_MULTISELECT_MODULE_PATH);
161
+ multiselectPromptConstructor.prototype.toggleAll = function() {
162
+ const isCurrentChoiceDisabled = Boolean(this.value[this.cursor]?.disabled);
163
+ if (this.maxChoices !== void 0 || isCurrentChoiceDisabled) {
164
+ this.bell();
165
+ return;
166
+ }
167
+ const shouldSelectAllEnabledChoices = shouldSelectAllChoices(this.value);
168
+ for (const choiceState of this.value) {
169
+ if (choiceState.disabled) continue;
170
+ choiceState.selected = shouldSelectAllEnabledChoices;
171
+ }
172
+ this.render();
173
+ };
174
+ };
175
+ const patchMultiselectSubmit = () => {
176
+ if (didPatchMultiselectSubmit) return;
177
+ didPatchMultiselectSubmit = true;
178
+ const multiselectPromptConstructor = require(PROMPTS_MULTISELECT_MODULE_PATH);
179
+ const originalSubmit = multiselectPromptConstructor.prototype.submit;
180
+ multiselectPromptConstructor.prototype.submit = function() {
181
+ if (shouldAutoSelectCurrentChoice(this.value, this.cursor)) this.value[this.cursor].selected = true;
182
+ originalSubmit.call(this);
183
+ };
184
+ };
185
+ const patchSelectBanner = () => {
186
+ if (didPatchSelectBanner) return;
187
+ didPatchSelectBanner = true;
188
+ const selectConstructor = require(PROMPTS_SELECT_MODULE_PATH);
189
+ const promptsClear = require("prompts/lib/util/clear");
190
+ const originalRender = selectConstructor.prototype.render;
191
+ selectConstructor.prototype.render = function() {
192
+ originalRender.call(this);
193
+ const banner = selectBannerMap.get(this.cursor);
194
+ if (!banner || this.closed || this.done) return;
195
+ this.out.write(promptsClear(this.outputText, this.out.columns));
196
+ this.outputText = `${banner}\n\n${this.outputText}`;
197
+ this.out.write(this.outputText);
198
+ };
199
+ };
200
+ const prompts = (questions) => {
201
+ patchMultiselectToggleAll();
202
+ patchMultiselectSubmit();
203
+ patchSelectBanner();
204
+ return basePrompts(questions, { onCancel });
205
+ };
206
+
207
+ //#endregion
208
+ //#region src/utils/spinner.ts
209
+ let sharedInstance = null;
210
+ let activeCount = 0;
211
+ const pendingTexts = /* @__PURE__ */ new Set();
212
+ const finalize = (method, originalText, displayText) => {
213
+ pendingTexts.delete(originalText);
214
+ activeCount--;
215
+ if (activeCount <= 0 || !sharedInstance) {
216
+ sharedInstance?.[method](displayText);
217
+ sharedInstance = null;
218
+ activeCount = 0;
219
+ return;
220
+ }
221
+ sharedInstance.stop();
222
+ ora(displayText).start()[method](displayText);
223
+ const [remainingText] = pendingTexts;
224
+ if (remainingText) sharedInstance.text = remainingText;
225
+ sharedInstance.start();
226
+ };
227
+ const spinner = (text) => ({ start() {
228
+ activeCount++;
229
+ pendingTexts.add(text);
230
+ if (!sharedInstance) sharedInstance = ora({ text }).start();
231
+ else sharedInstance.text = text;
232
+ return {
233
+ succeed: (displayText) => finalize("succeed", text, displayText),
234
+ fail: (displayText) => finalize("fail", text, displayText)
235
+ };
236
+ } });
237
+
238
+ //#endregion
239
+ //#region src/install-skill.ts
240
+ const SKILL_NAME = "react-doctor";
241
+ const getSkillSourceDirectory = () => {
242
+ const distDirectory = path.dirname(fileURLToPath(import.meta.url));
243
+ return path.join(distDirectory, "skills", SKILL_NAME);
244
+ };
245
+ const runInstallSkill = async (options = {}) => {
246
+ const projectRoot = process.cwd();
247
+ const sourceDir = getSkillSourceDirectory();
248
+ if (!existsSync(path.join(sourceDir, "SKILL.md"))) {
249
+ logger.error(`Could not locate the ${SKILL_NAME} skill bundled with this package.`);
250
+ process.exitCode = 1;
251
+ return;
252
+ }
253
+ const detectedAgents = detectAvailableAgents();
254
+ if (detectedAgents.length === 0) {
255
+ logger.error("No supported coding agents detected on your PATH.");
256
+ logger.dim(" Supported: Claude Code, Codex, GitHub Copilot, Gemini CLI, Cursor, OpenCode, Factory Droid, Pi.");
257
+ process.exitCode = 1;
258
+ return;
259
+ }
260
+ const selectedAgents = Boolean(options.yes) || !process.stdin.isTTY ? detectedAgents : (await prompts({
261
+ type: "multiselect",
262
+ name: "agents",
263
+ message: `Install the ${highlighter.info(SKILL_NAME)} skill for:`,
264
+ choices: detectedAgents.map((agent) => ({
265
+ title: toDisplayName(agent),
266
+ value: agent,
267
+ selected: true
268
+ })),
269
+ instructions: false,
270
+ min: 1
271
+ })).agents ?? [];
272
+ if (selectedAgents.length === 0) return;
273
+ const installSpinner = spinner(`Installing ${SKILL_NAME} skill...`).start();
274
+ for (const agent of selectedAgents) installSkillForAgent(projectRoot, agent, sourceDir, SKILL_NAME);
275
+ installSpinner.succeed(`${SKILL_NAME} skill installed for ${selectedAgents.map(toDisplayName).join(", ")}.`);
276
+ };
277
+
278
+ //#endregion
17
279
  //#region src/constants.ts
18
280
  const SOURCE_FILE_PATTERN = /\.(tsx?|jsx?)$/;
19
281
  const JSX_FILE_PATTERN = /\.(tsx|jsx)$/;
@@ -140,16 +402,6 @@ const calculateScore = async (diagnostics) => {
140
402
  }
141
403
  };
142
404
 
143
- //#endregion
144
- //#region src/utils/highlighter.ts
145
- const highlighter = {
146
- error: pc.red,
147
- warn: pc.yellow,
148
- info: pc.cyan,
149
- success: pc.green,
150
- dim: pc.dim
151
- };
152
-
153
405
  //#endregion
154
406
  //#region src/utils/colorize-by-score.ts
155
407
  const colorizeByScore = (text, score) => {
@@ -803,32 +1055,6 @@ const discoverProject = (directory) => {
803
1055
  };
804
1056
  };
805
1057
 
806
- //#endregion
807
- //#region src/utils/logger.ts
808
- const logger = {
809
- error(...args) {
810
- console.log(highlighter.error(args.join(" ")));
811
- },
812
- warn(...args) {
813
- console.log(highlighter.warn(args.join(" ")));
814
- },
815
- info(...args) {
816
- console.log(highlighter.info(args.join(" ")));
817
- },
818
- success(...args) {
819
- console.log(highlighter.success(args.join(" ")));
820
- },
821
- dim(...args) {
822
- console.log(highlighter.dim(args.join(" ")));
823
- },
824
- log(...args) {
825
- console.log(args.join(" "));
826
- },
827
- break() {
828
- console.log("");
829
- }
830
- };
831
-
832
1058
  //#endregion
833
1059
  //#region src/utils/framed-box.ts
834
1060
  const createFramedLine = (plainText, renderedText = plainText) => ({
@@ -909,86 +1135,6 @@ const loadConfig = (rootDirectory) => {
909
1135
  return null;
910
1136
  };
911
1137
 
912
- //#endregion
913
- //#region src/utils/should-auto-select-current-choice.ts
914
- const shouldAutoSelectCurrentChoice = (choiceStates, cursor) => {
915
- if (choiceStates.some((choiceState) => choiceState.selected)) return false;
916
- const currentChoice = choiceStates[cursor];
917
- return Boolean(currentChoice) && !currentChoice.disabled;
918
- };
919
-
920
- //#endregion
921
- //#region src/utils/should-select-all-choices.ts
922
- const shouldSelectAllChoices = (choiceStates) => {
923
- return choiceStates.filter((choiceState) => !choiceState.disabled).some((choiceState) => choiceState.selected !== true);
924
- };
925
-
926
- //#endregion
927
- //#region src/utils/prompts.ts
928
- const require = createRequire(import.meta.url);
929
- const PROMPTS_MULTISELECT_MODULE_PATH = "prompts/lib/elements/multiselect";
930
- const PROMPTS_SELECT_MODULE_PATH = "prompts/lib/elements/select";
931
- let didPatchMultiselectToggleAll = false;
932
- let didPatchMultiselectSubmit = false;
933
- let didPatchSelectBanner = false;
934
- const selectBannerMap = /* @__PURE__ */ new Map();
935
- const onCancel = () => {
936
- logger.break();
937
- logger.log("Cancelled.");
938
- logger.dim("Run `npx react-doctor@latest --fix` to fix issues.");
939
- logger.break();
940
- process.exit(0);
941
- };
942
- const patchMultiselectToggleAll = () => {
943
- if (didPatchMultiselectToggleAll) return;
944
- didPatchMultiselectToggleAll = true;
945
- const multiselectPromptConstructor = require(PROMPTS_MULTISELECT_MODULE_PATH);
946
- multiselectPromptConstructor.prototype.toggleAll = function() {
947
- const isCurrentChoiceDisabled = Boolean(this.value[this.cursor]?.disabled);
948
- if (this.maxChoices !== void 0 || isCurrentChoiceDisabled) {
949
- this.bell();
950
- return;
951
- }
952
- const shouldSelectAllEnabledChoices = shouldSelectAllChoices(this.value);
953
- for (const choiceState of this.value) {
954
- if (choiceState.disabled) continue;
955
- choiceState.selected = shouldSelectAllEnabledChoices;
956
- }
957
- this.render();
958
- };
959
- };
960
- const patchMultiselectSubmit = () => {
961
- if (didPatchMultiselectSubmit) return;
962
- didPatchMultiselectSubmit = true;
963
- const multiselectPromptConstructor = require(PROMPTS_MULTISELECT_MODULE_PATH);
964
- const originalSubmit = multiselectPromptConstructor.prototype.submit;
965
- multiselectPromptConstructor.prototype.submit = function() {
966
- if (shouldAutoSelectCurrentChoice(this.value, this.cursor)) this.value[this.cursor].selected = true;
967
- originalSubmit.call(this);
968
- };
969
- };
970
- const patchSelectBanner = () => {
971
- if (didPatchSelectBanner) return;
972
- didPatchSelectBanner = true;
973
- const selectConstructor = require(PROMPTS_SELECT_MODULE_PATH);
974
- const promptsClear = require("prompts/lib/util/clear");
975
- const originalRender = selectConstructor.prototype.render;
976
- selectConstructor.prototype.render = function() {
977
- originalRender.call(this);
978
- const banner = selectBannerMap.get(this.cursor);
979
- if (!banner || this.closed || this.done) return;
980
- this.out.write(promptsClear(this.outputText, this.out.columns));
981
- this.outputText = `${banner}\n\n${this.outputText}`;
982
- this.out.write(this.outputText);
983
- };
984
- };
985
- const prompts = (questions) => {
986
- patchMultiselectToggleAll();
987
- patchMultiselectSubmit();
988
- patchSelectBanner();
989
- return basePrompts(questions, { onCancel });
990
- };
991
-
992
1138
  //#endregion
993
1139
  //#region src/utils/resolve-compatible-node.ts
994
1140
  const parseNodeVersion = (versionString) => {
@@ -1386,6 +1532,21 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, customRul
1386
1532
  "react-doctor/query-no-query-in-effect": "warn",
1387
1533
  "react-doctor/query-mutation-missing-invalidation": "warn",
1388
1534
  "react-doctor/query-no-usequery-for-mutation": "warn",
1535
+ "react-doctor/no-inline-bounce-easing": "warn",
1536
+ "react-doctor/no-z-index-9999": "warn",
1537
+ "react-doctor/no-inline-exhaustive-style": "warn",
1538
+ "react-doctor/no-side-tab-border": "warn",
1539
+ "react-doctor/no-pure-black-background": "warn",
1540
+ "react-doctor/no-gradient-text": "warn",
1541
+ "react-doctor/no-dark-mode-glow": "warn",
1542
+ "react-doctor/no-justified-text": "warn",
1543
+ "react-doctor/no-tiny-text": "warn",
1544
+ "react-doctor/no-wide-letter-spacing": "warn",
1545
+ "react-doctor/no-gray-on-colored-background": "warn",
1546
+ "react-doctor/no-layout-transition-inline": "warn",
1547
+ "react-doctor/no-disabled-zoom": "error",
1548
+ "react-doctor/no-outline-none": "warn",
1549
+ "react-doctor/no-long-transition-duration": "warn",
1389
1550
  "react-doctor/async-parallel": "warn",
1390
1551
  ...framework === "nextjs" ? NEXTJS_RULES : {},
1391
1552
  ...framework === "expo" || framework === "react-native" ? REACT_NATIVE_RULES : {},
@@ -1507,6 +1668,21 @@ const RULE_CATEGORY_MAP = {
1507
1668
  "react-doctor/query-no-query-in-effect": "TanStack Query",
1508
1669
  "react-doctor/query-mutation-missing-invalidation": "TanStack Query",
1509
1670
  "react-doctor/query-no-usequery-for-mutation": "TanStack Query",
1671
+ "react-doctor/no-inline-bounce-easing": "Performance",
1672
+ "react-doctor/no-z-index-9999": "Architecture",
1673
+ "react-doctor/no-inline-exhaustive-style": "Architecture",
1674
+ "react-doctor/no-side-tab-border": "Architecture",
1675
+ "react-doctor/no-pure-black-background": "Architecture",
1676
+ "react-doctor/no-gradient-text": "Architecture",
1677
+ "react-doctor/no-dark-mode-glow": "Architecture",
1678
+ "react-doctor/no-justified-text": "Accessibility",
1679
+ "react-doctor/no-tiny-text": "Accessibility",
1680
+ "react-doctor/no-wide-letter-spacing": "Architecture",
1681
+ "react-doctor/no-gray-on-colored-background": "Accessibility",
1682
+ "react-doctor/no-layout-transition-inline": "Performance",
1683
+ "react-doctor/no-disabled-zoom": "Accessibility",
1684
+ "react-doctor/no-outline-none": "Accessibility",
1685
+ "react-doctor/no-long-transition-duration": "Performance",
1510
1686
  "react-doctor/js-flatmap-filter": "Performance",
1511
1687
  "react-doctor/async-parallel": "Performance",
1512
1688
  "react-doctor/rn-no-raw-text": "React Native",
@@ -1565,6 +1741,21 @@ const RULE_HELP_MAP = {
1565
1741
  "prefer-dynamic-import": "Use `const Component = dynamic(() => import('library'), { ssr: false })` from next/dynamic or React.lazy()",
1566
1742
  "use-lazy-motion": "Use `import { LazyMotion, m } from \"framer-motion\"` with `domAnimation` features — saves ~30kb",
1567
1743
  "no-undeferred-third-party": "Use `next/script` with `strategy=\"lazyOnload\"` or add the `defer` attribute",
1744
+ "no-inline-bounce-easing": "Use `cubic-bezier(0.16, 1, 0.3, 1)` (ease-out-expo) for natural deceleration — objects in the real world don't bounce",
1745
+ "no-z-index-9999": "Define a z-index scale in your design tokens (e.g. dropdown: 10, modal: 20, toast: 30). Create a new stacking context with `isolation: isolate` instead of escalating values",
1746
+ "no-inline-exhaustive-style": "Move styles to a CSS class, CSS module, Tailwind utilities, or a styled component — inline objects with many properties hurt readability and create new references every render",
1747
+ "no-side-tab-border": "Use a subtler accent (box-shadow inset, background gradient, or border-bottom) instead of a thick one-sided border",
1748
+ "no-pure-black-background": "Tint the background slightly toward your brand hue — e.g. `#0a0a0f` or Tailwind's `bg-gray-950`. Pure black looks harsh on modern displays",
1749
+ "no-gradient-text": "Use solid text colors for readability. If you need emphasis, use font weight, size, or a distinct color instead of gradients",
1750
+ "no-dark-mode-glow": "Use a subtle `box-shadow` with neutral colors for depth, or `border` with low opacity. Colored glows on dark backgrounds are the default AI-generated aesthetic",
1751
+ "no-justified-text": "Use `text-align: left` for body text, or add `hyphens: auto` and `overflow-wrap: break-word` if you must justify",
1752
+ "no-tiny-text": "Use at least 12px for body content, 16px is ideal. Small text is hard to read, especially on high-DPI mobile screens",
1753
+ "no-wide-letter-spacing": "Reserve wide tracking (letter-spacing > 0.05em) for short uppercase labels, navigation items, and buttons — not body text",
1754
+ "no-gray-on-colored-background": "Use a darker shade of the background color for text, or white/near-white for contrast. Gray text on colored backgrounds looks washed out",
1755
+ "no-layout-transition-inline": "Use `transform` and `opacity` for transitions — they run on the compositor thread. For height animations, use `grid-template-rows: 0fr → 1fr`",
1756
+ "no-disabled-zoom": "Remove `user-scalable=no` and `maximum-scale` from the viewport meta tag. If your layout breaks at 200% zoom, fix the layout — don't punish users with disabilities",
1757
+ "no-outline-none": "Use `:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px }` to show focus only for keyboard users while hiding it for mouse clicks",
1758
+ "no-long-transition-duration": "Keep UI transitions under 1s — 100-150ms for instant feedback, 200-300ms for state changes, 300-500ms for layout changes. Use longer durations only for page-load hero animations",
1568
1759
  "no-array-index-as-key": "Use a stable unique identifier: `key={item.id}` or `key={item.slug}` — index keys break on reorder/filter",
1569
1760
  "rendering-conditional-render": "Change to `{items.length > 0 && <List />}` or use a ternary: `{items.length ? <List /> : null}`",
1570
1761
  "no-prevent-default": "Use `<form action={serverAction}>` (works without JS) or `<button>` instead of `<a>` with preventDefault",
@@ -1762,37 +1953,6 @@ const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompil
1762
1953
  }
1763
1954
  };
1764
1955
 
1765
- //#endregion
1766
- //#region src/utils/spinner.ts
1767
- let sharedInstance = null;
1768
- let activeCount = 0;
1769
- const pendingTexts = /* @__PURE__ */ new Set();
1770
- const finalize = (method, originalText, displayText) => {
1771
- pendingTexts.delete(originalText);
1772
- activeCount--;
1773
- if (activeCount <= 0 || !sharedInstance) {
1774
- sharedInstance?.[method](displayText);
1775
- sharedInstance = null;
1776
- activeCount = 0;
1777
- return;
1778
- }
1779
- sharedInstance.stop();
1780
- ora(displayText).start()[method](displayText);
1781
- const [remainingText] = pendingTexts;
1782
- if (remainingText) sharedInstance.text = remainingText;
1783
- sharedInstance.start();
1784
- };
1785
- const spinner = (text) => ({ start() {
1786
- activeCount++;
1787
- pendingTexts.add(text);
1788
- if (!sharedInstance) sharedInstance = ora({ text }).start();
1789
- else sharedInstance.text = text;
1790
- return {
1791
- succeed: (displayText) => finalize("succeed", text, displayText),
1792
- fail: (displayText) => finalize("fail", text, displayText)
1793
- };
1794
- } });
1795
-
1796
1956
  //#endregion
1797
1957
  //#region src/scan.ts
1798
1958
  const SEVERITY_ORDER = {
@@ -2352,7 +2512,7 @@ const promptProjectSelection = async (workspacePackages, rootDirectory) => {
2352
2512
 
2353
2513
  //#endregion
2354
2514
  //#region src/cli.ts
2355
- const VERSION = "0.0.35";
2515
+ const VERSION = "0.0.37";
2356
2516
  const VALID_FAIL_ON_LEVELS = new Set([
2357
2517
  "error",
2358
2518
  "warning",
@@ -2510,6 +2670,13 @@ const program = new Command().name("react-doctor").description("Diagnose React c
2510
2670
  ${highlighter.dim("Learn more:")}
2511
2671
  ${highlighter.info("https://github.com/millionco/react-doctor")}
2512
2672
  `);
2673
+ program.command("install").description("Install the react-doctor skill into your coding agents").option("-y, --yes", "skip prompts, install for all detected agents").action(async (options) => {
2674
+ try {
2675
+ await runInstallSkill({ yes: options.yes });
2676
+ } catch (error) {
2677
+ handleError(error);
2678
+ }
2679
+ });
2513
2680
  const main$1 = async () => {
2514
2681
  await program.parseAsync();
2515
2682
  };