From f451766d12f546c9fbbc27292fb0611129b3246e Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 28 Feb 2026 20:25:41 -0800 Subject: [PATCH] Add `cmux ` to open directories and Homebrew binary stanza (#705) * Add `cmux ` 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 --- .github/workflows/update-homebrew.yml | 1 + CLI/cmux.swift | 133 +++++++++++++++++++++++--- Sources/TerminalController.swift | 12 ++- scripts/build-sign-upload.sh | 1 + 4 files changed, 131 insertions(+), 16 deletions(-) diff --git a/.github/workflows/update-homebrew.yml b/.github/workflows/update-homebrew.yml index d92de590..967cb442 100644 --- a/.github/workflows/update-homebrew.yml +++ b/.github/workflows/update-homebrew.yml @@ -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", diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 09fd854a..e83a0121 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -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 ") + throw CLIError(message: "new-workspace: unknown flag '\(unknown)'. Known flags: --command , --cwd ") } - 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 ] + Usage: cmux new-workspace [--cwd ] [--command ] Create a new workspace in the current window. Flags: + --cwd Set the working directory for the new workspace --command 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] [options] + cmux Open a directory in a new workspace (launches cmux if needed) + cmux [global-options] [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 (--index | --before | --after ) [--window ] workspace-action --action [--workspace ] [--title ] list-workspaces - new-workspace [--command ] + new-workspace [--cwd ] [--command ] new-split [--workspace ] [--surface ] [--panel ] list-panes [--workspace ] list-pane-surfaces [--workspace ] [--pane ] diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 956b9a4a..9eefcd40 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -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 diff --git a/scripts/build-sign-upload.sh b/scripts/build-sign-upload.sh index 06f4e8d8..ac870d30 100755 --- a/scripts/build-sign-upload.sh +++ b/scripts/build-sign-upload.sh @@ -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",