Skip to content

Commit 051a02c

Browse files
committed
refactor: update constants and improve score calculation logic with proxy fetch
1 parent bf21a87 commit 051a02c

File tree

4 files changed

+229
-16
lines changed

4 files changed

+229
-16
lines changed

packages/react-doctor/src/constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export const SHARE_BASE_URL = "https://www.react.doctor/share";
2626

2727
export const OPEN_BASE_URL = "https://www.react.doctor/open";
2828

29-
export const INSTALL_SKILL_URL = "https://www.react.doctor/install-skill";
29+
export const FETCH_TIMEOUT_MS = 10_000;
3030

3131
export const GIT_LS_FILES_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
3232

packages/react-doctor/src/utils/calculate-score.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
SCORE_OK_THRESHOLD,
77
} from "../constants.js";
88
import type { Diagnostic, EstimatedScoreResult, ScoreResult } from "../types.js";
9+
import { proxyFetch } from "./proxy-fetch.js";
910

1011
const ERROR_RULE_PENALTY = 1.5;
1112
const WARNING_RULE_PENALTY = 0.75;
@@ -66,7 +67,7 @@ const estimateScoreLocally = (diagnostics: Diagnostic[]): EstimatedScoreResult =
6667

6768
export const calculateScore = async (diagnostics: Diagnostic[]): Promise<ScoreResult | null> => {
6869
try {
69-
const response = await fetch(SCORE_API_URL, {
70+
const response = await proxyFetch(SCORE_API_URL, {
7071
method: "POST",
7172
headers: { "Content-Type": "application/json" },
7273
body: JSON.stringify({ diagnostics }),
@@ -84,7 +85,7 @@ export const fetchEstimatedScore = async (
8485
diagnostics: Diagnostic[],
8586
): Promise<EstimatedScoreResult | null> => {
8687
try {
87-
const response = await fetch(ESTIMATE_SCORE_API_URL, {
88+
const response = await proxyFetch(ESTIMATE_SCORE_API_URL, {
8889
method: "POST",
8990
headers: { "Content-Type": "application/json" },
9091
body: JSON.stringify({ diagnostics }),
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { execSync } from "node:child_process";
2+
import { FETCH_TIMEOUT_MS } from "../constants.js";
3+
4+
const readNpmConfigValue = (key: string): string | undefined => {
5+
try {
6+
const value = execSync(`npm config get ${key}`, {
7+
encoding: "utf-8",
8+
stdio: ["pipe", "pipe", "ignore"],
9+
}).trim();
10+
if (value && value !== "null" && value !== "undefined") return value;
11+
} catch {}
12+
return undefined;
13+
};
14+
15+
const resolveProxyUrl = (): string | undefined =>
16+
process.env.HTTPS_PROXY ??
17+
process.env.https_proxy ??
18+
process.env.HTTP_PROXY ??
19+
process.env.http_proxy ??
20+
readNpmConfigValue("https-proxy") ??
21+
readNpmConfigValue("proxy");
22+
23+
let isProxyUrlResolved = false;
24+
let resolvedProxyUrl: string | undefined;
25+
26+
const getProxyUrl = (): string | undefined => {
27+
if (isProxyUrlResolved) return resolvedProxyUrl;
28+
isProxyUrlResolved = true;
29+
resolvedProxyUrl = resolveProxyUrl();
30+
return resolvedProxyUrl;
31+
};
32+
33+
const createProxyDispatcher = async (proxyUrl: string): Promise<object | null> => {
34+
try {
35+
// @ts-expect-error undici is bundled with Node.js 18+ but lacks standalone type declarations
36+
const { ProxyAgent } = await import("undici");
37+
return new ProxyAgent(proxyUrl);
38+
} catch {
39+
return null;
40+
}
41+
};
42+
43+
// HACK: Node.js's global fetch (undici) accepts `dispatcher` for proxy routing,
44+
// which isn't part of the standard RequestInit type.
45+
export const proxyFetch = async (url: string | URL, init?: RequestInit): Promise<Response> => {
46+
const controller = new AbortController();
47+
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
48+
49+
try {
50+
const proxyUrl = getProxyUrl();
51+
const dispatcher = proxyUrl ? await createProxyDispatcher(proxyUrl) : null;
52+
53+
return await fetch(
54+
url,
55+
{ ...init, signal: controller.signal, ...(dispatcher ? { dispatcher } : {}) } as RequestInit,
56+
);
57+
} finally {
58+
clearTimeout(timeoutId);
59+
}
60+
};

packages/react-doctor/src/utils/skill-prompt.ts

Lines changed: 165 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,66 @@
11
import { execSync } from "node:child_process";
2-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
33
import { homedir } from "node:os";
44
import { join } from "node:path";
5-
import { INSTALL_SKILL_URL } from "../constants.js";
65
import { highlighter } from "./highlighter.js";
76
import { logger } from "./logger.js";
87
import { prompts } from "./prompts.js";
98

10-
const CONFIG_DIRECTORY = join(homedir(), ".react-doctor");
9+
const HOME_DIRECTORY = homedir();
10+
const CONFIG_DIRECTORY = join(HOME_DIRECTORY, ".react-doctor");
1111
const CONFIG_FILE = join(CONFIG_DIRECTORY, "config.json");
1212

13+
const SKILL_NAME = "react-doctor";
14+
const WINDSURF_MARKER = "# React Doctor";
15+
16+
const SKILL_DESCRIPTION =
17+
"Run after making React changes to catch issues early. Use when reviewing code, finishing a feature, or fixing bugs in a React project.";
18+
19+
const SKILL_BODY = `Scans your React codebase for security, performance, correctness, and architecture issues. Outputs a 0-100 score with actionable diagnostics.
20+
21+
## Usage
22+
23+
\`\`\`bash
24+
npx -y react-doctor@latest . --verbose --diff
25+
\`\`\`
26+
27+
## Workflow
28+
29+
Run after making changes to catch issues early. Fix errors first, then re-run to verify the score improved.`;
30+
31+
const SKILL_CONTENT = `---
32+
name: ${SKILL_NAME}
33+
description: ${SKILL_DESCRIPTION}
34+
version: 1.0.0
35+
---
36+
37+
# React Doctor
38+
39+
${SKILL_BODY}
40+
`;
41+
42+
const AGENTS_CONTENT = `# React Doctor
43+
44+
${SKILL_DESCRIPTION}
45+
46+
${SKILL_BODY}
47+
`;
48+
49+
const CODEX_AGENT_CONFIG = `interface:
50+
display_name: "${SKILL_NAME}"
51+
short_description: "Diagnose and fix React codebase health issues"
52+
`;
53+
1354
interface SkillPromptConfig {
1455
skillPromptDismissed?: boolean;
1556
}
1657

58+
interface SkillTarget {
59+
name: string;
60+
detect: () => boolean;
61+
install: () => void;
62+
}
63+
1764
const readSkillPromptConfig = (): SkillPromptConfig => {
1865
try {
1966
if (!existsSync(CONFIG_FILE)) return {};
@@ -25,20 +72,127 @@ const readSkillPromptConfig = (): SkillPromptConfig => {
2572

2673
const writeSkillPromptConfig = (config: SkillPromptConfig): void => {
2774
try {
28-
if (!existsSync(CONFIG_DIRECTORY)) {
29-
mkdirSync(CONFIG_DIRECTORY, { recursive: true });
30-
}
75+
mkdirSync(CONFIG_DIRECTORY, { recursive: true });
3176
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
3277
} catch {}
3378
};
3479

80+
const writeSkillFiles = (directory: string): void => {
81+
mkdirSync(directory, { recursive: true });
82+
writeFileSync(join(directory, "SKILL.md"), SKILL_CONTENT);
83+
writeFileSync(join(directory, "AGENTS.md"), AGENTS_CONTENT);
84+
};
85+
86+
const isCommandAvailable = (command: string): boolean => {
87+
try {
88+
const whichCommand = process.platform === "win32" ? "where" : "which";
89+
execSync(`${whichCommand} ${command}`, { stdio: "ignore" });
90+
return true;
91+
} catch {
92+
return false;
93+
}
94+
};
95+
96+
const SKILL_TARGETS: SkillTarget[] = [
97+
{
98+
name: "Claude Code",
99+
detect: () => existsSync(join(HOME_DIRECTORY, ".claude")),
100+
install: () => writeSkillFiles(join(HOME_DIRECTORY, ".claude", "skills", SKILL_NAME)),
101+
},
102+
{
103+
name: "Amp Code",
104+
detect: () => existsSync(join(HOME_DIRECTORY, ".amp")),
105+
install: () =>
106+
writeSkillFiles(join(HOME_DIRECTORY, ".config", "amp", "skills", SKILL_NAME)),
107+
},
108+
{
109+
name: "Cursor",
110+
detect: () => existsSync(join(HOME_DIRECTORY, ".cursor")),
111+
install: () => writeSkillFiles(join(HOME_DIRECTORY, ".cursor", "skills", SKILL_NAME)),
112+
},
113+
{
114+
name: "OpenCode",
115+
detect: () =>
116+
isCommandAvailable("opencode") ||
117+
existsSync(join(HOME_DIRECTORY, ".config", "opencode")),
118+
install: () =>
119+
writeSkillFiles(join(HOME_DIRECTORY, ".config", "opencode", "skills", SKILL_NAME)),
120+
},
121+
{
122+
name: "Windsurf",
123+
detect: () =>
124+
existsSync(join(HOME_DIRECTORY, ".codeium")) ||
125+
existsSync(join(HOME_DIRECTORY, "Library", "Application Support", "Windsurf")),
126+
install: () => {
127+
const memoriesDirectory = join(HOME_DIRECTORY, ".codeium", "windsurf", "memories");
128+
mkdirSync(memoriesDirectory, { recursive: true });
129+
const rulesFile = join(memoriesDirectory, "global_rules.md");
130+
131+
if (existsSync(rulesFile)) {
132+
const existingContent = readFileSync(rulesFile, "utf-8");
133+
if (existingContent.includes(WINDSURF_MARKER)) return;
134+
appendFileSync(rulesFile, `\n${WINDSURF_MARKER}\n\n${SKILL_CONTENT}`);
135+
} else {
136+
writeFileSync(rulesFile, `${WINDSURF_MARKER}\n\n${SKILL_CONTENT}`);
137+
}
138+
},
139+
},
140+
{
141+
name: "Antigravity",
142+
detect: () =>
143+
isCommandAvailable("agy") ||
144+
existsSync(join(HOME_DIRECTORY, ".gemini", "antigravity")),
145+
install: () =>
146+
writeSkillFiles(join(HOME_DIRECTORY, ".gemini", "antigravity", "skills", SKILL_NAME)),
147+
},
148+
{
149+
name: "Gemini CLI",
150+
detect: () =>
151+
isCommandAvailable("gemini") || existsSync(join(HOME_DIRECTORY, ".gemini")),
152+
install: () => writeSkillFiles(join(HOME_DIRECTORY, ".gemini", "skills", SKILL_NAME)),
153+
},
154+
{
155+
name: "Codex",
156+
detect: () =>
157+
isCommandAvailable("codex") || existsSync(join(HOME_DIRECTORY, ".codex")),
158+
install: () => {
159+
const skillDirectory = join(HOME_DIRECTORY, ".codex", "skills", SKILL_NAME);
160+
writeSkillFiles(skillDirectory);
161+
const agentsDirectory = join(skillDirectory, "agents");
162+
mkdirSync(agentsDirectory, { recursive: true });
163+
writeFileSync(join(agentsDirectory, "openai.yaml"), CODEX_AGENT_CONFIG);
164+
},
165+
},
166+
];
167+
35168
const installSkill = (): void => {
169+
let installedCount = 0;
170+
171+
for (const target of SKILL_TARGETS) {
172+
if (!target.detect()) continue;
173+
try {
174+
target.install();
175+
logger.log(` ${highlighter.success("✔")} ${target.name}`);
176+
installedCount++;
177+
} catch {
178+
logger.dim(` ✗ ${target.name} (failed)`);
179+
}
180+
}
181+
36182
try {
37-
execSync(`curl -fsSL ${INSTALL_SKILL_URL} | bash`, { stdio: "inherit" });
183+
const projectSkillDirectory = join(".agents", SKILL_NAME);
184+
writeSkillFiles(projectSkillDirectory);
185+
logger.log(` ${highlighter.success("✔")} .agents/`);
186+
installedCount++;
38187
} catch {
39-
logger.break();
40-
logger.dim("Skill install failed. You can install manually:");
41-
logger.dim(` curl -fsSL ${INSTALL_SKILL_URL} | bash`);
188+
logger.dim(" ✗ .agents/ (failed)");
189+
}
190+
191+
logger.break();
192+
if (installedCount === 0) {
193+
logger.dim("No supported tools detected.");
194+
} else {
195+
logger.success("Done! The skill will activate when working on React projects.");
42196
}
43197
};
44198

@@ -48,9 +202,7 @@ export const maybePromptSkillInstall = async (shouldSkipPrompts: boolean): Promi
48202
if (shouldSkipPrompts) return;
49203

50204
logger.break();
51-
logger.log(
52-
`${highlighter.info("💡")} Have your coding agent fix these issues automatically?`,
53-
);
205+
logger.log(`${highlighter.info("💡")} Have your coding agent fix these issues automatically?`);
54206
logger.dim(
55207
` Install the ${highlighter.info("react-doctor")} skill to teach Cursor, Claude Code,`,
56208
);

0 commit comments

Comments
 (0)