Skip to content

Commit d3cf9b2

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 44d5183 commit d3cf9b2

File tree

6 files changed

+260
-44
lines changed

6 files changed

+260
-44
lines changed

docs/cli.md

Lines changed: 5 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,11 @@ 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 --version`
9185

9286
Print the version and git commit:
9387

9488
```bash
95-
mux version
96-
# mux v0.8.4 (abc123)
89+
mux --version
90+
# v0.8.4 (abc123)
9791
```

src/cli/index.ts

Lines changed: 57 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,60 @@
11
#!/usr/bin/env node
2+
/**
3+
* Mux CLI entry point - simple router to subcommands or desktop app.
4+
*
5+
* Each subcommand handles its own argument parsing. This file just routes
6+
* based on argv[2] to avoid importing heavy modules (Electron, AI SDK) eagerly.
7+
*/
8+
import { VERSION } from "../version";
29

3-
const subcommand = process.argv.length > 2 ? process.argv[2] : null;
4-
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 === "run") {
11-
// Remove 'run' from args since run.ts uses Commander which handles its own parsing
12-
process.argv.splice(2, 1);
13-
// eslint-disable-next-line @typescript-eslint/no-require-imports
14-
require("./run");
15-
} else if (subcommand === "version") {
16-
// eslint-disable-next-line @typescript-eslint/no-require-imports
17-
const { VERSION } = require("../version") as {
18-
VERSION: { git_describe: string; git_commit: string };
19-
};
20-
console.log(`mux ${VERSION.git_describe} (${VERSION.git_commit})`);
21-
} else {
22-
// eslint-disable-next-line @typescript-eslint/no-require-imports
23-
require("../desktop/main");
10+
const HELP = `Usage: mux [command] [options]
11+
12+
Mux - AI agent orchestration
13+
14+
Commands:
15+
run Run a one-off agent task
16+
server Start the HTTP/WebSocket ORPC server
17+
18+
Options:
19+
-v, --version Show version
20+
-h, --help Show this help
21+
22+
Run 'mux <command> --help' for command-specific options.
23+
`;
24+
25+
const arg = process.argv[2];
26+
27+
switch (arg) {
28+
case "run":
29+
process.argv.splice(2, 1);
30+
// eslint-disable-next-line @typescript-eslint/no-require-imports
31+
require("./run");
32+
break;
33+
34+
case "server":
35+
process.argv.splice(2, 1);
36+
// eslint-disable-next-line @typescript-eslint/no-require-imports
37+
require("./server");
38+
break;
39+
40+
case "-v":
41+
case "--version":
42+
console.log(`mux ${VERSION.git_describe} (${VERSION.git_commit})`);
43+
break;
44+
45+
case "-h":
46+
case "--help":
47+
console.log(HELP);
48+
break;
49+
50+
case undefined:
51+
// No arguments - launch desktop app
52+
// eslint-disable-next-line @typescript-eslint/no-require-imports
53+
require("../desktop/main");
54+
break;
55+
56+
default:
57+
console.error(`error: unknown command '${arg}'`);
58+
console.error(`Run 'mux --help' for usage.`);
59+
process.exit(1);
2460
}

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")

src/node/services/mock/scenarios/slashCommands.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,26 +65,26 @@ const modelStatusTurn: ScenarioTurn = {
6565
kind: "stream-start",
6666
delay: 0,
6767
messageId: "msg-slash-model-status",
68-
model: "anthropic:claude-opus-4-5",
68+
model: "anthropic:claude-sonnet-4-5",
6969
},
7070
{
7171
kind: "stream-delta",
7272
delay: STREAM_BASE_DELAY,
73-
text: "Claude Opus 4.5 is now responding with enhanced reasoning capacity.",
73+
text: "Claude Sonnet 4.5 is now responding with standard reasoning capacity.",
7474
},
7575
{
7676
kind: "stream-end",
7777
delay: STREAM_BASE_DELAY * 2,
7878
metadata: {
79-
model: "anthropic:claude-opus-4-5",
79+
model: "anthropic:claude-sonnet-4-5",
8080
inputTokens: 70,
8181
outputTokens: 54,
8282
systemMessageTokens: 12,
8383
},
8484
parts: [
8585
{
8686
type: "text",
87-
text: "I'm responding as Claude Opus 4.5, which you selected via /model opus. Let me know how to proceed.",
87+
text: "I'm responding as Claude Sonnet 4.5, which you selected via /model sonnet. Let me know how to proceed.",
8888
},
8989
],
9090
},

tests/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, "../../src/cli/index.ts");
12+
const RUN_PATH = path.resolve(__dirname, "../../src/cli/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+
});

tests/e2e/scenarios/slashCommands.spec.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -99,26 +99,27 @@ test.describe("slash command flows", () => {
9999
await expect(transcript).not.toContainText("Directory listing:");
100100
});
101101

102-
test("slash command /model opus switches models for subsequent turns", async ({ ui, page }) => {
102+
test("slash command /model sonnet switches models for subsequent turns", async ({ ui, page }) => {
103103
await ui.projects.openFirstWorkspace();
104104

105105
const modeToggles = page.locator('[data-component="ChatModeToggles"]');
106+
// Default model is now Opus
107+
await expect(modeToggles.getByText("anthropic:claude-opus-4-5", { exact: true })).toBeVisible();
108+
109+
await ui.chat.sendMessage("/model sonnet");
110+
await ui.chat.expectStatusMessageContains("Model changed to anthropic:claude-sonnet-4-5");
106111
await expect(
107112
modeToggles.getByText("anthropic:claude-sonnet-4-5", { exact: true })
108113
).toBeVisible();
109114

110-
await ui.chat.sendMessage("/model opus");
111-
await ui.chat.expectStatusMessageContains("Model changed to anthropic:claude-opus-4-5");
112-
await expect(modeToggles.getByText("anthropic:claude-opus-4-5", { exact: true })).toBeVisible();
113-
114115
const timeline = await ui.chat.captureStreamTimeline(async () => {
115116
await ui.chat.sendMessage(SLASH_COMMAND_PROMPTS.MODEL_STATUS);
116117
});
117118

118119
const streamStart = timeline.events.find((event) => event.type === "stream-start");
119-
expect(streamStart?.model).toBe("anthropic:claude-opus-4-5");
120+
expect(streamStart?.model).toBe("anthropic:claude-sonnet-4-5");
120121
await ui.chat.expectTranscriptContains(
121-
"Claude Opus 4.5 is now responding with enhanced reasoning capacity."
122+
"Claude Sonnet 4.5 is now responding with standard reasoning capacity."
122123
);
123124
});
124125

0 commit comments

Comments
 (0)