mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-11-02 06:31:35 +08:00
619 lines
18 KiB
JavaScript
619 lines
18 KiB
JavaScript
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const projectRoot = path.resolve(__dirname, "..");
|
|
const outputFile = path.join(projectRoot, "packages", "ui", "src", "generated", "claude-code-config-options.json");
|
|
|
|
const sources = {
|
|
envEn: "https://code.claude.com/docs/en/env-vars.md",
|
|
envZh: "https://code.claude.com/docs/zh-CN/env-vars.md",
|
|
settingsEn: "https://code.claude.com/docs/en/settings-reference.md",
|
|
settingsZh: "https://code.claude.com/docs/zh-CN/settings.md"
|
|
};
|
|
|
|
const excludedSettingKeys = new Set([
|
|
"env"
|
|
]);
|
|
|
|
const generatedBy = "scripts/generate-claude-code-config-options.mjs";
|
|
|
|
async function main() {
|
|
let previous;
|
|
try {
|
|
previous = JSON.parse(readFileSync(outputFile, "utf8"));
|
|
} catch {
|
|
previous = undefined;
|
|
}
|
|
|
|
try {
|
|
const docs = await fetchDocs(sources);
|
|
const settingsEn = parseSettingsReference(docs.settingsEn, "en");
|
|
const settingsZh = parseSettingsReference(docs.settingsZh, "zh");
|
|
const envEn = parseEnvReference(docs.envEn);
|
|
const envZh = parseEnvReference(docs.envZh);
|
|
const generated = {
|
|
generatedAt: new Date().toISOString(),
|
|
generatedBy,
|
|
sourceUrls: sources,
|
|
settings: mergeLocalizedSettings(settingsEn, settingsZh),
|
|
env: mergeLocalizedEnv(envEn, envZh)
|
|
};
|
|
writeJsonIfChanged(outputFile, generated);
|
|
console.log(`[claude-config] Generated ${generated.settings.length} settings and ${generated.env.length} environment variables from official Claude Code docs.`);
|
|
} catch (error) {
|
|
if (previous?.settings?.length || previous?.env?.length) {
|
|
console.warn(`[claude-config] Failed to refresh official Claude Code docs; reusing existing metadata. ${formatError(error)}`);
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function fetchDocs(urls) {
|
|
const entries = await Promise.all(Object.entries(urls).map(async ([key, url]) => {
|
|
const response = await fetch(url, {
|
|
headers: {
|
|
"accept": "text/markdown,text/plain,*/*",
|
|
"user-agent": "claude-code-router-build"
|
|
}
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`${url} returned HTTP ${response.status}`);
|
|
}
|
|
return [key, await response.text()];
|
|
}));
|
|
return Object.fromEntries(entries);
|
|
}
|
|
|
|
function parseSettingsReference(markdown, locale) {
|
|
const globalConfigIndex = markdown.indexOf("\n## Global config settings");
|
|
const scopedMarkdown = globalConfigIndex > 0 ? markdown.slice(0, globalConfigIndex) : markdown;
|
|
const sections = markdownSections(scopedMarkdown, /^### `([^`]+)`/gm);
|
|
if (sections.length > 0) {
|
|
return sections
|
|
.map((section) => settingFromSection(section, locale))
|
|
.filter((option) => option && !excludedSettingKeys.has(option.key));
|
|
}
|
|
return settingOptionsFromTables(scopedMarkdown)
|
|
.filter((option) => !excludedSettingKeys.has(option.key));
|
|
}
|
|
|
|
function settingFromSection(section, locale) {
|
|
const key = section.title.trim();
|
|
if (!key || key.includes(" ")) {
|
|
return undefined;
|
|
}
|
|
const scope = firstMetadataValue(section.body, locale === "zh" ? "作用域" : "Scope");
|
|
const type = firstMetadataValue(section.body, locale === "zh" ? "类型" : "Type");
|
|
const defaultValue = firstMetadataValue(section.body, locale === "zh" ? "默认值" : "Default");
|
|
const example = firstCodeBlock(section.body, "json");
|
|
return {
|
|
key,
|
|
path: key.split("."),
|
|
scope,
|
|
type,
|
|
default: defaultValue,
|
|
example,
|
|
valueKind: inferValueKind(type, example),
|
|
enumValues: enumValuesFromSection(section.body),
|
|
description: cleanDescription(firstDescription(section.body))
|
|
};
|
|
}
|
|
|
|
function parseEnvReference(markdown) {
|
|
const variablesIndex = markdown.indexOf("\n## Variables");
|
|
const scopedMarkdown = variablesIndex > 0 ? markdown.slice(variablesIndex) : markdown;
|
|
const sections = markdownSections(scopedMarkdown, /^### `([^`]+)`/gm);
|
|
if (sections.length > 0) {
|
|
return sections
|
|
.map((section) => envFromSection(section))
|
|
.filter(Boolean);
|
|
}
|
|
return envFromTables(scopedMarkdown);
|
|
}
|
|
|
|
function settingOptionsFromTables(markdown) {
|
|
const options = [];
|
|
for (const table of markdownTables(markdown)) {
|
|
const headers = table.headers.map((item) => stripMarkdown(item).toLowerCase());
|
|
const keyIndex = headers.findIndex((item) => item.includes("key") || item.includes("键"));
|
|
const descriptionIndex = headers.findIndex((item) => item.includes("description") || item.includes("描述"));
|
|
const exampleIndex = headers.findIndex((item) => item.includes("example") || item.includes("示例"));
|
|
if (keyIndex < 0 || descriptionIndex < 0) {
|
|
continue;
|
|
}
|
|
for (const row of table.rows) {
|
|
const key = firstBacktickValue(row[keyIndex]) || stripMarkdown(row[keyIndex]).trim();
|
|
if (!key || key.includes(" ")) {
|
|
continue;
|
|
}
|
|
const example = exampleIndex >= 0 ? cleanInline(row[exampleIndex]) : "";
|
|
options.push({
|
|
key,
|
|
path: key.split("."),
|
|
scope: "",
|
|
type: "",
|
|
default: "",
|
|
example,
|
|
valueKind: inferValueKind("", example),
|
|
enumValues: enumValuesFromSection(row[descriptionIndex]),
|
|
description: cleanDescription(row[descriptionIndex])
|
|
});
|
|
}
|
|
}
|
|
return options;
|
|
}
|
|
|
|
function envFromSection(section) {
|
|
const key = section.title.trim();
|
|
if (!isEnvName(key)) {
|
|
return undefined;
|
|
}
|
|
const example = firstEnvExample(section.body, key) || placeholderForEnvName(key);
|
|
return {
|
|
key,
|
|
description: cleanDescription(firstDescription(section.body)),
|
|
example,
|
|
valueKind: inferEnvValueKind(key, section.body, example)
|
|
};
|
|
}
|
|
|
|
function envFromTables(markdown) {
|
|
const rows = [];
|
|
for (const table of markdownTables(markdown)) {
|
|
const headers = table.headers.map((item) => item.toLowerCase());
|
|
const keyIndex = headers.findIndex((item) => item.includes("variable") || item.includes("变量"));
|
|
const purposeIndex = headers.findIndex((item) => item.includes("purpose") || item.includes("description") || item.includes("用途") || item.includes("目的") || item.includes("描述"));
|
|
if (keyIndex < 0 || purposeIndex < 0) {
|
|
continue;
|
|
}
|
|
for (const row of table.rows) {
|
|
const key = stripMarkdown(row[keyIndex]).trim();
|
|
if (!isEnvName(key)) {
|
|
continue;
|
|
}
|
|
const description = cleanDescription(row[purposeIndex]);
|
|
const valueKind = inferEnvValueKind(key, description, "");
|
|
rows.push({
|
|
key,
|
|
description,
|
|
example: envExampleFromTableCell(key, row[purposeIndex], valueKind),
|
|
valueKind
|
|
});
|
|
}
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
function mergeLocalizedSettings(enOptions, zhOptions) {
|
|
const zhByKey = new Map(zhOptions.map((item) => [item.key, item]));
|
|
return enOptions
|
|
.map((option) => {
|
|
const zh = zhByKey.get(option.key);
|
|
return {
|
|
key: option.key,
|
|
path: option.path,
|
|
scope: option.scope,
|
|
type: option.type,
|
|
default: option.default,
|
|
example: option.example || zh?.example || placeholderForKind(option.valueKind),
|
|
valueKind: option.valueKind,
|
|
enumValues: option.enumValues.length ? option.enumValues : zh?.enumValues ?? [],
|
|
description: {
|
|
en: option.description,
|
|
zh: zh?.description || option.description
|
|
}
|
|
};
|
|
})
|
|
.filter((option) => isConfigurableSettingsOption(option))
|
|
.sort((a, b) => a.key.localeCompare(b.key));
|
|
}
|
|
|
|
function mergeLocalizedEnv(enOptions, zhOptions) {
|
|
const zhByKey = new Map(zhOptions.map((item) => [item.key, item]));
|
|
return uniqueByKey(enOptions)
|
|
.map((option) => {
|
|
const zh = zhByKey.get(option.key);
|
|
return {
|
|
key: option.key,
|
|
example: option.example || zh?.example || placeholderForEnvName(option.key),
|
|
valueKind: option.valueKind,
|
|
description: {
|
|
en: option.description,
|
|
zh: zh?.description || option.description
|
|
}
|
|
};
|
|
})
|
|
.filter((option) => isEnvName(option.key) && option.description.en)
|
|
.sort((a, b) => a.key.localeCompare(b.key));
|
|
}
|
|
|
|
function isConfigurableSettingsOption(option) {
|
|
if (!option.description.en) {
|
|
return false;
|
|
}
|
|
if (option.scope && /^global config$/i.test(stripMarkdown(option.scope))) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function markdownSections(markdown, headingPattern) {
|
|
const matches = [...markdown.matchAll(headingPattern)];
|
|
return matches.map((match, index) => {
|
|
const next = matches[index + 1];
|
|
return {
|
|
title: match[1],
|
|
body: markdown.slice(match.index + match[0].length, next?.index ?? markdown.length)
|
|
};
|
|
});
|
|
}
|
|
|
|
function markdownTables(markdown) {
|
|
const lines = markdown.split(/\r?\n/);
|
|
const tables = [];
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
if (!isTableSeparator(lines[index + 1] || "") || !lines[index].includes("|")) {
|
|
continue;
|
|
}
|
|
const headers = splitMarkdownTableRow(lines[index]);
|
|
const rows = [];
|
|
index += 2;
|
|
while (index < lines.length && lines[index].includes("|")) {
|
|
rows.push(splitMarkdownTableRow(lines[index]));
|
|
index += 1;
|
|
}
|
|
tables.push({ headers, rows });
|
|
}
|
|
return tables;
|
|
}
|
|
|
|
function splitMarkdownTableRow(line) {
|
|
const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
|
|
const cells = [];
|
|
let current = "";
|
|
let escaped = false;
|
|
let code = false;
|
|
for (const char of trimmed) {
|
|
if (escaped) {
|
|
current += char;
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
if (char === "\\") {
|
|
current += char;
|
|
escaped = true;
|
|
continue;
|
|
}
|
|
if (char === "`") {
|
|
code = !code;
|
|
current += char;
|
|
continue;
|
|
}
|
|
if (char === "|" && !code) {
|
|
cells.push(current.trim());
|
|
current = "";
|
|
continue;
|
|
}
|
|
current += char;
|
|
}
|
|
cells.push(current.trim());
|
|
return cells;
|
|
}
|
|
|
|
function isTableSeparator(line) {
|
|
return /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line);
|
|
}
|
|
|
|
function firstMetadataValue(markdown, label) {
|
|
const escaped = escapeRegExp(label);
|
|
const pattern = new RegExp(`^\\s*\\*\\s*\\*\\*${escaped}\\*\\*:\\s*(.+)$`, "im");
|
|
return cleanInline(markdown.match(pattern)?.[1] || "");
|
|
}
|
|
|
|
function firstDescription(markdown) {
|
|
const withoutImports = markdown
|
|
.replace(/^\s*import\s+.*$/gm, "")
|
|
.replace(/^\s*<Warning>[\s\S]*?<\/Warning>\s*/m, "")
|
|
.trimStart();
|
|
const lines = withoutImports.split(/\r?\n/);
|
|
const paragraph = [];
|
|
let inCode = false;
|
|
for (const line of lines) {
|
|
if (/^\s*```/.test(line)) {
|
|
inCode = !inCode;
|
|
continue;
|
|
}
|
|
if (inCode) {
|
|
continue;
|
|
}
|
|
const trimmed = line.trim();
|
|
if (!trimmed) {
|
|
if (paragraph.length > 0) {
|
|
break;
|
|
}
|
|
continue;
|
|
}
|
|
if (
|
|
trimmed.startsWith("* **") ||
|
|
trimmed.startsWith("|") ||
|
|
trimmed.startsWith("<") ||
|
|
trimmed.startsWith("{") ||
|
|
trimmed.startsWith("}") ||
|
|
trimmed.startsWith("#### ") ||
|
|
trimmed.startsWith("### ") ||
|
|
trimmed.startsWith("## ")
|
|
) {
|
|
if (paragraph.length > 0) {
|
|
break;
|
|
}
|
|
continue;
|
|
}
|
|
paragraph.push(trimmed);
|
|
}
|
|
return paragraph.join(" ");
|
|
}
|
|
|
|
function firstCodeBlock(markdown, lang) {
|
|
const pattern = new RegExp("```" + lang + "[^\\n]*\\n([\\s\\S]*?)```", "i");
|
|
const code = markdown.match(pattern)?.[1]?.trim();
|
|
return code ? stripCommonIndent(code) : "";
|
|
}
|
|
|
|
function firstEnvExample(markdown, key) {
|
|
const examples = [];
|
|
const codePattern = /```(?:bash|json|powershell|batch|text)?[^\n]*\n([\s\S]*?)```/gi;
|
|
for (const match of markdown.matchAll(codePattern)) {
|
|
examples.push(match[1]);
|
|
}
|
|
const assignmentPattern = new RegExp(`${escapeRegExp(key)}\\s*[:=]\\s*[\"']?([^\"'\\n,}]+)`, "i");
|
|
for (const example of examples) {
|
|
const value = example.match(assignmentPattern)?.[1]?.trim();
|
|
if (value) {
|
|
return value;
|
|
}
|
|
}
|
|
return firstBacktickValue(markdown);
|
|
}
|
|
|
|
function firstBacktickValue(markdown) {
|
|
const match = markdown.match(/`([^`\n]+)`/);
|
|
return match?.[1]?.trim() || "";
|
|
}
|
|
|
|
function envExampleFromTableCell(key, markdown, valueKind) {
|
|
const assignmentPattern = new RegExp(`${escapeRegExp(key)}\\s*[:=]\\s*["']?([^"'\\s,}]+)`, "i");
|
|
const assignment = markdown.match(assignmentPattern)?.[1]?.trim();
|
|
if (assignment) {
|
|
return assignment;
|
|
}
|
|
|
|
for (const value of backtickValues(markdown)) {
|
|
const assignedValue = value.match(assignmentPattern)?.[1]?.trim();
|
|
if (assignedValue) {
|
|
return assignedValue;
|
|
}
|
|
if (valueKind === "boolean-string" && /^(?:0|1|true|false)$/i.test(value)) {
|
|
return value;
|
|
}
|
|
if (valueKind === "number-string" && /^\d+$/.test(value)) {
|
|
return value;
|
|
}
|
|
if ((key.includes("BASE_URL") || key.includes("ENDPOINT") || key.includes("URL")) && /^https?:\/\//i.test(value)) {
|
|
return value;
|
|
}
|
|
if (key.includes("MODEL") && /^claude-/i.test(value)) {
|
|
return value;
|
|
}
|
|
if (key.includes("REGION") && /^[a-z]{2}(?:-[a-z]+)+-\d$/.test(value)) {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
return placeholderForEnvName(key);
|
|
}
|
|
|
|
function backtickValues(markdown) {
|
|
return [...String(markdown || "").matchAll(/`([^`\n]+)`/g)]
|
|
.map((match) => match[1].trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function enumValuesFromSection(markdown) {
|
|
const values = [];
|
|
const seen = new Set();
|
|
const enumIntro = /one of|valid values|accepts|有效值|可选值|以下值/i;
|
|
if (!enumIntro.test(markdown)) {
|
|
return values;
|
|
}
|
|
for (const match of markdown.matchAll(/^\s*[*-]\s*`"([^"`]+)"`/gm)) {
|
|
const value = match[1].trim();
|
|
if (!seen.has(value)) {
|
|
seen.add(value);
|
|
values.push(value);
|
|
}
|
|
}
|
|
return values;
|
|
}
|
|
|
|
function inferValueKind(type, example) {
|
|
const normalized = stripMarkdown(type).toLowerCase();
|
|
if (normalized.includes("boolean")) {
|
|
return "boolean";
|
|
}
|
|
if (normalized.includes("integer")) {
|
|
return "integer";
|
|
}
|
|
if (normalized.includes("number") || normalized.includes("float")) {
|
|
return "number";
|
|
}
|
|
if (normalized.includes("array")) {
|
|
return "array";
|
|
}
|
|
if (normalized.includes("object") || normalized.includes("dictionary") || normalized.includes("map")) {
|
|
return "object";
|
|
}
|
|
const parsed = parseJsonExampleValue(example);
|
|
if (Array.isArray(parsed)) {
|
|
return "array";
|
|
}
|
|
if (parsed && typeof parsed === "object") {
|
|
return "object";
|
|
}
|
|
if (typeof parsed === "boolean") {
|
|
return "boolean";
|
|
}
|
|
if (typeof parsed === "number") {
|
|
return Number.isInteger(parsed) ? "integer" : "number";
|
|
}
|
|
return "string";
|
|
}
|
|
|
|
function inferEnvValueKind(key, description, example) {
|
|
const text = `${key} ${description} ${example}`.toLowerCase();
|
|
if (
|
|
/\b(?:1|true)\b.*\b(?:0|false)\b/.test(text) ||
|
|
/\b(?:0|false)\b.*\b(?:1|true)\b/.test(text) ||
|
|
/\bset to\s+(?:1|0|true|false)\b/.test(text) ||
|
|
/设置为\s*(?:1|0|true|false)\b/.test(text)
|
|
) {
|
|
return "boolean-string";
|
|
}
|
|
if (text.includes("milliseconds") || text.includes("tokens") || text.includes("timeout") || /^MAX_/.test(key) || key.endsWith("_MS")) {
|
|
return "number-string";
|
|
}
|
|
return "string";
|
|
}
|
|
|
|
function parseJsonExampleValue(example) {
|
|
if (!example) {
|
|
return undefined;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(example);
|
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
const entries = Object.entries(parsed);
|
|
return entries.length === 1 ? entries[0][1] : parsed;
|
|
}
|
|
return parsed;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function placeholderForKind(kind) {
|
|
if (kind === "boolean") {
|
|
return "true";
|
|
}
|
|
if (kind === "integer") {
|
|
return "300000";
|
|
}
|
|
if (kind === "number") {
|
|
return "0.05";
|
|
}
|
|
if (kind === "array") {
|
|
return "[]";
|
|
}
|
|
if (kind === "object") {
|
|
return "{}";
|
|
}
|
|
return "value";
|
|
}
|
|
|
|
function placeholderForEnvName(key) {
|
|
if (key.includes("API_KEY") || key.includes("AUTH_TOKEN")) {
|
|
return "sk-...";
|
|
}
|
|
if (key.includes("BASE_URL") || key.includes("ENDPOINT") || key.includes("URL")) {
|
|
return "https://example.com";
|
|
}
|
|
if (key.endsWith("_MS") || key.includes("TIMEOUT") || key.includes("TOKENS") || key.includes("RETRIES")) {
|
|
return "300000";
|
|
}
|
|
if (key.includes("DISABLE") || key.includes("ENABLE") || key.includes("SKIP") || key.includes("USE_")) {
|
|
return "1";
|
|
}
|
|
if (key.includes("MODEL")) {
|
|
return "claude-sonnet-4-6";
|
|
}
|
|
if (key.includes("REGION")) {
|
|
return "us-east-1";
|
|
}
|
|
return "value";
|
|
}
|
|
|
|
function cleanDescription(value) {
|
|
return cleanInline(value)
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
function cleanInline(value) {
|
|
return stripMarkdown(value)
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
function stripMarkdown(value) {
|
|
return String(value || "")
|
|
.replace(/<[^>]+>/g, "")
|
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
.replace(/`([^`]+)`/g, "$1")
|
|
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
.replace(/\*([^*]+)\*/g, "$1")
|
|
.replace(/"/g, "\"")
|
|
.replace(/&/g, "&")
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
function stripCommonIndent(value) {
|
|
const lines = value.replace(/\s+$/g, "").split(/\r?\n/);
|
|
const indents = lines
|
|
.filter((line) => line.trim())
|
|
.map((line) => line.match(/^\s*/)?.[0].length ?? 0);
|
|
const indent = indents.length > 0 ? Math.min(...indents) : 0;
|
|
return indent > 0 ? lines.map((line) => line.slice(indent)).join("\n") : lines.join("\n");
|
|
}
|
|
|
|
function uniqueByKey(options) {
|
|
const seen = new Set();
|
|
return options.filter((option) => {
|
|
if (seen.has(option.key)) {
|
|
return false;
|
|
}
|
|
seen.add(option.key);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function isEnvName(value) {
|
|
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
|
|
}
|
|
|
|
function writeJsonIfChanged(file, value) {
|
|
const content = `${JSON.stringify(value, null, 2)}\n`;
|
|
let previous = "";
|
|
try {
|
|
previous = readFileSync(file, "utf8");
|
|
} catch {
|
|
previous = "";
|
|
}
|
|
if (previous === content) {
|
|
return;
|
|
}
|
|
mkdirSync(path.dirname(file), { recursive: true });
|
|
writeFileSync(file, content, "utf8");
|
|
}
|
|
|
|
function escapeRegExp(value) {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
function formatError(error) {
|
|
return error instanceof Error ? error.message : String(error);
|
|
}
|
|
|
|
await main();
|