Skip to content

Commit 888f233

Browse files
committed
refactor: use Commander.js for top-level CLI routing
- Replace manual argv parsing with proper Commander.js subcommands - Add --version flag with proper version info - Subcommands (run, server) now properly routed via executableFile - Default action launches desktop app when no subcommand given - Update docs to reflect --version flag instead of subcommand
1 parent b803ecc commit 888f233

File tree

6 files changed

+260
-94
lines changed

6 files changed

+260
-94
lines changed

docs/cli.md

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
# Command Line Interface
22

3-
Mux provides a CLI for running agent sessions without opening the desktop app.
3+
Mux provides a CLI for running one-off agent tasks without the desktop app. Unlike the interactive desktop experience, `mux run` executes a single request to completion and exits.
44

55
## `mux run`
66

7-
Run an agent session in any directory:
7+
Execute a one-off agent task:
88

99
```bash
1010
# Basic usage - run in current directory
@@ -16,9 +16,6 @@ mux run --dir /path/to/project "Add authentication"
1616
# Use SSH runtime
1717
mux run --runtime "ssh user@myserver" "Deploy changes"
1818

19-
# Plan mode (proposes a plan, then auto-executes)
20-
mux run --mode plan "Refactor the auth module"
21-
2219
# Pipe instructions via stdin
2320
echo "Add logging to all API endpoints" | mux run
2421

@@ -67,9 +64,6 @@ mux run -r "ssh dev@staging.example.com" -d /app "Update dependencies"
6764

6865
# Scripted usage with timeout
6966
mux run --json --timeout 5m "Generate API documentation" > output.jsonl
70-
71-
# Plan first, then execute
72-
mux run --mode plan "Migrate from REST to GraphQL"
7367
```
7468

7569
## `mux server`
@@ -87,11 +81,21 @@ Options:
8781
- `--auth-token <token>` - Optional bearer token for authentication
8882
- `--add-project <path>` - Add and open project at the specified path
8983

90-
## `mux version`
84+
## `mux desktop`
85+
86+
Launch the desktop app. This is automatically invoked when running the packaged app or via `electron .`:
87+
88+
```bash
89+
mux desktop
90+
```
91+
92+
Note: Requires Electron. When running `mux` with no arguments under Electron, the desktop app launches automatically.
93+
94+
## `mux --version`
9195

9296
Print the version and git commit:
9397

9498
```bash
95-
mux version
96-
# mux v0.8.4 (abc123)
99+
mux --version
100+
# v0.8.4 (abc123)
97101
```

src/cli/index.ts

Lines changed: 47 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,84 +1,60 @@
11
#!/usr/bin/env node
2-
2+
/**
3+
* Mux CLI entry point.
4+
*
5+
* LAZY LOADING REQUIREMENT:
6+
* We manually route subcommands before calling program.parse() to avoid
7+
* eagerly importing heavy modules. The desktop app imports Electron, which
8+
* fails when running CLI commands in non-GUI environments. Subcommands like
9+
* `run` and `server` import the AI SDK which has significant startup cost.
10+
*
11+
* By checking argv[2] first, we only load the code path actually needed.
12+
*
13+
* ELECTRON DETECTION:
14+
* When run via `electron .` or as a packaged app, Electron sets process.versions.electron.
15+
* In that case, we launch the desktop app automatically. When run via `bun` or `node`,
16+
* we show CLI help instead.
17+
*/
318
import { Command } from "commander";
419
import { VERSION } from "../version";
520

6-
<<<<<<< HEAD
7-
const program = new Command();
8-
9-
program
10-
.name("mux")
11-
.description("mux - coder multiplexer")
12-
.version(`mux ${VERSION.git_describe} (${VERSION.git_commit})`, "-v, --version");
13-
14-
// Subcommands with their own CLI parsers - disable help interception so --help passes through
15-
program
16-
.command("server")
17-
.description("Start the HTTP/WebSocket oRPC server")
18-
.helpOption(false)
19-
.allowUnknownOption()
20-
.allowExcessArguments()
21-
.action(() => {
22-
process.argv.splice(2, 1);
23-
// eslint-disable-next-line @typescript-eslint/no-require-imports
24-
require("./server");
25-
});
26-
27-
program
28-
.command("api")
29-
.description("Interact with the mux API via a running server")
30-
.helpOption(false)
31-
.allowUnknownOption()
32-
.allowExcessArguments()
33-
.action(() => {
34-
process.argv.splice(2, 1);
35-
// eslint-disable-next-line @typescript-eslint/no-require-imports
36-
require("./api");
37-
});
21+
const subcommand = process.argv[2];
22+
const isElectron = "electron" in process.versions;
3823

39-
program
40-
.command("version")
41-
.description("Show version information")
42-
.action(() => {
43-
console.log(`mux ${VERSION.git_describe} (${VERSION.git_commit})`);
44-
});
45-
46-
// Default action: launch desktop app when no subcommand given
47-
program.action(() => {
48-
||||||| parent of 0f258d5fc (🤖 feat: add first-class `mux run` CLI command)
49-
if (subcommand === "server") {
50-
// Remove 'server' from args since main-server doesn't expect it as a positional argument.
51-
process.argv.splice(2, 1);
24+
function launchDesktop(): void {
5225
// eslint-disable-next-line @typescript-eslint/no-require-imports
53-
require("./server");
54-
} else if (subcommand === "version") {
26+
require("../desktop/main");
27+
}
28+
29+
// Route known subcommands to their dedicated entry points (each has its own Commander instance)
30+
if (subcommand === "run") {
31+
process.argv.splice(2, 1); // Remove "run" since run.ts defines .name("mux run")
5532
// eslint-disable-next-line @typescript-eslint/no-require-imports
56-
const { VERSION } = require("../version") as {
57-
VERSION: { git_describe: string; git_commit: string };
58-
};
59-
console.log(`mux ${VERSION.git_describe} (${VERSION.git_commit})`);
60-
} else {
61-
=======
62-
if (subcommand === "server") {
63-
// Remove 'server' from args since main-server doesn't expect it as a positional argument.
33+
require("./run");
34+
} else if (subcommand === "server") {
6435
process.argv.splice(2, 1);
6536
// eslint-disable-next-line @typescript-eslint/no-require-imports
6637
require("./server");
67-
} else if (subcommand === "run") {
68-
// Remove 'run' from args since run.ts uses Commander which handles its own parsing
38+
} else if (subcommand === "api") {
6939
process.argv.splice(2, 1);
7040
// eslint-disable-next-line @typescript-eslint/no-require-imports
71-
require("./run");
72-
} else if (subcommand === "version") {
73-
// eslint-disable-next-line @typescript-eslint/no-require-imports
74-
const { VERSION } = require("../version") as {
75-
VERSION: { git_describe: string; git_commit: string };
76-
};
77-
console.log(`mux ${VERSION.git_describe} (${VERSION.git_commit})`);
41+
require("./api");
42+
} else if (subcommand === "desktop" || (subcommand === undefined && isElectron)) {
43+
// Explicit `mux desktop` or no args when running under Electron
44+
launchDesktop();
7845
} else {
79-
>>>>>>> 0f258d5fc (🤖 feat: add first-class `mux run` CLI command)
80-
// eslint-disable-next-line @typescript-eslint/no-require-imports
81-
require("../desktop/main");
82-
});
83-
84-
program.parse();
46+
// No subcommand (non-Electron), flags (--help, --version), or unknown commands
47+
const program = new Command();
48+
program
49+
.name("mux")
50+
.description("Mux - AI agent orchestration")
51+
.version(`${VERSION.git_describe} (${VERSION.git_commit})`, "-v, --version");
52+
53+
// Register subcommand stubs for help display (actual implementations are above)
54+
program.command("run").description("Run a one-off agent task");
55+
program.command("server").description("Start the HTTP/WebSocket ORPC server");
56+
program.command("api").description("Interact with the mux API via a running server");
57+
program.command("desktop").description("Launch the desktop app (requires Electron)");
58+
59+
program.parse();
60+
}

src/cli/run.test.ts

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
/**
2+
* Integration tests for `mux run` CLI command.
3+
*
4+
* These tests verify the CLI interface without actually running agent sessions.
5+
* They test argument parsing, help output, and error handling.
6+
*/
7+
import { describe, test, expect, beforeAll } from "bun:test";
8+
import { spawn } from "child_process";
9+
import * as path from "path";
10+
11+
const CLI_PATH = path.resolve(__dirname, "index.ts");
12+
const RUN_PATH = path.resolve(__dirname, "run.ts");
13+
14+
interface ExecResult {
15+
stdout: string;
16+
stderr: string;
17+
output: string; // combined stdout + stderr
18+
exitCode: number;
19+
}
20+
21+
async function runCli(args: string[], timeoutMs = 5000): Promise<ExecResult> {
22+
return new Promise((resolve) => {
23+
const proc = spawn("bun", [CLI_PATH, ...args], {
24+
timeout: timeoutMs,
25+
env: { ...process.env, NO_COLOR: "1" },
26+
});
27+
28+
let stdout = "";
29+
let stderr = "";
30+
31+
proc.stdout?.on("data", (data) => {
32+
stdout += data.toString();
33+
});
34+
35+
proc.stderr?.on("data", (data) => {
36+
stderr += data.toString();
37+
});
38+
39+
proc.on("close", (code) => {
40+
resolve({ stdout, stderr, output: stdout + stderr, exitCode: code ?? 1 });
41+
});
42+
43+
proc.on("error", () => {
44+
resolve({ stdout, stderr, output: stdout + stderr, exitCode: 1 });
45+
});
46+
});
47+
}
48+
49+
/**
50+
* Run run.ts directly with stdin closed to avoid hanging.
51+
* Passes empty stdin to simulate non-TTY invocation without input.
52+
*/
53+
async function runRunDirect(args: string[], timeoutMs = 5000): Promise<ExecResult> {
54+
return new Promise((resolve) => {
55+
const proc = spawn("bun", [RUN_PATH, ...args], {
56+
timeout: timeoutMs,
57+
env: { ...process.env, NO_COLOR: "1" },
58+
stdio: ["pipe", "pipe", "pipe"], // stdin, stdout, stderr
59+
});
60+
61+
let stdout = "";
62+
let stderr = "";
63+
64+
proc.stdout?.on("data", (data) => {
65+
stdout += data.toString();
66+
});
67+
68+
proc.stderr?.on("data", (data) => {
69+
stderr += data.toString();
70+
});
71+
72+
// Close stdin immediately to prevent hanging on stdin.read()
73+
proc.stdin?.end();
74+
75+
proc.on("close", (code) => {
76+
resolve({ stdout, stderr, output: stdout + stderr, exitCode: code ?? 1 });
77+
});
78+
79+
proc.on("error", () => {
80+
resolve({ stdout, stderr, output: stdout + stderr, exitCode: 1 });
81+
});
82+
});
83+
}
84+
85+
describe("mux CLI", () => {
86+
beforeAll(() => {
87+
// Verify CLI files exist
88+
expect(Bun.file(CLI_PATH).size).toBeGreaterThan(0);
89+
expect(Bun.file(RUN_PATH).size).toBeGreaterThan(0);
90+
});
91+
92+
describe("top-level", () => {
93+
test("--help shows usage", async () => {
94+
const result = await runCli(["--help"]);
95+
expect(result.exitCode).toBe(0);
96+
expect(result.stdout).toContain("Usage: mux");
97+
expect(result.stdout).toContain("Mux - AI agent orchestration");
98+
expect(result.stdout).toContain("run");
99+
expect(result.stdout).toContain("server");
100+
});
101+
102+
test("--version shows version info", async () => {
103+
const result = await runCli(["--version"]);
104+
expect(result.exitCode).toBe(0);
105+
// Version format: vX.Y.Z-N-gHASH (HASH)
106+
expect(result.stdout).toMatch(/v\d+\.\d+\.\d+/);
107+
});
108+
109+
test("unknown command shows error", async () => {
110+
const result = await runCli(["nonexistent"]);
111+
expect(result.exitCode).toBe(1);
112+
expect(result.stderr).toContain("unknown command");
113+
});
114+
});
115+
116+
describe("mux run", () => {
117+
test("--help shows all options", async () => {
118+
const result = await runCli(["run", "--help"]);
119+
expect(result.exitCode).toBe(0);
120+
expect(result.stdout).toContain("Usage: mux run");
121+
expect(result.stdout).toContain("--dir");
122+
expect(result.stdout).toContain("--model");
123+
expect(result.stdout).toContain("--runtime");
124+
expect(result.stdout).toContain("--mode");
125+
expect(result.stdout).toContain("--thinking");
126+
expect(result.stdout).toContain("--timeout");
127+
expect(result.stdout).toContain("--json");
128+
expect(result.stdout).toContain("--quiet");
129+
expect(result.stdout).toContain("--workspace-id");
130+
expect(result.stdout).toContain("--config-root");
131+
});
132+
133+
test("shows default model as opus", async () => {
134+
const result = await runCli(["run", "--help"]);
135+
expect(result.exitCode).toBe(0);
136+
expect(result.stdout).toContain("anthropic:claude-opus-4-5");
137+
});
138+
139+
test("no message shows error", async () => {
140+
const result = await runRunDirect([]);
141+
expect(result.exitCode).toBe(1);
142+
expect(result.output).toContain("No message provided");
143+
});
144+
145+
test("invalid thinking level shows error", async () => {
146+
const result = await runRunDirect(["--thinking", "extreme", "test message"]);
147+
expect(result.exitCode).toBe(1);
148+
expect(result.output).toContain("Invalid thinking level");
149+
});
150+
151+
test("invalid mode shows error", async () => {
152+
const result = await runRunDirect(["--mode", "chaos", "test message"]);
153+
expect(result.exitCode).toBe(1);
154+
expect(result.output).toContain("Invalid mode");
155+
});
156+
157+
test("invalid timeout shows error", async () => {
158+
const result = await runRunDirect(["--timeout", "abc", "test message"]);
159+
expect(result.exitCode).toBe(1);
160+
expect(result.output).toContain("Invalid timeout");
161+
});
162+
163+
test("nonexistent directory shows error", async () => {
164+
const result = await runRunDirect([
165+
"--dir",
166+
"/nonexistent/path/that/does/not/exist",
167+
"test message",
168+
]);
169+
expect(result.exitCode).toBe(1);
170+
expect(result.output.length).toBeGreaterThan(0);
171+
});
172+
});
173+
174+
describe("mux server", () => {
175+
test("--help shows all options", async () => {
176+
const result = await runCli(["server", "--help"]);
177+
expect(result.exitCode).toBe(0);
178+
expect(result.stdout).toContain("Usage: mux server");
179+
expect(result.stdout).toContain("--host");
180+
expect(result.stdout).toContain("--port");
181+
expect(result.stdout).toContain("--auth-token");
182+
expect(result.stdout).toContain("--add-project");
183+
});
184+
});
185+
});

src/cli/server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import type { ORPCContext } from "@/node/orpc/context";
1313

1414
const program = new Command();
1515
program
16-
.name("mux-server")
16+
.name("mux server")
1717
.description("HTTP/WebSocket ORPC server for mux")
1818
.option("-h, --host <host>", "bind to specific host", "localhost")
1919
.option("-p, --port <port>", "bind to specific port", "3000")

0 commit comments

Comments
 (0)