#!/usr/bin/env node /** * Generates proto message definitions from TypeScript source of truth. * * This script reads the field definitions from src/shared/storage/state-keys.ts * and generates the corresponding proto message definitions for Secrets and Settings. * * Usage: node scripts/generate-state-proto.mjs * * The generated proto content is written to proto/cline/state.proto, * replacing only the Secrets and Settings messages while preserving * the rest of the file (services, enums, other messages). */ import * as fs from "node:fs/promises" import { Project, SyntaxKind } from "ts-morph" const STATE_KEYS_PATH = "src/shared/storage/state-keys.ts" const STATE_PROTO_PATH = "proto/cline/state.proto" /** * Convert field name to valid snake_case proto field name. * Handles camelCase (apiKey -> api_key) and hyphens (openai-codex -> openai_codex). */ function toProtoFieldName(str) { return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`).replace(/-/g, "_") } // Fields that should use int64 instead of int32 const INT64_FIELDS = new Set(["planModeThinkingBudgetTokens", "actModeThinkingBudgetTokens"]) // Fields that should use double instead of int32 const DOUBLE_FIELDS = new Set() /** * Infer proto type from TypeScript type expression * @param {string} typeText - The TypeScript type expression * @param {string} [fieldName] - Optional field name for field-specific overrides */ function inferProtoType(typeText, fieldName) { // Remove 'undefined' from union types const cleanType = typeText .replace(/\s*\|\s*undefined/g, "") .replace(/undefined\s*\|\s*/g, "") .trim() // Handle common types if (cleanType === "string") { return "string" } if (cleanType === "boolean") { return "bool" } if (cleanType === "number") { // Some number fields need specific numeric types if (fieldName && INT64_FIELDS.has(fieldName)) { return "int64" } if (fieldName && DOUBLE_FIELDS.has(fieldName)) { return "double" } return "int32" } // Handle Record as map if (/Record\s*<\s*string\s*,\s*string\s*>/.test(cleanType)) { return "map" } // Handle specific known types that map to proto messages/enums // Order matters! More specific types must come before generic ones // (e.g., OpenAiCompatibleModelInfo before ModelInfo) // Check known types BEFORE string literals, since types like `"act" as Mode` // contain quotes but should map to proto enums const knownTypes = [ // Specific model info types first ["OpenAiCompatibleModelInfo", "OpenAiCompatibleModelInfo"], ["LiteLLMModelInfo", "LiteLLMModelInfo"], ["OcaModelInfo", "OcaModelInfo"], // Generic ModelInfo last (catches OpenRouterModelInfo, etc.) ["ModelInfo", "OpenRouterModelInfo"], // Other types - order matters for substring matching ["AutoApprovalSettings", "AutoApprovalSettings"], ["BrowserSettings", "BrowserSettings"], ["FocusChainSettings", "FocusChainSettings"], ["OpenaiReasoningEffort", "OpenaiReasoningEffort"], ["PlanActMode", "PlanActMode"], ["ApiProvider", "string"], ["LanguageModelChatSelector", "LanguageModelChatSelector"], // Must come before "Mode" check ] for (const [tsType, protoType] of knownTypes) { if (cleanType.includes(tsType)) { return protoType } } // Check for Mode type separately with word boundary to avoid matching "VsCodeLmModelSelector" // This handles TS `Mode` type which maps to proto `PlanActMode` if (/\bMode\b/.test(cleanType)) { return "PlanActMode" } // Handle specific string literal unions (treat as string) // This comes after known types check since some types like `"act" as Mode` contain quotes if (cleanType.includes('"') || cleanType.includes("'")) { return "string" } // Default to string for complex types we can't map return "string" } /** * Parse the SECRETS_KEYS array from state-keys.ts */ function parseSecretsKeys(sourceFile) { const secretsDecl = sourceFile.getVariableDeclaration("SECRETS_KEYS") if (!secretsDecl) { throw new Error("Could not find SECRETS_KEYS declaration") } let initializer = secretsDecl.getInitializer() if (!initializer) { throw new Error("SECRETS_KEYS has no initializer") } // Handle 'as const' expression if (initializer.getKind() === SyntaxKind.AsExpression) { initializer = initializer.getExpression() } if (initializer.getKind() !== SyntaxKind.ArrayLiteralExpression) { throw new Error(`SECRETS_KEYS is not an array literal (got ${SyntaxKind[initializer.getKind()]})`) } const keys = [] for (const element of initializer.getElements()) { const text = element.getText() // Remove quotes and handle special prefixes const key = text.replace(/^['"]|['"]$/g, "") // Skip prefixed keys like "cline:clineAccountId" if (!key.includes(":")) { keys.push(key) } } return keys } /** * Parse field definitions from an object literal in state-keys.ts */ function parseFieldDefinitions(sourceFile, variableName) { const decl = sourceFile.getVariableDeclaration(variableName) if (!decl) { throw new Error(`Could not find ${variableName} declaration`) } const initializer = decl.getInitializer() if (!initializer) { throw new Error(`${variableName} has no initializer`) } // Handle 'satisfies' expression let objectLiteral = initializer if (initializer.getKind() === SyntaxKind.SatisfiesExpression) { objectLiteral = initializer.getExpression() } if (objectLiteral.getKind() !== SyntaxKind.ObjectLiteralExpression) { throw new Error(`${variableName} is not an object literal`) } const fields = [] for (const prop of objectLiteral.getProperties()) { if (prop.getKind() !== SyntaxKind.PropertyAssignment) { continue } const name = prop.getName() const propInit = prop.getInitializer() if (!propInit || propInit.getKind() !== SyntaxKind.ObjectLiteralExpression) { continue } // Get the 'default' property to infer the type const defaultProp = propInit.getProperty("default") if (!defaultProp) { continue } let typeText = "string" const defaultInit = defaultProp.getInitializer() if (defaultInit) { // Check for 'as' expression to get the type if (defaultInit.getKind() === SyntaxKind.AsExpression) { const typeNode = defaultInit.getTypeNode() if (typeNode) { typeText = typeNode.getText() } } else { // Infer from literal const text = defaultInit.getText() if (text === "true" || text === "false") { typeText = "boolean" } else if (/^\d+$/.test(text)) { typeText = "number" } else if (/^\d+\.\d+$/.test(text)) { typeText = "number" } } } fields.push({ name, tsType: typeText, protoType: inferProtoType(typeText, name), }) } return fields } /** * Convert snake_case to camelCase for mapping proto fields back to TS keys */ function snakeToCamel(str) { return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()) } /** * Normalize a property key to the camelCase form parsed from proto field names. * Example: "openai-codex-oauth-credentials" -> "openaiCodexOauthCredentials". * We do this before looking up existing field numbers so regenerated proto fields * reuse their old numbers instead of being treated as new fields. */ function normalizePropertyNameForProtoLookup(fieldName) { return snakeToCamel(toProtoFieldName(fieldName)) } /** * Fail fast when two distinct property keys collapse to the same normalized lookup key. * Collisions make field-number preservation ambiguous and can cause proto renumbering. */ function assertNoNormalizedFieldNameCollisions(fields) { const normalizedToOriginalNames = new Map() for (const field of fields) { const normalizedName = normalizePropertyNameForProtoLookup(field.name) const names = normalizedToOriginalNames.get(normalizedName) ?? new Set() names.add(field.name) normalizedToOriginalNames.set(normalizedName, names) } for (const [normalizedName, names] of normalizedToOriginalNames) { if (names.size > 1) { const sortedNames = [...names].sort() throw new Error( `Field-name collision after proto normalization: ${sortedNames.join(", ")} all normalize to "${normalizedName}". Rename one field to keep proto field-number mapping unambiguous.`, ) } } } /** * Parse field numbers from an existing proto message definition * Returns a map of camelCase field names to their field numbers */ function parseProtoMessageFieldNumbers(protoContent, messageName) { const fieldNumbers = {} // Match the message block (handles single-level nesting for now) const messageRegex = new RegExp(`message\\s+${messageName}\\s*\\{([^}]*(?:\\{[^}]*\\}[^}]*)*)\\}`, "s") const match = protoContent.match(messageRegex) if (!match) { return fieldNumbers } const messageBody = match[1] // Match field definitions: optional/required/repeated type name = number; // Supports scalar/message types and map fields. const fieldRegex = /(?:optional|required|repeated)?\s*(?:map<[^>]+>|[\w.]+)\s+(\w+)\s*=\s*(\d+)\s*;/g const matches = messageBody.matchAll(fieldRegex) for (const fieldMatch of matches) { const snakeName = fieldMatch[1] const fieldNum = Number.parseInt(fieldMatch[2], 10) const camelName = snakeToCamel(snakeName) fieldNumbers[camelName] = fieldNum } return fieldNumbers } /** * Load field number mappings from existing proto file */ async function loadFieldNumbersFromProto() { try { const protoContent = await fs.readFile(STATE_PROTO_PATH, "utf-8") const secrets = parseProtoMessageFieldNumbers(protoContent, "Secrets") const settings = parseProtoMessageFieldNumbers(protoContent, "Settings") console.log(` Found ${Object.keys(secrets).length} existing Secrets fields`) console.log(` Found ${Object.keys(settings).length} existing Settings fields`) return { Secrets: secrets, Settings: settings } } catch { // Proto file doesn't exist, start fresh return { Secrets: {}, Settings: {} } } } /** * Assign field numbers, preserving existing assignments and adding new ones */ function assignFieldNumbers(fields, existingNumbers, startNumber = 1) { const result = {} let nextNumber = startNumber assertNoNormalizedFieldNameCollisions(fields) // Find the highest existing number for (const num of Object.values(existingNumbers)) { if (num >= nextNumber) { nextNumber = num + 1 } } // Preserve existing assignments for (const field of fields) { // Normalize before lookup so keys like "openai-codex-oauth-credentials" // reuse existing proto numbers instead of being treated as new fields. const normalizedFieldName = normalizePropertyNameForProtoLookup(field.name) const normalizedFieldNumber = existingNumbers[normalizedFieldName] const rawFieldNumber = existingNumbers[field.name] if (normalizedFieldNumber !== undefined && rawFieldNumber !== undefined && normalizedFieldNumber !== rawFieldNumber) { throw new Error( `Ambiguous proto field-number mapping for "${field.name}": normalized key "${normalizedFieldName}" -> ${normalizedFieldNumber}, raw key "${field.name}" -> ${rawFieldNumber}.`, ) } const existingFieldNumber = normalizedFieldNumber ?? rawFieldNumber if (existingFieldNumber !== undefined) { result[field.name] = existingFieldNumber } } // Assign new numbers for new fields for (const field of fields) { if (result[field.name] === undefined) { result[field.name] = nextNumber++ } } return result } /** * Generate proto message definition */ function generateProtoMessage(messageName, fields, fieldNumbers) { const lines = [`message ${messageName} {`] // Sort fields by field number for consistent output const sortedFields = [...fields].sort((a, b) => fieldNumbers[a.name] - fieldNumbers[b.name]) for (const field of sortedFields) { const snakeName = toProtoFieldName(field.name) const fieldNum = fieldNumbers[field.name] // Map types cannot have the 'optional' modifier in proto3 const prefix = field.protoType.startsWith("map<") ? "" : "optional " lines.push(` ${prefix}${field.protoType} ${snakeName} = ${fieldNum};`) } lines.push("}") return lines.join("\n") } /** * Generate Secrets message from SECRETS_KEYS */ function generateSecretsMessage(secretsKeys, fieldNumbers) { const fields = secretsKeys.map((key) => ({ name: key, protoType: "string", })) return generateProtoMessage("Secrets", fields, fieldNumbers) } /** * Replace a message in the proto file content */ function replaceMessage(protoContent, messageName, newMessageContent) { // Match the message definition including nested braces const messageRegex = new RegExp(`message\\s+${messageName}\\s*\\{[^}]*(?:\\{[^}]*\\}[^}]*)*\\}`, "g") if (messageRegex.test(protoContent)) { return protoContent.replace(messageRegex, newMessageContent) } // Message doesn't exist, append before the first message or at end console.warn(`Warning: ${messageName} message not found in proto file, appending`) return protoContent + "\n\n" + newMessageContent } async function main() { console.log("Generating proto definitions from TypeScript source...") // Parse TypeScript source const project = new Project({ tsConfigFilePath: "tsconfig.json", }) const sourceFile = project.addSourceFileAtPath(STATE_KEYS_PATH) // Parse definitions const secretsKeys = parseSecretsKeys(sourceFile) console.log(`Found ${secretsKeys.length} secret keys`) const apiHandlerFields = parseFieldDefinitions(sourceFile, "API_HANDLER_SETTINGS_FIELDS") const userSettingsFields = parseFieldDefinitions(sourceFile, "USER_SETTINGS_FIELDS") const settingsFields = [...apiHandlerFields, ...userSettingsFields] console.log(`Found ${settingsFields.length} settings fields`) // Load existing field numbers from proto file const existingFieldNumbers = await loadFieldNumbersFromProto() // Assign field numbers (preserving existing, adding new ones) const secretsFieldNumbers = assignFieldNumbers( secretsKeys.map((k) => ({ name: k })), existingFieldNumbers.Secrets, 1, ) const settingsFieldNumbers = assignFieldNumbers(settingsFields, existingFieldNumbers.Settings, 1) // Generate messages const secretsMessage = generateSecretsMessage(secretsKeys, secretsFieldNumbers) const settingsMessage = generateProtoMessage("Settings", settingsFields, settingsFieldNumbers) // Read existing proto file let protoContent = await fs.readFile(STATE_PROTO_PATH, "utf-8") // Replace messages protoContent = replaceMessage(protoContent, "Secrets", secretsMessage) protoContent = replaceMessage(protoContent, "Settings", settingsMessage) // Write updated proto file await fs.writeFile(STATE_PROTO_PATH, protoContent) console.log(`Updated ${STATE_PROTO_PATH}`) console.log("\nGeneration complete! Run 'bun run protos' to regenerate TypeScript from protos.") } main().catch((error) => { console.error("Error:", error) process.exit(1) })