Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions bun.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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=="],
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
21 changes: 21 additions & 0 deletions src/cli/api.ts
Original file line number Diff line number Diff line change
@@ -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();
60 changes: 46 additions & 14 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
@@ -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();
194 changes: 194 additions & 0 deletions src/cli/proxifyOrpc.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading