diff --git a/.changeset/tired-banks-show.md b/.changeset/tired-banks-show.md new file mode 100644 index 0000000000..0168677ce2 --- /dev/null +++ b/.changeset/tired-banks-show.md @@ -0,0 +1,5 @@ +--- +"claude-dev": minor +--- + +This PR introduces a Python gRPC codegen flow analogous to the existing Go flow diff --git a/package.json b/package.json index b93aa3b773..86f16e3fb4 100644 --- a/package.json +++ b/package.json @@ -310,6 +310,7 @@ "package": "npm run check-types && npm run build:webview && npm run lint && node esbuild.mjs --production", "protos": "node scripts/build-proto.mjs", "protos-go": "node scripts/build-go-proto.mjs", + "protos-python": "node scripts/build-python-proto.mjs", "cli-providers": "node scripts/cli-providers.mjs", "download-ripgrep": "node scripts/download-ripgrep.mjs", "postprotos": "biome format src/shared/proto src/core/controller src/hosts/ webview-ui/src/services src/generated --write --no-errors-on-unmatched", diff --git a/scripts/build-python-proto.mjs b/scripts/build-python-proto.mjs new file mode 100644 index 0000000000..946eb664ae --- /dev/null +++ b/scripts/build-python-proto.mjs @@ -0,0 +1,426 @@ +#!/usr/bin/env node + +import chalk from "chalk" +import { execSync } from "child_process" +import * as fs from "fs/promises" +import { globby } from "globby" +import * as path from "path" +import { fileURLToPath } from "url" + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)) +const ROOT_DIR = path.resolve(SCRIPT_DIR, "..") +const PROTO_DIR = path.join(ROOT_DIR, "proto") +const PY_OUT_DIR = path.join(ROOT_DIR, "src", "generated", "grpc-python") +const PY_CLIENT_DIR = path.join(PY_OUT_DIR, "client") + +function hasCommand(cmd) { + try { + if (process.platform === "win32") { + execSync(`where ${cmd}`, { stdio: "pipe" }) + } else { + execSync(`which ${cmd}`, { stdio: "pipe" }) + } + return true + } catch { + return false + } +} + +function resolvePython() { + // Allow override via env.PYTHON pointing to a specific interpreter + const envPy = process.env.PYTHON + if (envPy) { + try { + execSync(`"${envPy}" --version`, { stdio: "pipe" }) + return envPy + } catch { + console.warn(chalk.yellow(`Warning: PYTHON override "${envPy}" is not usable, falling back to discovery.`)) + } + } + const candidates = ["python3", "python"] + for (const c of candidates) { + if (hasCommand(c)) { + try { + execSync(`${c} --version`, { stdio: "pipe" }) + return c + } catch { + // continue + } + } + } + return null +} + +function checkGrpcTools(pythonExe) { + try { + execSync(`"${pythonExe}" -c "import grpc_tools"`, { stdio: "pipe" }) + return true + } catch { + return false + } +} + +async function ensureDir(dir) { + await fs.mkdir(dir, { recursive: true }) +} + +async function ensureInitPy(dir) { + try { + await fs.writeFile(path.join(dir, "__init__.py"), "", { flag: "wx" }) + } catch { + // exists + } +} + +/** + * Parse proto files to extract service names with their source file and package. + * Returns array of: + * { serviceName: string, serviceKey: string, protoPackage: "cline"|"host", moduleBase: string } + */ +async function parseServicesWithFiles(protoDir, protoFiles) { + const services = [] + for (const relPath of protoFiles) { + const full = path.join(protoDir, relPath) + const content = await fs.readFile(full, "utf8") + const pkg = relPath.startsWith("host/") ? "host" : "cline" + const moduleBase = path.basename(relPath, ".proto") + const serviceRe = /service\s+(\w+Service)\s*\{([\s\S]*?)\}/g + for (const m of content.matchAll(serviceRe)) { + const serviceName = m[1] // e.g., TaskService + const serviceKey = serviceName.replace(/Service$/, "").toLowerCase() // task + const body = m[2] + const methodRe = /rpc\s+(\w+)\s*\((stream\s)?([\w.]+)\)\s*returns\s*\((stream\s)?([\w.]+)\)/g + const methods = [] + for (const mm of body.matchAll(methodRe)) { + methods.push({ + name: mm[1], + isRequestStreaming: !!mm[2], + requestType: mm[3], + isResponseStreaming: !!mm[4], + responseType: mm[5], + }) + } + services.push({ serviceName, serviceKey, protoPackage: pkg, moduleBase, methods }) + } + } + return services +} + +function upperFirst(s) { + return s.length ? s[0].toUpperCase() + s.slice(1) : s +} + +async function generateConnectionPy(outDir) { + const content = `# AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY +# Generated by scripts/build-python-proto.mjs + +import grpc +import time +from typing import Optional + +class ConnectionManager: + def __init__(self, address: str, timeout: float = 30.0): + self.address = address + self.timeout = timeout + self._channel: Optional[grpc.Channel] = None + + def connect(self) -> None: + if self._channel is not None: + return + self._channel = grpc.insecure_channel(self.address) + # Wait for channel to be ready within timeout + grpc.channel_ready_future(self._channel).result(timeout=self.timeout) + + def disconnect(self) -> None: + if self._channel is not None: + self._channel.close() + self._channel = None + + @property + def channel(self) -> Optional[grpc.Channel]: + return self._channel + + def is_connected(self) -> bool: + return self._channel is not None +` + await fs.mkdir(outDir, { recursive: true }) + await fs.writeFile(path.join(outDir, "connection.py"), content) + await ensureInitPy(outDir) +} + +async function generateClineClientPy(outDir, services) { + // Import per-service wrapper clients + const importLines = [] + const seen = new Set() + for (const s of services) { + const fileBase = `${s.serviceKey}_client` + const className = `${s.serviceName.replace(/Service$/, "")}Client` + const importKey = `${fileBase}:${className}` + if (!seen.has(importKey)) { + importLines.push(`from .services.${fileBase} import ${className}`) + seen.add(importKey) + } + } + + // Build wrapper initializations on connect (like Go NewClient) + const initLines = services.map((s) => { + const shortName = s.serviceName.replace(/Service$/, "") // Task + const className = `${shortName}Client` + return ` self.${shortName} = ${className}(self._conn.channel)` + }) + + // Build attribute resets on disconnect + const nilLines = services.map((s) => { + const shortName = s.serviceName.replace(/Service$/, "") + return ` self.${shortName} = None` + }) + + const content = `# AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY +# Generated by scripts/build-python-proto.mjs + +from typing import Optional + +import grpc +from .connection import ConnectionManager +${importLines.join("\n")} + +class ClineClient: + """ + Unified Python client analogous to src/generated/grpc-go/client/ClineClient. + + Usage: + client = ClineClient("localhost:17611") + client.connect() + # Call wrappers, e.g.: client.Task.SomeRpc(...) + client.disconnect() + """ + + def __init__(self, address: str, timeout: float = 30.0): + self._conn = ConnectionManager(address, timeout=timeout) + self._connected = False + +${services.map((s) => ` self.${s.serviceName.replace(/Service$/, "")}: Optional[object] = None`).join("\n")} + + def connect(self) -> None: + if self._connected: + return + self._conn.connect() +${initLines.join("\n")} + self._connected = True + + def disconnect(self) -> None: + if not self._connected: + return + self._conn.disconnect() +${nilLines.join("\n")} + self._connected = False + + def is_connected(self) -> bool: + return self._connected + + @property + def channel(self) -> Optional[grpc.Channel]: + return self._conn.channel +` + const clientDir = outDir + await fs.mkdir(clientDir, { recursive: true }) + await fs.writeFile(path.join(clientDir, "cline_client.py"), content) +} + +async function generatePythonClient(protoDir, pyOutDir, clientDir, protoFiles) { + // Ensure package structure for client + await fs.mkdir(clientDir, { recursive: true }) + await ensureInitPy(pyOutDir) + await ensureInitPy(clientDir) + + const services = await parseServicesWithFiles(protoDir, protoFiles) + + // connection.py + await generateConnectionPy(clientDir) + + // services/ per-service wrappers (mirror Go client/services) + const servicesDir = path.join(clientDir, "services") + await fs.mkdir(servicesDir, { recursive: true }) + await ensureInitPy(servicesDir) + await generateServiceClientsPy(servicesDir, services) + + // cline_client.py (unified that composes service wrappers) + await generateClineClientPy(clientDir, services) +} + +async function generateServiceClientsPy(outDir, services) { + await fs.mkdir(outDir, { recursive: true }) + await ensureInitPy(outDir) + + for (const s of services) { + const shortName = s.serviceName.replace(/Service$/, "") // Task + const className = `${shortName}Client` + const fileName = `${s.serviceKey}_client.py` + + const aliasPb2 = `${s.protoPackage}_${s.moduleBase}_pb2` + const aliasGrpc = `${s.protoPackage}_${s.moduleBase}_pb2_grpc` + + const methodLines = s.methods + .map((m) => { + const reqTypeName = m.requestType.split(".").pop() + const respTypeName = m.responseType.split(".").pop() + if (m.isResponseStreaming) { + return ` + def ${m.name}(self, req): + """ + Server-streaming RPC. + :param req: ${aliasPb2}.${reqTypeName} + :return: iterator of ${aliasPb2}.${respTypeName} + """ + return self._stub.${m.name}(req)` + } else { + return ` + def ${m.name}(self, req): + """ + Unary RPC. + :param req: ${aliasPb2}.${reqTypeName} + :return: ${aliasPb2}.${respTypeName} + """ + return self._stub.${m.name}(req)` + } + }) + .join("\n") + + const content = `# AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY +# Generated by scripts/build-python-proto.mjs + +import grpc +from ${s.protoPackage} import ${s.moduleBase}_pb2 as ${aliasPb2} +from ${s.protoPackage} import ${s.moduleBase}_pb2_grpc as ${aliasGrpc} + +class ${className}: + def __init__(self, channel: grpc.Channel): + self._stub = ${aliasGrpc}.${s.serviceName}Stub(channel) +${methodLines} +` + await fs.writeFile(path.join(outDir, fileName), content) + } +} + +async function generatePyproject(outDir) { + const content = `[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "cline-grpc-python" +version = "0.1.0" +description = "Generated Python gRPC stubs and client wrappers for Cline protos" +license = { text: "Apache-2.0" } +requires-python = ">=3.9" +dependencies = [ + "grpcio>=1.56.0", + "protobuf>=4.21.0" +] + +[tool.setuptools.packages.find] +where = ["."] +` + await fs.writeFile(path.join(outDir, "pyproject.toml"), content) +} + +async function main() { + console.log(chalk.cyan("Starting Python protobuf code generation...")) + + // Verify proto dir exists + try { + const stat = await fs.stat(PROTO_DIR) + if (!stat.isDirectory()) { + console.error(chalk.red(`Proto directory is not a folder: ${PROTO_DIR}`)) + process.exit(1) + } + } catch { + console.error(chalk.red(`Proto directory not found: ${PROTO_DIR}`)) + process.exit(1) + } + + // Resolve Python + const python = resolvePython() + if (!python) { + console.error( + chalk.red("Python not found on PATH. Please install Python 3 and ensure it is available (python3 or python)."), + ) + process.exit(1) + } + console.log(chalk.green(`✓ Using Python executable: ${python}`)) + + // Check grpcio-tools + if (!checkGrpcTools(python)) { + console.error(chalk.red("Missing dependency: grpcio-tools")) + console.log(chalk.yellow("Install with:")) + console.log(chalk.yellow(` ${python} -m pip install grpcio-tools --user --break-system-packages`)) + process.exit(1) + } + console.log(chalk.green("✓ grpcio-tools available")) + + // Discover proto files + const protoFiles = await globby("**/*.proto", { cwd: PROTO_DIR }) + if (!protoFiles.length) { + console.error(chalk.red("No .proto files found under ./proto")) + process.exit(1) + } + console.log(chalk.cyan(`Found ${protoFiles.length} proto files`)) + + // Ensure output directory + await ensureDir(PY_OUT_DIR) + + // Build and run protoc command via grpc_tools + const quoted = (s) => `"${s}"` + const pythonCmd = quoted(python) + const cmd = + `${pythonCmd} -m grpc_tools.protoc ` + + `-I ${quoted(PROTO_DIR)} ` + + `--python_out=${quoted(PY_OUT_DIR)} ` + + `--grpc_python_out=${quoted(PY_OUT_DIR)} ` + + protoFiles.map((f) => quoted(f)).join(" ") + + try { + console.log(chalk.cyan(`Generating Python code into ${PY_OUT_DIR}...`)) + execSync(cmd, { cwd: ROOT_DIR, stdio: "inherit", env: process.env }) + } catch (error) { + console.error(chalk.red("Error generating Python code:"), error?.message || error) + process.exit(1) + } + + // Ensure package structure (__init__.py) for imports + await ensureInitPy(PY_OUT_DIR) + try { + const clineDir = path.join(PY_OUT_DIR, "cline") + const hostDir = path.join(PY_OUT_DIR, "host") + // These may or may not exist depending on which protos are present + await fs + .stat(clineDir) + .then(() => ensureInitPy(clineDir)) + .catch(() => {}) + await fs + .stat(hostDir) + .then(() => ensureInitPy(hostDir)) + .catch(() => {}) + } catch { + // ignore + } + + // Generate Python client structure analogous to src/generated/grpc-go/client + await generatePythonClient(PROTO_DIR, PY_OUT_DIR, PY_CLIENT_DIR, protoFiles) + + // Generate a minimal pyproject.toml in the generated output so it can be pip-installed if desired + await generatePyproject(PY_OUT_DIR) + + console.log(chalk.green("✓ Python protobuf and client code generation completed successfully!")) + console.log(chalk.cyan(`Output directory: ${PY_OUT_DIR}`)) + console.log(chalk.cyan(`Client directory: ${PY_CLIENT_DIR}`)) + console.log(chalk.cyan(`PyProject: ${path.join(PY_OUT_DIR, "pyproject.toml")}`)) + console.log(chalk.gray("Note: To import, add the output dir to your PYTHONPATH or pip install -e src/generated/grpc-python")) +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch((err) => { + console.error(chalk.red("Unexpected error in build-python-proto.mjs:"), err) + process.exit(1) + }) +}