diff --git a/bun.lock b/bun.lock index a7826169d..dc43b2f05 100644 --- a/bun.lock +++ b/bun.lock @@ -30,6 +30,7 @@ "ai": "^5.0.101", "ai-tokenizer": "^1.0.4", "chalk": "^5.6.2", + "commander": "^14.0.2", "cors": "^2.8.5", "crc-32": "^1.2.2", "diff": "^8.0.2", @@ -51,6 +52,7 @@ "shescape": "^2.1.6", "source-map-support": "^0.5.21", "streamdown": "^1.4.0", + "trpc-cli": "^0.12.1", "turndown": "^7.2.2", "undici": "^7.16.0", "write-file-atomic": "^6.0.0", @@ -3401,6 +3403,8 @@ "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + "trpc-cli": ["trpc-cli@0.12.1", "", { "dependencies": { "commander": "^14.0.0" }, "peerDependencies": { "@orpc/server": "^1.0.0", "@trpc/server": "^10.45.2 || ^11.0.1", "@valibot/to-json-schema": "^1.1.0", "effect": "^3.14.2 || ^4.0.0", "valibot": "^1.1.0", "zod": "^3.24.0 || ^4.0.0" }, "optionalPeers": ["@orpc/server", "@trpc/server", "@valibot/to-json-schema", "effect", "valibot", "zod"], "bin": { "trpc-cli": "dist/bin.js" } }, "sha512-/D/mIQf3tUrS7ZKJZ1gmSPJn2psAABJfkC5Eevm55SZ4s6KwANOUNlwhAGXN9HT4VSJVfoF2jettevE9vHPQlg=="], + "truncate-utf8-bytes": ["truncate-utf8-bytes@1.0.2", "", { "dependencies": { "utf8-byte-length": "^1.0.1" } }, "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ=="], "ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="], diff --git a/package.json b/package.json index 543b23dc6..694bac513 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "ai": "^5.0.101", "ai-tokenizer": "^1.0.4", "chalk": "^5.6.2", + "commander": "^14.0.2", "cors": "^2.8.5", "crc-32": "^1.2.2", "diff": "^8.0.2", @@ -92,6 +93,7 @@ "shescape": "^2.1.6", "source-map-support": "^0.5.21", "streamdown": "^1.4.0", + "trpc-cli": "^0.12.1", "turndown": "^7.2.2", "undici": "^7.16.0", "write-file-atomic": "^6.0.0", diff --git a/src/cli/api.ts b/src/cli/api.ts new file mode 100644 index 000000000..eee11821d --- /dev/null +++ b/src/cli/api.ts @@ -0,0 +1,21 @@ +/** + * API CLI subcommand - delegates to a running mux server via HTTP. + * + * This module is loaded lazily to avoid pulling in ESM-only dependencies + * (trpc-cli) when running other commands like the desktop app. + */ + +import { createCli } from "trpc-cli"; +import { router } from "@/node/orpc/router"; +import { proxifyOrpc } from "./proxifyOrpc"; +import type { Command } from "commander"; + +const baseUrl = process.env.MUX_SERVER_URL ?? "http://localhost:3000"; +const authToken = process.env.MUX_SERVER_AUTH_TOKEN; + +const proxiedRouter = proxifyOrpc(router(), { baseUrl, authToken }); +const cli = createCli({ router: proxiedRouter }).buildProgram() as Command; + +cli.name("mux api"); +cli.description("Interact with the mux API via a running server"); +cli.parse(); diff --git a/src/cli/index.ts b/src/cli/index.ts index ed2c2df8a..91da07889 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,19 +1,51 @@ #!/usr/bin/env node -const subcommand = process.argv.length > 2 ? process.argv[2] : null; +import { Command } from "commander"; +import { VERSION } from "../version"; -if (subcommand === "server") { - // Remove 'server' from args since main-server doesn't expect it as a positional argument. - process.argv.splice(2, 1); - // eslint-disable-next-line @typescript-eslint/no-require-imports - require("./server"); -} else if (subcommand === "version") { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { VERSION } = require("../version") as { - VERSION: { git_describe: string; git_commit: string }; - }; - console.log(`mux ${VERSION.git_describe} (${VERSION.git_commit})`); -} else { +const program = new Command(); + +program + .name("mux") + .description("mux - coder multiplexer") + .version(`mux ${VERSION.git_describe} (${VERSION.git_commit})`, "-v, --version"); + +// Subcommands with their own CLI parsers - disable help interception so --help passes through +program + .command("server") + .description("Start the HTTP/WebSocket oRPC server") + .helpOption(false) + .allowUnknownOption() + .allowExcessArguments() + .action(() => { + process.argv.splice(2, 1); + // eslint-disable-next-line @typescript-eslint/no-require-imports + require("./server"); + }); + +program + .command("api") + .description("Interact with the mux API via a running server") + .helpOption(false) + .allowUnknownOption() + .allowExcessArguments() + .action(() => { + process.argv.splice(2, 1); + // eslint-disable-next-line @typescript-eslint/no-require-imports + require("./api"); + }); + +program + .command("version") + .description("Show version information") + .action(() => { + console.log(`mux ${VERSION.git_describe} (${VERSION.git_commit})`); + }); + +// Default action: launch desktop app when no subcommand given +program.action(() => { // eslint-disable-next-line @typescript-eslint/no-require-imports require("../desktop/main"); -} +}); + +program.parse(); diff --git a/src/cli/proxifyOrpc.test.ts b/src/cli/proxifyOrpc.test.ts new file mode 100644 index 000000000..d3f25374f --- /dev/null +++ b/src/cli/proxifyOrpc.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, test } from "bun:test"; +import { z } from "zod"; +import * as zod4Core from "zod/v4/core"; +import { router } from "@/node/orpc/router"; +import { proxifyOrpc } from "./proxifyOrpc"; + +describe("proxifyOrpc schema enhancement", () => { + describe("describeZodType", () => { + // Helper to get description from a schema via JSON Schema conversion + function getJsonSchemaDescription(schema: z.ZodTypeAny): string | undefined { + const jsonSchema = zod4Core.toJSONSchema(schema, { + io: "input", + unrepresentable: "any", + override: (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access + const meta = (ctx.zodSchema as any).meta?.(); + if (meta) Object.assign(ctx.jsonSchema, meta); + }, + }); + return jsonSchema.description; + } + + test("described object schema has description in JSON Schema", () => { + const schema = z.object({ foo: z.string() }).describe("Test description"); + const desc = getJsonSchemaDescription(schema); + expect(desc).toBe("Test description"); + }); + + test("enum values are preserved in JSON Schema", () => { + const schema = z.enum(["a", "b", "c"]); + const jsonSchema = zod4Core.toJSONSchema(schema, { + io: "input", + unrepresentable: "any", + }); + expect(jsonSchema.enum).toEqual(["a", "b", "c"]); + }); + + test("optional fields are marked in JSON Schema", () => { + const schema = z.object({ + required: z.string(), + optional: z.string().optional(), + }); + const jsonSchema = zod4Core.toJSONSchema(schema, { + io: "input", + unrepresentable: "any", + override: (ctx) => { + if (ctx.zodSchema?.constructor?.name === "ZodOptional") { + ctx.jsonSchema.optional = true; + } + }, + }); + expect(jsonSchema.required).toContain("required"); + expect(jsonSchema.required).not.toContain("optional"); + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access + expect((jsonSchema.properties as any)?.optional?.optional).toBe(true); + }); + }); + + describe("void schema handling", () => { + test("void schema converts to empty JSON Schema object", () => { + // In the actual proxifyOrpc, void schemas are converted to z.object({}) + const emptyObj = z.object({}); + const jsonSchema = zod4Core.toJSONSchema(emptyObj, { + io: "input", + unrepresentable: "any", + }); + expect(jsonSchema.type).toBe("object"); + expect(jsonSchema.properties).toEqual({}); + }); + }); + + describe("nested object descriptions", () => { + test("described nested object preserves description", () => { + const nested = z + .object({ + field1: z.string(), + field2: z.number(), + }) + .describe("Required: field1: string, field2: number"); + + const parent = z.object({ + nested, + }); + + const jsonSchema = zod4Core.toJSONSchema(parent, { + io: "input", + unrepresentable: "any", + override: (ctx) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access + const meta = (ctx.zodSchema as any).meta?.(); + if (meta) Object.assign(ctx.jsonSchema, meta); + }, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const nestedJsonSchema = (jsonSchema.properties as any)?.nested; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(nestedJsonSchema?.description).toBe("Required: field1: string, field2: number"); + }); + }); +}); + +describe("proxifyOrpc CLI help output", () => { + test("workspace resume-stream shows options description", () => { + const r = router(); + const proxied = proxifyOrpc(r, { baseUrl: "http://localhost:8080" }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const resumeStream = (proxied as any).workspace?.resumeStream; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const inputSchema = resumeStream?.["~orpc"]?.inputSchema; + + // The options field should have a description + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const optionsField = inputSchema?.def?.shape?.options; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(optionsField?.description).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(optionsField?.description).toContain("model: string"); + }); + + test("workspace list has empty object schema (no options)", () => { + const r = router(); + const proxied = proxifyOrpc(r, { baseUrl: "http://localhost:8080" }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const listProc = (proxied as any).workspace?.list; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const inputSchema = listProc?.["~orpc"]?.inputSchema; + + // void input should be converted to empty object + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(inputSchema?.def?.type).toBe("object"); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument + expect(Object.keys(inputSchema?.def?.shape ?? {})).toHaveLength(0); + }); + + test("enhanced schema preserves _zod property for JSON Schema conversion", () => { + const r = router(); + const proxied = proxifyOrpc(r, { baseUrl: "http://localhost:8080" }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const resumeStream = (proxied as any).workspace?.resumeStream; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const inputSchema = resumeStream?.["~orpc"]?.inputSchema; + + // Must have _zod for trpc-cli to detect Zod 4 + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(inputSchema?._zod).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(inputSchema?._zod?.version?.major).toBe(4); + + // _zod.def should have the enhanced shape + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const zodDefOptions = inputSchema?._zod?.def?.shape?.options; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(zodDefOptions?.description).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(zodDefOptions?.description).toContain("model: string"); + }); + + test("JSON Schema for options includes description", () => { + const r = router(); + const proxied = proxifyOrpc(r, { baseUrl: "http://localhost:8080" }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const resumeStream = (proxied as any).workspace?.resumeStream; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const inputSchema = resumeStream?.["~orpc"]?.inputSchema; + + // Convert to JSON Schema (what trpc-cli does) + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + const jsonSchema = zod4Core.toJSONSchema(inputSchema, { + io: "input", + unrepresentable: "any", + override: (ctx) => { + if (ctx.zodSchema?.constructor?.name === "ZodOptional") { + ctx.jsonSchema.optional = true; + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access + const meta = (ctx.zodSchema as any).meta?.(); + if (meta) Object.assign(ctx.jsonSchema, meta); + }, + }); + + // The options property should have a description + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const optionsJsonSchema = (jsonSchema.properties as any)?.options; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(optionsJsonSchema?.description).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(optionsJsonSchema?.description).toContain("model: string"); + }); +}); diff --git a/src/cli/proxifyOrpc.ts b/src/cli/proxifyOrpc.ts new file mode 100644 index 000000000..f17d005d5 --- /dev/null +++ b/src/cli/proxifyOrpc.ts @@ -0,0 +1,613 @@ +/** + * Creates an oRPC router proxy that delegates procedure calls to a running server via HTTP. + * + * This allows using trpc-cli with an oRPC router without needing to initialize + * services locally - calls are forwarded to a running mux server. + * + * The returned router maintains the same structure and schemas as the original, + * so trpc-cli can extract procedure metadata for CLI generation. + */ + +import { RPCLink as HTTPRPCLink } from "@orpc/client/fetch"; +import { createORPCClient } from "@orpc/client"; +import { isProcedure } from "@orpc/server"; +import { z } from "zod"; +import type { AppRouter } from "@/node/orpc/router"; +import type { RouterClient } from "@orpc/server"; + +// Pre-create an empty object schema for void inputs +const emptyObjectSchema = z.object({}); + +export interface ProxifyOrpcOptions { + /** Base URL of the oRPC server, e.g., "http://localhost:8080" */ + baseUrl: string; + /** Optional auth token for Bearer authentication */ + authToken?: string; +} + +interface OrpcDef { + inputSchema?: unknown; + outputSchema?: unknown; + middlewares?: unknown[]; + inputValidationIndex?: number; + outputValidationIndex?: number; + errorMap?: unknown; + meta?: unknown; + route?: unknown; + config?: unknown; + handler?: (opts: { input: unknown; context?: unknown }) => Promise; +} + +// Duck-typing interfaces for Zod 4 schema introspection (no Zod import needed) +// Zod 4 uses schema.def.type instead of schema._def.typeName +interface Zod4Def { + type?: string; + shape?: Record; + innerType?: Zod4Like; + element?: Zod4Like; + options?: Zod4Like[]; + // Zod 4 enums use entries (key-value map) instead of values (array) + entries?: Record; + // Zod 4 literals use values array (e.g., ["a"]) instead of value + values?: readonly unknown[]; + value?: unknown; + // Discriminated unions have a discriminator field + discriminator?: string; +} + +interface Zod4Like { + def?: Zod4Def; + _def?: Zod4Def; + description?: string; + describe?: (desc: string) => Zod4Like; +} + +/** + * Check if a value looks like a Zod 4 schema (duck-typing). + */ +function isZod4Like(value: unknown): value is Zod4Like { + if (typeof value !== "object" || value === null) return false; + const v = value as Zod4Like; + return ( + (v.def !== undefined && typeof v.def === "object") || + (v._def !== undefined && typeof v._def === "object") + ); +} + +/** + * Get the def from a Zod 4 schema (handles both .def and ._def). + */ +function getDef(schema: Zod4Like): Zod4Def | undefined { + return schema.def ?? schema._def; +} + +/** + * Unwrap optional/nullable/default wrappers to get the inner schema type. + */ +function unwrapSchema(schema: Zod4Like): Zod4Like { + let current = schema; + let currentDef = getDef(current); + while ( + currentDef && + (currentDef.type === "optional" || + currentDef.type === "nullable" || + currentDef.type === "default") && + currentDef.innerType + ) { + current = currentDef.innerType; + currentDef = getDef(current); + } + return current; +} + +/** + * Check if a field schema is optional (wrapped in optional/default). + */ +function isOptionalField(schema: Zod4Like): boolean { + const def = getDef(schema); + return def?.type === "optional" || def?.type === "default"; +} + +/** + * Detect a common discriminator field in a plain union (not z.discriminatedUnion). + * Looks for a field that has a literal value in all object variants. + * Common patterns: "type", "kind", "tag" + */ +function detectCommonDiscriminator(options: Zod4Like[]): string | undefined { + const commonFields = ["type", "kind", "tag", "variant"]; + + for (const fieldName of commonFields) { + let allHaveLiteral = true; + for (const option of options) { + if (!isZod4Like(option)) { + allHaveLiteral = false; + break; + } + const optDef = getDef(option); + if (optDef?.type !== "object" || !optDef.shape) { + allHaveLiteral = false; + break; + } + const field = optDef.shape[fieldName]; + if (!field || !isZod4Like(field)) { + allHaveLiteral = false; + break; + } + const fieldDef = getDef(field); + if (fieldDef?.type !== "literal") { + allHaveLiteral = false; + break; + } + } + if (allHaveLiteral) { + return fieldName; + } + } + + return undefined; +} + +/** + * Describe a Zod 4 type for CLI help. + * Returns either a simple type string or a multiline hierarchical description. + * @param schema - The Zod schema to describe + * @param indent - Current indentation level for nested structures + */ +function describeZodType(schema: unknown, indent = 0): string { + if (!isZod4Like(schema)) return "unknown"; + + // Unwrap to get the actual type + const unwrapped = unwrapSchema(schema); + const def = getDef(unwrapped); + if (!def) return "unknown"; + + const type = def.type; + + switch (type) { + case "string": + return "string"; + case "number": + return "number"; + case "boolean": + return "boolean"; + case "literal": + // Zod 4 uses values array, Zod 3 uses value + if (def.values && def.values.length > 0) { + return JSON.stringify(def.values[0]); + } + if (def.value !== undefined) { + return JSON.stringify(def.value); + } + return "literal"; + case "enum": + if (def.entries) { + return Object.values(def.entries) + .map((v) => JSON.stringify(v)) + .join("|"); + } + return "enum"; + case "array": + if (def.element && isZod4Like(def.element)) { + const elemUnwrapped = unwrapSchema(def.element); + const elemDef = getDef(elemUnwrapped); + // For arrays of objects, show "Array of:" with nested structure + if (elemDef?.type === "object" && typeof elemDef.shape === "object") { + const childFields = describeObjectFieldsHierarchical(elemUnwrapped, indent + 1); + return `Array of:\n${childFields}`; + } + // For arrays of unions (discriminated or regular), describe the element + if (elemDef?.type === "union") { + const elemDesc = describeZodType(elemUnwrapped, indent); + if (elemDesc.startsWith("One of:")) { + return `Array of ${elemDesc}`; + } + } + // For arrays of primitives, show inline + return `${describeZodType(def.element, indent)}[]`; + } + return "array"; + case "optional": + case "nullable": + case "default": + // Should be unwrapped already, but handle just in case + if (def.innerType) { + return describeZodType(def.innerType, indent); + } + return "unknown"; + case "union": + if (def.options && Array.isArray(def.options)) { + const variants = def.options + .map((o) => describeZodType(o, indent)) + .filter((v): v is string => v !== undefined && v !== null); + if (variants.length === 0) return "union"; + // Check if all variants are primitives/enums (no newlines) + const allPrimitive = variants.every((v) => !v.includes("\n")); + if (allPrimitive) { + return variants.join("|"); + } + // For discriminated unions or plain unions with a common "type" field, + // extract the discriminator value from each variant for labeling + const indentStr = " ".repeat(indent + 1); + const discriminator = def.discriminator ?? detectCommonDiscriminator(def.options); + const formattedVariants = def.options + .map((option, i) => { + const variantDesc = variants[i]; + if (!variantDesc) return ""; + // Try to extract discriminator value for labeling + let label = `Variant ${i + 1}`; + if (discriminator && isZod4Like(option)) { + const optDef = getDef(option); + if (optDef?.type === "object" && optDef.shape) { + const discField = optDef.shape[discriminator]; + if (discField && isZod4Like(discField)) { + const discDef = getDef(discField); + if (discDef?.type === "literal") { + const val = discDef.values?.[0] ?? discDef.value; + if (val !== undefined) label = `${discriminator}=${JSON.stringify(val)}`; + } + } + } + } + if (variantDesc.startsWith("\n")) { + return `${indentStr}${label}:${variantDesc}`; + } + return `${indentStr}${label}: ${variantDesc}`; + }) + .filter(Boolean); + return `One of:\n${formattedVariants.join("\n")}`; + } + return "union"; + case "object": + // For objects, return hierarchical description + if (typeof def.shape === "object") { + const childFields = describeObjectFieldsHierarchical(unwrapped, indent + 1); + return `\n${childFields}`; + } + return "object"; + case "any": + return "any"; + case "unknown": + return "unknown"; + case "record": + return "object"; + default: + return type ?? "unknown"; + } +} + +/** + * Describe object fields in hierarchical YAML-like format. + * Each field is on its own line with proper indentation. + */ +function describeObjectFieldsHierarchical(schema: Zod4Like, indent: number): string { + const def = getDef(schema); + if (!def || typeof def.shape !== "object") return `${" ".repeat(indent)}- object`; + + const shape = def.shape; + const lines: string[] = []; + const indentStr = " ".repeat(indent); + + for (const [key, fieldSchema] of Object.entries(shape)) { + if (!isZod4Like(fieldSchema)) continue; + + const isOpt = isOptionalField(fieldSchema); + const optMark = isOpt ? "?" : ""; + const fieldType = describeZodType(fieldSchema, indent); + + // Guard against undefined/null fieldType + if (!fieldType) { + lines.push(`${indentStr}- ${key}${optMark}: unknown`); + continue; + } + + // If the type starts with newline, it's a nested object - append directly + if (fieldType.startsWith("\n")) { + lines.push(`${indentStr}- ${key}${optMark}:${fieldType}`); + } else if (fieldType.startsWith("Array of:\n")) { + // Array of objects - split and handle + lines.push(`${indentStr}- ${key}${optMark}: ${fieldType}`); + } else { + lines.push(`${indentStr}- ${key}${optMark}: ${fieldType}`); + } + } + + return lines.join("\n"); +} + +/** + * Describe a ZodObject's fields for the top-level CLI description. + * Separates required and optional fields with headers. + */ +function describeZodObjectFields(schema: Zod4Like): string { + const def = getDef(schema); + if (!def || typeof def.shape !== "object") return "object"; + + const shape = def.shape; + const requiredLines: string[] = []; + const optionalLines: string[] = []; + + for (const [key, fieldSchema] of Object.entries(shape)) { + if (!isZod4Like(fieldSchema)) continue; + + const isOpt = isOptionalField(fieldSchema); + const optMark = isOpt ? "?" : ""; + const fieldType = describeZodType(fieldSchema, 1); + + // Format the field entry + let entry: string; + if (fieldType.startsWith("\n")) { + // Nested object - the type already includes the newline and indented children + entry = `- ${key}${optMark}:${fieldType}`; + } else if (fieldType.startsWith("Array of:\n") || fieldType.startsWith("Array of One of:\n")) { + // Array of objects or discriminated unions + entry = `- ${key}${optMark}: ${fieldType}`; + } else { + entry = `- ${key}${optMark}: ${fieldType}`; + } + + if (isOpt) { + optionalLines.push(entry); + } else { + requiredLines.push(entry); + } + } + + const parts: string[] = []; + if (requiredLines.length > 0) { + parts.push(`Required:\n${requiredLines.join("\n")}`); + } + if (optionalLines.length > 0) { + parts.push(`Optional:\n${optionalLines.join("\n")}`); + } + + const content = parts.join("\n") || "object"; + + // Add base indent to all lines and prepend newline for CLI formatting + // This ensures the description appears indented under the --option flag + const baseIndent = " "; // 6 spaces + const indentedContent = content + .split("\n") + .map((line) => baseIndent + line) + .join("\n"); + return "\n" + indentedContent; +} + +/** + * Enhance a Zod 4 schema by injecting rich descriptions for complex fields. + * This makes CLI help show field details instead of raw JSON Schema. + * + * For object-typed fields without descriptions, we inject a description + * showing all available fields with their types. + * + * For union and array fields, we generate hierarchical descriptions. + * + * Special handling for void/undefined schemas which don't convert well to JSON Schema. + */ +function enhanceInputSchema(schema: unknown): unknown { + if (!isZod4Like(schema)) return schema; + + const def = getDef(schema); + + // Handle void/undefined schemas - trpc-cli doesn't handle these well. + // Convert them to an empty object schema which converts properly to JSON Schema. + if (def?.type === "void" || def?.type === "undefined") { + return emptyObjectSchema; + } + + if (def?.type !== "object" || typeof def.shape !== "object") { + return schema; + } + + const shape = def.shape; + let hasEnhancements = false; + const enhancedShape: Record = {}; + + for (const [key, fieldSchema] of Object.entries(shape)) { + if (!isZod4Like(fieldSchema)) { + enhancedShape[key] = fieldSchema; + continue; + } + + // Skip if already has a description + if (fieldSchema.description || typeof fieldSchema.describe !== "function") { + enhancedShape[key] = fieldSchema; + continue; + } + + // Unwrap optional/default to get the inner type + let innerSchema = fieldSchema; + let innerDef = getDef(fieldSchema); + + while ( + innerDef && + (innerDef.type === "optional" || innerDef.type === "default") && + innerDef.innerType + ) { + innerSchema = innerDef.innerType; + innerDef = getDef(innerSchema); + } + + const innerType = innerDef?.type; + + // For objects, replace with z.any().describe(...) to avoid trpc-cli appending + // "Object (json formatted); Required: [...]" from the JSON Schema + if (innerType === "object" && typeof innerDef?.shape === "object") { + const desc = describeZodObjectFields(innerSchema); + const isOptional = getDef(fieldSchema)?.type === "optional"; + const replacement = isOptional ? z.any().optional().describe(desc) : z.any().describe(desc); + enhancedShape[key] = replacement; + hasEnhancements = true; + } + // For unions and arrays of complex types, replace with z.any().describe(...) + // This prevents trpc-cli from appending raw JSON Schema (anyOf, oneOf, etc.) + else if (innerType === "union" || innerType === "array") { + const desc = describeZodType(innerSchema, 0); + // Only replace if it's multi-line (complex type) + if (desc.includes("\n")) { + const baseIndent = " "; + const indentedDesc = desc + .split("\n") + .map((line) => baseIndent + line) + .join("\n"); + // Replace with z.any() to avoid anyOf/oneOf in JSON Schema + // Preserve optionality by wrapping appropriately + const isOptional = getDef(fieldSchema)?.type === "optional"; + const replacement = isOptional + ? z + .any() + .optional() + .describe("\n" + indentedDesc) + : z.any().describe("\n" + indentedDesc); + enhancedShape[key] = replacement; + hasEnhancements = true; + } else { + enhancedShape[key] = fieldSchema; + } + } else { + enhancedShape[key] = fieldSchema; + } + } + + if (!hasEnhancements) return schema; + + // Clone the schema preserving the _zod property which trpc-cli needs for Zod 4 detection. + // Object spread doesn't capture _zod properly (it may be non-enumerable or have getters), + // so we explicitly copy it. We also update _zod.def.shape to use our enhanced shape, + // since toJSONSchema reads from _zod.def, not schema.def. + const enhancedDef = { ...def, shape: enhancedShape }; + const enhanced = { + ...schema, + def: enhancedDef, + _def: enhancedDef, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment + const originalZod = (schema as any)._zod; + if (originalZod) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment + (enhanced as any)._zod = { + ...originalZod, + def: enhancedDef, // toJSONSchema reads shape from _zod.def + }; + } + return enhanced; +} + +interface OrpcProcedureLike { + "~orpc": OrpcDef; +} + +interface OrpcRouterLike { + [key: string]: OrpcProcedureLike | OrpcRouterLike; +} + +/** + * Creates a proxied oRPC router that delegates to an HTTP client. + * + * The HTTP client is created lazily on each procedure invocation to avoid + * connection overhead during CLI initialization (help, autocomplete, etc.). + * + * @param router - The original oRPC router (used to extract procedure schemas) + * @param options - Configuration for connecting to the server + * @returns A router-like object compatible with trpc-cli that proxies calls to the server + * + * @example + * ```ts + * import { router } from "@/node/orpc/router"; + * import { proxifyOrpc } from "./proxifyOrpc"; + * + * const proxiedRouter = proxifyOrpc(router(), { + * baseUrl: "http://localhost:8080", + * authToken: "secret", + * }); + * + * const cli = createCli({ router: proxiedRouter }); + * ``` + */ +type ClientFactory = () => RouterClient; + +export function proxifyOrpc(router: AppRouter, options: ProxifyOrpcOptions): AppRouter { + // Client factory - creates a new client on each procedure invocation + const createClient: ClientFactory = () => { + const link = new HTTPRPCLink({ + url: `${options.baseUrl}/orpc`, + headers: options.authToken ? { Authorization: `Bearer ${options.authToken}` } : undefined, + }); + return createORPCClient(link); + }; + + return createRouterProxy( + router as unknown as OrpcRouterLike, + createClient, + [] + ) as unknown as AppRouter; +} + +function createRouterProxy( + router: OrpcRouterLike, + createClient: ClientFactory, + path: string[] +): OrpcRouterLike { + const result: OrpcRouterLike = {}; + + for (const [key, value] of Object.entries(router)) { + const newPath = [...path, key]; + + if (isProcedure(value)) { + result[key] = createProcedureProxy( + value as unknown as OrpcProcedureLike, + createClient, + newPath + ); + } else if (typeof value === "object" && value !== null && !Array.isArray(value)) { + result[key] = createRouterProxy(value as OrpcRouterLike, createClient, newPath); + } + } + + return result; +} + +function createProcedureProxy( + procedure: OrpcProcedureLike, + createClient: ClientFactory, + path: string[] +): OrpcProcedureLike { + const originalDef = procedure["~orpc"]; + + // Enhance input schema to show rich field descriptions in CLI help + const enhancedInputSchema = enhanceInputSchema(originalDef.inputSchema); + + // Navigate to the client method using the path (lazily creates client on call) + const getClientMethod = (): ((input: unknown) => Promise) => { + const client = createClient(); + let method: unknown = client; + for (const segment of path) { + method = (method as Record)[segment]; + } + return method as (input: unknown) => Promise; + }; + + // Create a procedure-like object that: + // 1. Has the same ~orpc metadata (for schema extraction by trpc-cli) + // 2. When called via @orpc/server's `call()`, delegates to the HTTP client + // + // The trick is that @orpc/server's `call()` function looks for a handler + // in the procedure definition. We provide one that proxies to the client. + const proxy: OrpcProcedureLike = { + "~orpc": { + ...originalDef, + // Use enhanced schema for CLI help generation + inputSchema: enhancedInputSchema, + // Keep the original middlewares empty for the proxy - we don't need them + // since the server will run its own middleware chain + middlewares: [], + // The handler that will be called by @orpc/server's `call()` function + // eslint-disable-next-line @typescript-eslint/no-explicit-any + handler: async (opts: { input: unknown }): Promise => { + const clientMethod = getClientMethod(); + return clientMethod(opts.input); + }, + }, + }; + + return proxy; +}