Skip to content

Commit 85cf87e

Browse files
authored
🤖 feat(cli): add mux api subcommand with oRPC proxy (#874)
## Summary - Migrate CLI to commander for structured subcommand handling and auto-generated help - Add `mux api` subcommand that exposes oRPC procedures via trpc-cli - Proxy API calls to the running mux server via HTTP instead of initializing services locally - Improve help output with hierarchical schema descriptions for complex Zod types _Generated with `mux`_
1 parent 2870d55 commit 85cf87e

File tree

6 files changed

+880
-14
lines changed

6 files changed

+880
-14
lines changed

bun.lock

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"ai": "^5.0.101",
3131
"ai-tokenizer": "^1.0.4",
3232
"chalk": "^5.6.2",
33+
"commander": "^14.0.2",
3334
"cors": "^2.8.5",
3435
"crc-32": "^1.2.2",
3536
"diff": "^8.0.2",
@@ -51,6 +52,7 @@
5152
"shescape": "^2.1.6",
5253
"source-map-support": "^0.5.21",
5354
"streamdown": "^1.4.0",
55+
"trpc-cli": "^0.12.1",
5456
"turndown": "^7.2.2",
5557
"undici": "^7.16.0",
5658
"write-file-atomic": "^6.0.0",
@@ -3401,6 +3403,8 @@
34013403

34023404
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
34033405

3406+
"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=="],
3407+
34043408
"truncate-utf8-bytes": ["truncate-utf8-bytes@1.0.2", "", { "dependencies": { "utf8-byte-length": "^1.0.1" } }, "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ=="],
34053409

34063410
"ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="],

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
"ai": "^5.0.101",
7272
"ai-tokenizer": "^1.0.4",
7373
"chalk": "^5.6.2",
74+
"commander": "^14.0.2",
7475
"cors": "^2.8.5",
7576
"crc-32": "^1.2.2",
7677
"diff": "^8.0.2",
@@ -92,6 +93,7 @@
9293
"shescape": "^2.1.6",
9394
"source-map-support": "^0.5.21",
9495
"streamdown": "^1.4.0",
96+
"trpc-cli": "^0.12.1",
9597
"turndown": "^7.2.2",
9698
"undici": "^7.16.0",
9799
"write-file-atomic": "^6.0.0",

src/cli/api.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/**
2+
* API CLI subcommand - delegates to a running mux server via HTTP.
3+
*
4+
* This module is loaded lazily to avoid pulling in ESM-only dependencies
5+
* (trpc-cli) when running other commands like the desktop app.
6+
*/
7+
8+
import { createCli } from "trpc-cli";
9+
import { router } from "@/node/orpc/router";
10+
import { proxifyOrpc } from "./proxifyOrpc";
11+
import type { Command } from "commander";
12+
13+
const baseUrl = process.env.MUX_SERVER_URL ?? "http://localhost:3000";
14+
const authToken = process.env.MUX_SERVER_AUTH_TOKEN;
15+
16+
const proxiedRouter = proxifyOrpc(router(), { baseUrl, authToken });
17+
const cli = createCli({ router: proxiedRouter }).buildProgram() as Command;
18+
19+
cli.name("mux api");
20+
cli.description("Interact with the mux API via a running server");
21+
cli.parse();

src/cli/index.ts

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,51 @@
11
#!/usr/bin/env node
22

3-
const subcommand = process.argv.length > 2 ? process.argv[2] : null;
3+
import { Command } from "commander";
4+
import { VERSION } from "../version";
45

5-
if (subcommand === "server") {
6-
// Remove 'server' from args since main-server doesn't expect it as a positional argument.
7-
process.argv.splice(2, 1);
8-
// eslint-disable-next-line @typescript-eslint/no-require-imports
9-
require("./server");
10-
} else if (subcommand === "version") {
11-
// eslint-disable-next-line @typescript-eslint/no-require-imports
12-
const { VERSION } = require("../version") as {
13-
VERSION: { git_describe: string; git_commit: string };
14-
};
15-
console.log(`mux ${VERSION.git_describe} (${VERSION.git_commit})`);
16-
} else {
6+
const program = new Command();
7+
8+
program
9+
.name("mux")
10+
.description("mux - coder multiplexer")
11+
.version(`mux ${VERSION.git_describe} (${VERSION.git_commit})`, "-v, --version");
12+
13+
// Subcommands with their own CLI parsers - disable help interception so --help passes through
14+
program
15+
.command("server")
16+
.description("Start the HTTP/WebSocket oRPC server")
17+
.helpOption(false)
18+
.allowUnknownOption()
19+
.allowExcessArguments()
20+
.action(() => {
21+
process.argv.splice(2, 1);
22+
// eslint-disable-next-line @typescript-eslint/no-require-imports
23+
require("./server");
24+
});
25+
26+
program
27+
.command("api")
28+
.description("Interact with the mux API via a running server")
29+
.helpOption(false)
30+
.allowUnknownOption()
31+
.allowExcessArguments()
32+
.action(() => {
33+
process.argv.splice(2, 1);
34+
// eslint-disable-next-line @typescript-eslint/no-require-imports
35+
require("./api");
36+
});
37+
38+
program
39+
.command("version")
40+
.description("Show version information")
41+
.action(() => {
42+
console.log(`mux ${VERSION.git_describe} (${VERSION.git_commit})`);
43+
});
44+
45+
// Default action: launch desktop app when no subcommand given
46+
program.action(() => {
1747
// eslint-disable-next-line @typescript-eslint/no-require-imports
1848
require("../desktop/main");
19-
}
49+
});
50+
51+
program.parse();

src/cli/proxifyOrpc.test.ts

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { z } from "zod";
3+
import * as zod4Core from "zod/v4/core";
4+
import { router } from "@/node/orpc/router";
5+
import { proxifyOrpc } from "./proxifyOrpc";
6+
7+
describe("proxifyOrpc schema enhancement", () => {
8+
describe("describeZodType", () => {
9+
// Helper to get description from a schema via JSON Schema conversion
10+
function getJsonSchemaDescription(schema: z.ZodTypeAny): string | undefined {
11+
const jsonSchema = zod4Core.toJSONSchema(schema, {
12+
io: "input",
13+
unrepresentable: "any",
14+
override: (ctx) => {
15+
// 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
16+
const meta = (ctx.zodSchema as any).meta?.();
17+
if (meta) Object.assign(ctx.jsonSchema, meta);
18+
},
19+
});
20+
return jsonSchema.description;
21+
}
22+
23+
test("described object schema has description in JSON Schema", () => {
24+
const schema = z.object({ foo: z.string() }).describe("Test description");
25+
const desc = getJsonSchemaDescription(schema);
26+
expect(desc).toBe("Test description");
27+
});
28+
29+
test("enum values are preserved in JSON Schema", () => {
30+
const schema = z.enum(["a", "b", "c"]);
31+
const jsonSchema = zod4Core.toJSONSchema(schema, {
32+
io: "input",
33+
unrepresentable: "any",
34+
});
35+
expect(jsonSchema.enum).toEqual(["a", "b", "c"]);
36+
});
37+
38+
test("optional fields are marked in JSON Schema", () => {
39+
const schema = z.object({
40+
required: z.string(),
41+
optional: z.string().optional(),
42+
});
43+
const jsonSchema = zod4Core.toJSONSchema(schema, {
44+
io: "input",
45+
unrepresentable: "any",
46+
override: (ctx) => {
47+
if (ctx.zodSchema?.constructor?.name === "ZodOptional") {
48+
ctx.jsonSchema.optional = true;
49+
}
50+
},
51+
});
52+
expect(jsonSchema.required).toContain("required");
53+
expect(jsonSchema.required).not.toContain("optional");
54+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
55+
expect((jsonSchema.properties as any)?.optional?.optional).toBe(true);
56+
});
57+
});
58+
59+
describe("void schema handling", () => {
60+
test("void schema converts to empty JSON Schema object", () => {
61+
// In the actual proxifyOrpc, void schemas are converted to z.object({})
62+
const emptyObj = z.object({});
63+
const jsonSchema = zod4Core.toJSONSchema(emptyObj, {
64+
io: "input",
65+
unrepresentable: "any",
66+
});
67+
expect(jsonSchema.type).toBe("object");
68+
expect(jsonSchema.properties).toEqual({});
69+
});
70+
});
71+
72+
describe("nested object descriptions", () => {
73+
test("described nested object preserves description", () => {
74+
const nested = z
75+
.object({
76+
field1: z.string(),
77+
field2: z.number(),
78+
})
79+
.describe("Required: field1: string, field2: number");
80+
81+
const parent = z.object({
82+
nested,
83+
});
84+
85+
const jsonSchema = zod4Core.toJSONSchema(parent, {
86+
io: "input",
87+
unrepresentable: "any",
88+
override: (ctx) => {
89+
// 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
90+
const meta = (ctx.zodSchema as any).meta?.();
91+
if (meta) Object.assign(ctx.jsonSchema, meta);
92+
},
93+
});
94+
95+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
96+
const nestedJsonSchema = (jsonSchema.properties as any)?.nested;
97+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
98+
expect(nestedJsonSchema?.description).toBe("Required: field1: string, field2: number");
99+
});
100+
});
101+
});
102+
103+
describe("proxifyOrpc CLI help output", () => {
104+
test("workspace resume-stream shows options description", () => {
105+
const r = router();
106+
const proxied = proxifyOrpc(r, { baseUrl: "http://localhost:8080" });
107+
108+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
109+
const resumeStream = (proxied as any).workspace?.resumeStream;
110+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
111+
const inputSchema = resumeStream?.["~orpc"]?.inputSchema;
112+
113+
// The options field should have a description
114+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
115+
const optionsField = inputSchema?.def?.shape?.options;
116+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
117+
expect(optionsField?.description).toBeDefined();
118+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
119+
expect(optionsField?.description).toContain("model: string");
120+
});
121+
122+
test("workspace list has empty object schema (no options)", () => {
123+
const r = router();
124+
const proxied = proxifyOrpc(r, { baseUrl: "http://localhost:8080" });
125+
126+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
127+
const listProc = (proxied as any).workspace?.list;
128+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
129+
const inputSchema = listProc?.["~orpc"]?.inputSchema;
130+
131+
// void input should be converted to empty object
132+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
133+
expect(inputSchema?.def?.type).toBe("object");
134+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument
135+
expect(Object.keys(inputSchema?.def?.shape ?? {})).toHaveLength(0);
136+
});
137+
138+
test("enhanced schema preserves _zod property for JSON Schema conversion", () => {
139+
const r = router();
140+
const proxied = proxifyOrpc(r, { baseUrl: "http://localhost:8080" });
141+
142+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
143+
const resumeStream = (proxied as any).workspace?.resumeStream;
144+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
145+
const inputSchema = resumeStream?.["~orpc"]?.inputSchema;
146+
147+
// Must have _zod for trpc-cli to detect Zod 4
148+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
149+
expect(inputSchema?._zod).toBeDefined();
150+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
151+
expect(inputSchema?._zod?.version?.major).toBe(4);
152+
153+
// _zod.def should have the enhanced shape
154+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
155+
const zodDefOptions = inputSchema?._zod?.def?.shape?.options;
156+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
157+
expect(zodDefOptions?.description).toBeDefined();
158+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
159+
expect(zodDefOptions?.description).toContain("model: string");
160+
});
161+
162+
test("JSON Schema for options includes description", () => {
163+
const r = router();
164+
const proxied = proxifyOrpc(r, { baseUrl: "http://localhost:8080" });
165+
166+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
167+
const resumeStream = (proxied as any).workspace?.resumeStream;
168+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
169+
const inputSchema = resumeStream?.["~orpc"]?.inputSchema;
170+
171+
// Convert to JSON Schema (what trpc-cli does)
172+
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
173+
const jsonSchema = zod4Core.toJSONSchema(inputSchema, {
174+
io: "input",
175+
unrepresentable: "any",
176+
override: (ctx) => {
177+
if (ctx.zodSchema?.constructor?.name === "ZodOptional") {
178+
ctx.jsonSchema.optional = true;
179+
}
180+
// 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
181+
const meta = (ctx.zodSchema as any).meta?.();
182+
if (meta) Object.assign(ctx.jsonSchema, meta);
183+
},
184+
});
185+
186+
// The options property should have a description
187+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
188+
const optionsJsonSchema = (jsonSchema.properties as any)?.options;
189+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
190+
expect(optionsJsonSchema?.description).toBeDefined();
191+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
192+
expect(optionsJsonSchema?.description).toContain("model: string");
193+
});
194+
});

0 commit comments

Comments
 (0)