Add cmux <path> to open directories and Homebrew binary stanza (#705)

* Add `cmux <path>` to open directories and Homebrew binary stanza

CLI: `cmux .` or `cmux /path/to/dir` opens a new workspace at the
given directory. If the app isn't running, it launches first and waits
for the socket. Also adds `--cwd` flag to `new-workspace`.

Server: `workspace.create` now accepts an optional `cwd` parameter,
passed through to `TabManager.addWorkspace(workingDirectory:)`.

Homebrew: adds `binary` stanza to the cask so `cmux` CLI is globally
available after `brew install --cask cmux`. Updated both the cask file,
the CI workflow template, and the manual release script so automated
version bumps preserve the stanza.

* Address review: validate cwd type, fix socket detection, propagate errors

- looksLikePath now also matches paths containing `/` (e.g. `foo/bar`)
- openPath uses socket connection attempt instead of fileExists to detect
  whether the app is running (Unix sockets may not appear on filesystem)
- launchApp/activateApp now throw instead of swallowing errors with try?
- Server validates that cwd param is a string, returns invalid_params error
  if wrong type is passed
This commit is contained in:
Lawrence Chen 2026-02-28 20:25:41 -08:00 committed by GitHub
parent 838d1b07b1
commit f451766d12
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 131 additions and 16 deletions

View file

@ -94,6 +94,7 @@ jobs:
depends_on macos: ">= :sonoma"
app "cmux.app"
binary "#{appdir}/cmux.app/Contents/Resources/bin/cmux"
zap trash: [
"~/Library/Application Support/cmux",

View file

@ -690,6 +690,12 @@ struct CMUXCLI {
return
}
// If the argument looks like a path (not a known command), open a workspace there.
if looksLikePath(command) {
try openPath(command, socketPath: socketPath)
return
}
// Check for --help/-h on subcommands before connecting to the socket,
// so help text is available even when cmux is not running.
if commandArgs.contains("--help") || commandArgs.contains("-h") {
@ -875,22 +881,25 @@ struct CMUXCLI {
}
case "new-workspace":
let (commandOpt, remaining) = parseOption(commandArgs, name: "--command")
let (commandOpt, rem0) = parseOption(commandArgs, name: "--command")
let (cwdOpt, remaining) = parseOption(rem0, name: "--cwd")
if let unknown = remaining.first(where: { $0.hasPrefix("--") }) {
throw CLIError(message: "new-workspace: unknown flag '\(unknown)'. Known flags: --command <text>")
throw CLIError(message: "new-workspace: unknown flag '\(unknown)'. Known flags: --command <text>, --cwd <path>")
}
let response = try sendV1Command("new_workspace", client: client)
print(response)
if let commandText = commandOpt {
guard response.hasPrefix("OK ") else {
throw CLIError(message: "new-workspace failed, cannot run --command")
}
let wsId = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines)
var params: [String: Any] = [:]
if let cwdOpt {
let resolved = resolvePath(cwdOpt)
params["cwd"] = resolved
}
let response = try client.sendV2(method: "workspace.create", params: params)
let wsId = (response["workspace_ref"] as? String) ?? (response["workspace_id"] as? String) ?? ""
print("OK \(wsId)")
if let commandText = commandOpt, !wsId.isEmpty {
// Wait for shell to initialize
Thread.sleep(forTimeInterval: 0.5)
let text = unescapeSendText(commandText + "\\n")
let params: [String: Any] = ["text": text, "workspace_id": wsId]
_ = try client.sendV2(method: "surface.send_text", params: params)
let sendParams: [String: Any] = ["text": text, "workspace_id": wsId]
_ = try client.sendV2(method: "surface.send_text", params: sendParams)
}
case "new-split":
@ -1499,6 +1508,97 @@ struct CMUXCLI {
}
}
private func resolvePath(_ path: String) -> String {
let expanded = NSString(string: path).expandingTildeInPath
if expanded.hasPrefix("/") { return expanded }
let cwd = FileManager.default.currentDirectoryPath
return (cwd as NSString).appendingPathComponent(expanded)
}
/// Returns true if the argument looks like a filesystem path rather than a CLI command.
private func looksLikePath(_ arg: String) -> Bool {
if arg == "." || arg == ".." { return true }
if arg.hasPrefix("/") || arg.hasPrefix("./") || arg.hasPrefix("../") || arg.hasPrefix("~") { return true }
if arg.contains("/") { return true }
return false
}
/// Open a path in cmux by creating a new workspace with the given directory.
/// Launches the app if it isn't already running.
private func openPath(_ path: String, socketPath: String) throws {
let resolved = resolvePath(path)
var isDir: ObjCBool = false
let exists = FileManager.default.fileExists(atPath: resolved, isDirectory: &isDir)
let directory: String
if exists && isDir.boolValue {
directory = resolved
} else if exists {
// It's a file; use its parent directory
directory = (resolved as NSString).deletingLastPathComponent
} else {
throw CLIError(message: "Path does not exist: \(resolved)")
}
// Try connecting to the socket. If it fails, launch the app and retry.
let client = SocketClient(path: socketPath)
if (try? client.connect()) == nil {
client.close()
try launchApp()
// Poll until socket accepts connections (up to 10 seconds)
let pollClient = SocketClient(path: socketPath)
var connected = false
for _ in 0..<100 {
if (try? pollClient.connect()) != nil {
connected = true
break
}
pollClient.close()
Thread.sleep(forTimeInterval: 0.1)
}
guard connected else {
throw CLIError(message: "cmux app did not start in time (socket not found at \(socketPath))")
}
// Use pollClient since it's connected
defer { pollClient.close() }
let params: [String: Any] = ["cwd": directory]
let response = try pollClient.sendV2(method: "workspace.create", params: params)
let wsRef = (response["workspace_ref"] as? String) ?? (response["workspace_id"] as? String) ?? ""
if !wsRef.isEmpty {
print("OK \(wsRef)")
}
try activateApp()
return
}
defer { client.close() }
let params: [String: Any] = ["cwd": directory]
let response = try client.sendV2(method: "workspace.create", params: params)
let wsRef = (response["workspace_ref"] as? String) ?? (response["workspace_id"] as? String) ?? ""
if !wsRef.isEmpty {
print("OK \(wsRef)")
}
// Bring the app to front
try activateApp()
}
private func launchApp() throws {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/open")
process.arguments = ["-a", "cmux"]
try process.run()
process.waitUntilExit()
}
private func activateApp() throws {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/open")
process.arguments = ["-a", "cmux"]
try process.run()
process.waitUntilExit()
}
private func sendV1Command(_ command: String, client: SocketClient) throws -> String {
let response = try client.send(command: command)
if response.hasPrefix("ERROR:") {
@ -3626,16 +3726,18 @@ struct CMUXCLI {
"""
case "new-workspace":
return """
Usage: cmux new-workspace [--command <text>]
Usage: cmux new-workspace [--cwd <path>] [--command <text>]
Create a new workspace in the current window.
Flags:
--cwd <path> Set the working directory for the new workspace
--command <text> Send text+Enter to the new workspace after creation
Example:
cmux new-workspace
cmux new-workspace --command "npm test"
cmux new-workspace --cwd ~/projects/myapp
cmux new-workspace --cwd . --command "npm test"
"""
case "list-workspaces":
return """
@ -6221,7 +6323,8 @@ struct CMUXCLI {
cmux - control cmux via Unix socket
Usage:
cmux [--socket PATH] [--window WINDOW] [--password PASSWORD] [--json] [--id-format refs|uuids|both] [--version] <command> [options]
cmux <path> Open a directory in a new workspace (launches cmux if needed)
cmux [global-options] <command> [options]
Handle Inputs:
For most v2-backed commands you can use UUIDs, short refs (window:1/workspace:2/pane:3/surface:4), or indexes.
@ -6245,7 +6348,7 @@ struct CMUXCLI {
reorder-workspace --workspace <id|ref|index> (--index <n> | --before <id|ref|index> | --after <id|ref|index>) [--window <id|ref|index>]
workspace-action --action <name> [--workspace <id|ref|index>] [--title <text>]
list-workspaces
new-workspace [--command <text>]
new-workspace [--cwd <path>] [--command <text>]
new-split <left|right|up|down> [--workspace <id|ref>] [--surface <id|ref>] [--panel <id|ref>]
list-panes [--workspace <id|ref>]
list-pane-surfaces [--workspace <id|ref>] [--pane <id|ref>]

View file

@ -2368,13 +2368,23 @@ class TerminalController {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
let cwd: String?
if let raw = params["cwd"] {
guard let str = raw as? String else {
return .err(code: "invalid_params", message: "cwd must be a string", data: nil)
}
cwd = str
} else {
cwd = nil
}
var newId: UUID?
let shouldFocus = v2FocusAllowed()
#if DEBUG
let startedAt = ProcessInfo.processInfo.systemUptime
#endif
v2MainSync {
let ws = tabManager.addWorkspace(select: shouldFocus)
let ws = tabManager.addWorkspace(workingDirectory: cwd, select: shouldFocus)
newId = ws.id
}
#if DEBUG

View file

@ -177,6 +177,7 @@ cask "cmux" do
depends_on macos: ">= :ventura"
app "cmux.app"
binary "#{appdir}/cmux.app/Contents/Resources/bin/cmux"
zap trash: [
"~/Library/Application Support/cmux",