From abe2ffaf85fe0c0d39bfc5e3f5bd5ddf07d4a558 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 1 Dec 2025 14:14:31 +0100 Subject: [PATCH 1/4] refactor: migrate CLI to commander for structured argument parsing Replace manual argv parsing with commander.js to provide proper subcommand handling, automatic help generation, and conventional -v/--version flags. Adds trpc-cli as a dependency (likely for future oRPC CLI tooling). --- bun.lock | 4 ++++ package.json | 2 ++ src/cli/index.ts | 46 ++++++++++++++++++++++++++++++++-------------- 3 files changed, 38 insertions(+), 14 deletions(-) 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/index.ts b/src/cli/index.ts index ed2c2df8a..66c28308c 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,19 +1,37 @@ #!/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"); + +program + .command("server") + .description("Start the HTTP/WebSocket oRPC server") + .allowUnknownOption() // server.ts handles its own options via commander + .action(() => { + // Remove 'server' from args since server.ts has its own commander instance + process.argv.splice(2, 1); + // eslint-disable-next-line @typescript-eslint/no-require-imports + require("./server"); + }); + +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(); From d78b7d8d9f2ce3a0bf6f4e6e897fffe56523deda Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 1 Dec 2025 14:37:52 +0100 Subject: [PATCH 2/4] feat: add `mux api` subcommand for direct oRPC access Integrates trpc-cli to expose the oRPC router as CLI commands, enabling scripted interactions without running the full desktop app or server. Services are lazily initialized only when the api subcommand is invoked to avoid startup overhead for other commands. --- src/cli/index.ts | 105 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 80 insertions(+), 25 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index 66c28308c..97b72dc48 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -2,36 +2,91 @@ import { Command } from "commander"; import { VERSION } from "../version"; +import { createCli } from "trpc-cli"; +import { router } from "@/node/orpc/router"; +import { Config } from "@/node/config"; +import { ServiceContainer } from "@/node/services/serviceContainer"; +import { migrateLegacyMuxHome } from "@/common/constants/paths"; +import type { BrowserWindow } from "electron"; +import type { ORPCContext } from "@/node/orpc/context"; -const program = new Command(); +// Minimal BrowserWindow stub for services that expect one (same as server.ts) +const mockWindow: BrowserWindow = { + isDestroyed: () => false, + setTitle: () => undefined, + webContents: { + send: () => undefined, + openDevTools: () => undefined, + }, +} as unknown as BrowserWindow; -program - .name("mux") - .description("mux - coder multiplexer") - .version(`mux ${VERSION.git_describe} (${VERSION.git_commit})`, "-v, --version"); +async function createServiceContext(): Promise { + migrateLegacyMuxHome(); -program - .command("server") - .description("Start the HTTP/WebSocket oRPC server") - .allowUnknownOption() // server.ts handles its own options via commander - .action(() => { - // Remove 'server' from args since server.ts has its own commander instance - process.argv.splice(2, 1); + const config = new Config(); + const serviceContainer = new ServiceContainer(config); + await serviceContainer.initialize(); + serviceContainer.windowService.setMainWindow(mockWindow); + + return { + projectService: serviceContainer.projectService, + workspaceService: serviceContainer.workspaceService, + providerService: serviceContainer.providerService, + terminalService: serviceContainer.terminalService, + windowService: serviceContainer.windowService, + updateService: serviceContainer.updateService, + tokenizerService: serviceContainer.tokenizerService, + serverService: serviceContainer.serverService, + menuEventService: serviceContainer.menuEventService, + }; +} + +async function main() { + const program = new Command(); + + program + .name("mux") + .description("mux - coder multiplexer") + .version(`mux ${VERSION.git_describe} (${VERSION.git_commit})`, "-v, --version"); + + program + .command("server") + .description("Start the HTTP/WebSocket oRPC server") + .allowUnknownOption() // server.ts handles its own options via commander + .action(() => { + // Remove 'server' from args since server.ts has its own commander instance + process.argv.splice(2, 1); + // eslint-disable-next-line @typescript-eslint/no-require-imports + require("./server"); + }); + + program + .command("version") + .description("Show version information") + .action(() => { + console.log(`mux ${VERSION.git_describe} (${VERSION.git_commit})`); + }); + + // Only initialize services if the 'api' subcommand is being used + if (process.argv[2] === "api") { + const context = await createServiceContext(); + program.addCommand( + (createCli({ router: router(), context }).buildProgram() as Command) + .name("api") + .description("Interact with the oRPC API directly") + ); + } + + // Default action: launch desktop app when no subcommand given + program.action(() => { // eslint-disable-next-line @typescript-eslint/no-require-imports - require("./server"); + require("../desktop/main"); }); -program - .command("version") - .description("Show version information") - .action(() => { - console.log(`mux ${VERSION.git_describe} (${VERSION.git_commit})`); - }); + program.parse(); +} -// Default action: launch desktop app when no subcommand given -program.action(() => { - // eslint-disable-next-line @typescript-eslint/no-require-imports - require("../desktop/main"); +main().catch((error) => { + console.error("CLI initialization failed:", error); + process.exit(1); }); - -program.parse(); From efccd992be0004beb76da5a0b97ff95c6a960ffe Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 1 Dec 2025 14:52:11 +0100 Subject: [PATCH 3/4] refactor: delegate `mux api` CLI to running server via HTTP Instead of initializing services locally, the api subcommand now proxies oRPC procedure calls to a running mux server. This avoids the heavyweight service initialization when using the CLI and ensures consistent behavior with the server. Key changes: - Add proxifyOrpc utility that wraps an oRPC router to forward calls via HTTPRPCLink to a remote server - Lazily import trpc-cli only when api command is invoked - Remove inline service context creation from CLI entry point - Enhance Zod 4 schema descriptions for better CLI help output --- src/cli/api.ts | 24 +++ src/cli/index.ts | 124 +++++---------- src/cli/proxifyOrpc.ts | 339 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 405 insertions(+), 82 deletions(-) create mode 100644 src/cli/api.ts create mode 100644 src/cli/proxifyOrpc.ts diff --git a/src/cli/api.ts b/src/cli/api.ts new file mode 100644 index 000000000..c19ca5b9a --- /dev/null +++ b/src/cli/api.ts @@ -0,0 +1,24 @@ +/** + * 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 { Command } from "commander"; + +export async function runApiCli(parent: Command): Promise { + const baseUrl = process.env.MUX_SERVER_URL ?? "http://localhost:3000"; + const authToken = process.env.MUX_AUTH_TOKEN; + + const proxiedRouter = proxifyOrpc(router(), { baseUrl, authToken }); + const cli = createCli({ router: proxiedRouter }).buildProgram() as Command; + + cli.name("api"); + cli.description("Interact with the oRPC API via a running server"); + cli.parent = parent; + cli.parse(); +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 97b72dc48..2203e079c 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -2,91 +2,51 @@ import { Command } from "commander"; import { VERSION } from "../version"; -import { createCli } from "trpc-cli"; -import { router } from "@/node/orpc/router"; -import { Config } from "@/node/config"; -import { ServiceContainer } from "@/node/services/serviceContainer"; -import { migrateLegacyMuxHome } from "@/common/constants/paths"; -import type { BrowserWindow } from "electron"; -import type { ORPCContext } from "@/node/orpc/context"; -// Minimal BrowserWindow stub for services that expect one (same as server.ts) -const mockWindow: BrowserWindow = { - isDestroyed: () => false, - setTitle: () => undefined, - webContents: { - send: () => undefined, - openDevTools: () => undefined, - }, -} as unknown as BrowserWindow; - -async function createServiceContext(): Promise { - migrateLegacyMuxHome(); - - const config = new Config(); - const serviceContainer = new ServiceContainer(config); - await serviceContainer.initialize(); - serviceContainer.windowService.setMainWindow(mockWindow); - - return { - projectService: serviceContainer.projectService, - workspaceService: serviceContainer.workspaceService, - providerService: serviceContainer.providerService, - terminalService: serviceContainer.terminalService, - windowService: serviceContainer.windowService, - updateService: serviceContainer.updateService, - tokenizerService: serviceContainer.tokenizerService, - serverService: serviceContainer.serverService, - menuEventService: serviceContainer.menuEventService, - }; -} - -async function main() { - const program = new Command(); - - program - .name("mux") - .description("mux - coder multiplexer") - .version(`mux ${VERSION.git_describe} (${VERSION.git_commit})`, "-v, --version"); - - program - .command("server") - .description("Start the HTTP/WebSocket oRPC server") - .allowUnknownOption() // server.ts handles its own options via commander - .action(() => { - // Remove 'server' from args since server.ts has its own commander instance - process.argv.splice(2, 1); - // eslint-disable-next-line @typescript-eslint/no-require-imports - require("./server"); - }); - - program - .command("version") - .description("Show version information") - .action(() => { - console.log(`mux ${VERSION.git_describe} (${VERSION.git_commit})`); - }); - - // Only initialize services if the 'api' subcommand is being used - if (process.argv[2] === "api") { - const context = await createServiceContext(); - program.addCommand( - (createCli({ router: router(), context }).buildProgram() as Command) - .name("api") - .description("Interact with the oRPC API directly") - ); - } - - // Default action: launch desktop app when no subcommand given - program.action(() => { +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("../desktop/main"); + require("./server"); + }); + +program + .command("api") + .description("Interact with the oRPC API via a running server") + .helpOption(false) + .allowUnknownOption() + .allowExcessArguments() + .action(async () => { + process.argv.splice(2, 1); + // eslint-disable-next-line no-restricted-syntax -- dynamic import needed for ESM-only trpc-cli + const { runApiCli } = await import("./api"); + await runApiCli(program); }); - program.parse(); -} +program + .command("version") + .description("Show version information") + .action(() => { + console.log(`mux ${VERSION.git_describe} (${VERSION.git_commit})`); + }); -main().catch((error) => { - console.error("CLI initialization failed:", error); - process.exit(1); +// 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.ts b/src/cli/proxifyOrpc.ts new file mode 100644 index 000000000..88fd563ea --- /dev/null +++ b/src/cli/proxifyOrpc.ts @@ -0,0 +1,339 @@ +/** + * 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 type { AppRouter } from "@/node/orpc/router"; +import type { RouterClient } from "@orpc/server"; + +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[]; + values?: readonly string[]; + value?: unknown; +} + +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; +} + +/** + * Describe a Zod 4 type as a concise string for CLI help. + */ +function describeZodType(schema: unknown): string { + if (!isZod4Like(schema)) return "unknown"; + + const def = getDef(schema); + if (!def) return "unknown"; + + const type = def.type; + + switch (type) { + case "string": + return "string"; + case "number": + return "number"; + case "boolean": + return "boolean"; + case "literal": + return JSON.stringify(def.value); + case "enum": + if (def.values) { + return def.values.map((v) => JSON.stringify(v)).join(" | "); + } + return "enum"; + case "array": + if (def.element) { + return `${describeZodType(def.element)}[]`; + } + return "array"; + case "optional": + case "nullable": + if (def.innerType) { + return describeZodType(def.innerType); + } + return "unknown"; + case "default": + if (def.innerType) { + return describeZodType(def.innerType); + } + return "unknown"; + case "union": + if (def.options && Array.isArray(def.options)) { + return def.options.map(describeZodType).join(" | "); + } + return "union"; + case "object": + return describeZodObject(schema as Zod4Like); + case "any": + return "any"; + case "unknown": + return "unknown"; + case "record": + return "Record"; + default: + return type ?? "unknown"; + } +} + +/** + * Describe a ZodObject's shape as a concise field list. + */ +function describeZodObject(schema: Zod4Like): string { + const def = getDef(schema); + if (!def || typeof def.shape !== "object") return "object"; + + const shape = def.shape; + const fields: string[] = []; + + for (const [key, fieldSchema] of Object.entries(shape)) { + if (!isZod4Like(fieldSchema)) continue; + + const fieldDef = getDef(fieldSchema); + const isOptional = fieldDef?.type === "optional" || fieldDef?.type === "default"; + const fieldType = describeZodType(fieldSchema); + const optMarker = isOptional ? "?" : ""; + + fields.push(`${key}${optMarker}: ${fieldType}`); + } + + return `{ ${fields.join(", ")} }`; +} + +/** + * Enhance a Zod 4 schema by injecting rich descriptions for object fields. + * This makes CLI help show field details instead of just "Object (json formatted)". + * + * For object-typed fields without descriptions, we inject a description + * showing all available fields with their types. + */ +function enhanceInputSchema(schema: unknown): unknown { + if (!isZod4Like(schema)) return schema; + + const def = getDef(schema); + if (!def || 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; + } + + // 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); + } + + // If the inner type is an object without a description, inject one + if ( + isZod4Like(innerSchema) && + getDef(innerSchema)?.type === "object" && + !fieldSchema.description && + typeof fieldSchema.describe === "function" + ) { + const desc = describeZodObject(innerSchema); + enhancedShape[key] = fieldSchema.describe(desc); + hasEnhancements = true; + } else { + enhancedShape[key] = fieldSchema; + } + } + + if (!hasEnhancements) return schema; + + // Clone the schema with the enhanced shape + return { + ...schema, + def: { ...def, shape: enhancedShape }, + _def: { ...def, shape: enhancedShape }, + }; +} + +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; +} From a808f8a854f6464643daf567bfa9d3b75c065f59 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 2 Dec 2025 15:28:40 +0100 Subject: [PATCH 4/4] refactor(cli): improve help output with hierarchical schema descriptions Enhance the proxifyOrpc schema description system to produce readable, hierarchical help output for complex Zod schemas. Previously, the CLI showed raw JSON Schema (anyOf, oneOf) for unions and nested objects. Key changes: - Add hierarchical YAML-like formatting for nested objects - Separate required/optional fields with clear headers - Handle discriminated unions by showing variant labels - Detect common discriminators (type/kind/tag) in plain unions - Replace complex fields with z.any().describe() to prevent trpc-cli from appending JSON Schema noise to descriptions - Convert void/undefined inputs to empty object schema for proper JSON Schema conversion - Preserve _zod property for Zod 4 detection in trpc-cli - Simplify api.ts by removing function wrapper and module-level init - Rename "oRPC API" to "mux API" for consistency --- src/cli/api.ts | 19 +- src/cli/index.ts | 9 +- src/cli/proxifyOrpc.test.ts | 194 +++++++++++++++++++ src/cli/proxifyOrpc.ts | 358 +++++++++++++++++++++++++++++++----- 4 files changed, 522 insertions(+), 58 deletions(-) create mode 100644 src/cli/proxifyOrpc.test.ts diff --git a/src/cli/api.ts b/src/cli/api.ts index c19ca5b9a..eee11821d 100644 --- a/src/cli/api.ts +++ b/src/cli/api.ts @@ -8,17 +8,14 @@ import { createCli } from "trpc-cli"; import { router } from "@/node/orpc/router"; import { proxifyOrpc } from "./proxifyOrpc"; -import { Command } from "commander"; +import type { Command } from "commander"; -export async function runApiCli(parent: Command): Promise { - const baseUrl = process.env.MUX_SERVER_URL ?? "http://localhost:3000"; - const authToken = process.env.MUX_AUTH_TOKEN; +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; +const proxiedRouter = proxifyOrpc(router(), { baseUrl, authToken }); +const cli = createCli({ router: proxiedRouter }).buildProgram() as Command; - cli.name("api"); - cli.description("Interact with the oRPC API via a running server"); - cli.parent = parent; - cli.parse(); -} +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 2203e079c..91da07889 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -25,15 +25,14 @@ program program .command("api") - .description("Interact with the oRPC API via a running server") + .description("Interact with the mux API via a running server") .helpOption(false) .allowUnknownOption() .allowExcessArguments() - .action(async () => { + .action(() => { process.argv.splice(2, 1); - // eslint-disable-next-line no-restricted-syntax -- dynamic import needed for ESM-only trpc-cli - const { runApiCli } = await import("./api"); - await runApiCli(program); + // eslint-disable-next-line @typescript-eslint/no-require-imports + require("./api"); }); program 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 index 88fd563ea..f17d005d5 100644 --- a/src/cli/proxifyOrpc.ts +++ b/src/cli/proxifyOrpc.ts @@ -11,9 +11,13 @@ 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; @@ -42,8 +46,13 @@ interface Zod4Def { innerType?: Zod4Like; element?: Zod4Like; options?: Zod4Like[]; - values?: readonly string[]; + // 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 { @@ -73,12 +82,83 @@ function getDef(schema: Zod4Like): Zod4Def | undefined { } /** - * Describe a Zod 4 type as a concise string for CLI help. + * Unwrap optional/nullable/default wrappers to get the inner schema type. */ -function describeZodType(schema: unknown): string { - if (!isZod4Like(schema)) return "unknown"; +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; @@ -91,82 +171,230 @@ function describeZodType(schema: unknown): string { case "boolean": return "boolean"; case "literal": - return JSON.stringify(def.value); + // 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.values) { - return def.values.map((v) => JSON.stringify(v)).join(" | "); + if (def.entries) { + return Object.values(def.entries) + .map((v) => JSON.stringify(v)) + .join("|"); } return "enum"; case "array": - if (def.element) { - return `${describeZodType(def.element)}[]`; + 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": - if (def.innerType) { - return describeZodType(def.innerType); - } - return "unknown"; case "default": + // Should be unwrapped already, but handle just in case if (def.innerType) { - return describeZodType(def.innerType); + return describeZodType(def.innerType, indent); } return "unknown"; case "union": if (def.options && Array.isArray(def.options)) { - return def.options.map(describeZodType).join(" | "); + 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": - return describeZodObject(schema as Zod4Like); + // 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 "Record"; + return "object"; default: return type ?? "unknown"; } } /** - * Describe a ZodObject's shape as a concise field list. + * Describe object fields in hierarchical YAML-like format. + * Each field is on its own line with proper indentation. */ -function describeZodObject(schema: Zod4Like): string { +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 fields: string[] = []; + const requiredLines: string[] = []; + const optionalLines: string[] = []; for (const [key, fieldSchema] of Object.entries(shape)) { if (!isZod4Like(fieldSchema)) continue; - const fieldDef = getDef(fieldSchema); - const isOptional = fieldDef?.type === "optional" || fieldDef?.type === "default"; - const fieldType = describeZodType(fieldSchema); - const optMarker = isOptional ? "?" : ""; + 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}`; + } - fields.push(`${key}${optMarker}: ${fieldType}`); + if (isOpt) { + optionalLines.push(entry); + } else { + requiredLines.push(entry); + } } - return `{ ${fields.join(", ")} }`; + 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 object fields. - * This makes CLI help show field details instead of just "Object (json formatted)". + * 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); - if (!def || def.type !== "object" || typeof def.shape !== "object") { + + // 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; } @@ -180,6 +408,12 @@ function enhanceInputSchema(schema: unknown): unknown { 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); @@ -193,16 +427,42 @@ function enhanceInputSchema(schema: unknown): unknown { innerDef = getDef(innerSchema); } - // If the inner type is an object without a description, inject one - if ( - isZod4Like(innerSchema) && - getDef(innerSchema)?.type === "object" && - !fieldSchema.description && - typeof fieldSchema.describe === "function" - ) { - const desc = describeZodObject(innerSchema); - enhancedShape[key] = fieldSchema.describe(desc); + 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; } @@ -210,12 +470,26 @@ function enhanceInputSchema(schema: unknown): unknown { if (!hasEnhancements) return schema; - // Clone the schema with the enhanced shape - return { + // 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: { ...def, shape: enhancedShape }, - _def: { ...def, shape: enhancedShape }, + 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 {