cmux/CLI/cmux.swift
2026-03-12 05:04:44 -07:00

9368 lines
391 KiB
Swift

import Foundation
import CryptoKit
import Darwin
#if canImport(LocalAuthentication)
import LocalAuthentication
#endif
#if canImport(Security)
import Security
#endif
#if canImport(Sentry)
import Sentry
#endif
struct CLIError: Error, CustomStringConvertible {
let message: String
var description: String { message }
}
private final class CLISocketSentryTelemetry {
private let command: String
private let subcommand: String
private let socketPath: String
private let envSocketPath: String?
private let workspaceId: String?
private let surfaceId: String?
private let disabledByEnv: Bool
#if canImport(Sentry)
private static let startupLock = NSLock()
private static var started = false
private static let dsn = "https://ecba1ec90ecaee02a102fba931b6d2b3@o4507547940749312.ingest.us.sentry.io/4510796264636416"
#endif
init(command: String, commandArgs: [String], socketPath: String, processEnv: [String: String]) {
self.command = command.lowercased()
self.subcommand = commandArgs.first?.lowercased() ?? "help"
self.socketPath = socketPath
self.envSocketPath = processEnv["CMUX_SOCKET_PATH"] ?? processEnv["CMUX_SOCKET"]
self.workspaceId = processEnv["CMUX_WORKSPACE_ID"]
self.surfaceId = processEnv["CMUX_SURFACE_ID"]
self.disabledByEnv =
processEnv["CMUX_CLI_SENTRY_DISABLED"] == "1" ||
processEnv["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] == "1"
}
func breadcrumb(_ message: String, data: [String: Any] = [:]) {
guard shouldEmit else { return }
#if canImport(Sentry)
Self.ensureStarted()
var payload = baseContext()
for (key, value) in data {
payload[key] = value
}
let crumb = Breadcrumb(level: .info, category: "cmux.cli")
crumb.message = message
crumb.data = payload
SentrySDK.addBreadcrumb(crumb)
#endif
}
func captureError(stage: String, error: Error) {
guard shouldEmit else { return }
#if canImport(Sentry)
Self.ensureStarted()
var context = baseContext()
context["stage"] = stage
context["error"] = String(describing: error)
for (key, value) in socketDiagnostics() {
context[key] = value
}
let subcommand = self.subcommand
let command = self.command
_ = SentrySDK.capture(error: error) { scope in
scope.setLevel(.error)
scope.setTag(value: "cmux-cli", key: "component")
scope.setTag(value: command, key: "cli_command")
scope.setTag(value: subcommand, key: "cli_subcommand")
scope.setContext(value: context, key: "cli_socket")
}
SentrySDK.flush(timeout: 2.0)
#endif
}
private var shouldEmit: Bool {
!disabledByEnv
}
private func baseContext() -> [String: Any] {
var context: [String: Any] = [
"command": command,
"subcommand": subcommand,
"requested_socket_path": socketPath,
"env_socket_path": envSocketPath ?? "<unset>"
]
if let workspaceId {
context["workspace_id"] = workspaceId
}
if let surfaceId {
context["surface_id"] = surfaceId
}
return context
}
private func socketDiagnostics() -> [String: Any] {
var context: [String: Any] = [
"cwd": FileManager.default.currentDirectoryPath,
"uid": Int(getuid()),
"euid": Int(geteuid())
]
var st = stat()
if lstat(socketPath, &st) == 0 {
context["socket_exists"] = true
context["socket_mode"] = String(format: "%o", Int(st.st_mode & 0o7777))
context["socket_owner_uid"] = Int(st.st_uid)
context["socket_owner_gid"] = Int(st.st_gid)
context["socket_file_type"] = Self.fileTypeDescription(mode: st.st_mode)
} else {
let code = errno
context["socket_exists"] = false
context["socket_errno"] = Int(code)
context["socket_errno_description"] = String(cString: strerror(code))
}
let tmpSockets = Self.discoverTmpCmuxSockets(limit: 10)
if !tmpSockets.isEmpty {
context["tmp_cmux_sockets"] = tmpSockets
}
let taggedSockets = tmpSockets.filter { $0 != "/tmp/cmux.sock" }
if socketPath == "/tmp/cmux.sock",
(envSocketPath?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true),
!taggedSockets.isEmpty {
context["possible_root_cause"] = "CMUX_SOCKET_PATH/CMUX_SOCKET missing while tagged sockets exist"
}
return context
}
private static func fileTypeDescription(mode: mode_t) -> String {
switch mode & mode_t(S_IFMT) {
case mode_t(S_IFSOCK):
return "socket"
case mode_t(S_IFREG):
return "regular"
case mode_t(S_IFDIR):
return "directory"
case mode_t(S_IFLNK):
return "symlink"
default:
return "other"
}
}
private static func discoverTmpCmuxSockets(limit: Int) -> [String] {
guard let entries = try? FileManager.default.contentsOfDirectory(atPath: "/tmp") else {
return []
}
var sockets: [String] = []
for name in entries.sorted() {
guard name.hasPrefix("cmux"), name.hasSuffix(".sock") else { continue }
let fullPath = "/tmp/\(name)"
var st = stat()
guard lstat(fullPath, &st) == 0 else { continue }
guard (st.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK) else { continue }
sockets.append(fullPath)
if sockets.count >= limit {
break
}
}
return sockets
}
#if canImport(Sentry)
private static func ensureStarted() {
startupLock.lock()
defer { startupLock.unlock() }
guard !started else { return }
SentrySDK.start { options in
options.dsn = dsn
#if DEBUG
options.environment = "development-cli"
#else
options.environment = "production-cli"
#endif
options.debug = false
options.sendDefaultPii = true
options.attachStacktrace = true
options.tracesSampleRate = 0.0
}
started = true
}
#endif
}
struct WindowInfo {
let index: Int
let id: String
let key: Bool
let selectedWorkspaceId: String?
let workspaceCount: Int
}
struct NotificationInfo {
let id: String
let workspaceId: String
let surfaceId: String?
let isRead: Bool
let title: String
let subtitle: String
let body: String
}
private struct ClaudeHookParsedInput {
let rawInput: String
let object: [String: Any]?
let sessionId: String?
let cwd: String?
let transcriptPath: String?
}
private struct ClaudeHookSessionRecord: Codable {
var sessionId: String
var workspaceId: String
var surfaceId: String
var cwd: String?
var lastSubtitle: String?
var lastBody: String?
var startedAt: TimeInterval
var updatedAt: TimeInterval
}
private struct ClaudeHookSessionStoreFile: Codable {
var version: Int = 1
var sessions: [String: ClaudeHookSessionRecord] = [:]
}
private final class ClaudeHookSessionStore {
private static let defaultStatePath = "~/.cmuxterm/claude-hook-sessions.json"
private static let maxStateAgeSeconds: TimeInterval = 60 * 60 * 24 * 7
private let statePath: String
private let fileManager: FileManager
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()
init(
processEnv: [String: String] = ProcessInfo.processInfo.environment,
fileManager: FileManager = .default
) {
if let overridePath = processEnv["CMUX_CLAUDE_HOOK_STATE_PATH"]?.trimmingCharacters(in: .whitespacesAndNewlines),
!overridePath.isEmpty {
self.statePath = NSString(string: overridePath).expandingTildeInPath
} else {
self.statePath = NSString(string: Self.defaultStatePath).expandingTildeInPath
}
self.fileManager = fileManager
self.encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
}
func lookup(sessionId: String) throws -> ClaudeHookSessionRecord? {
let normalized = normalizeSessionId(sessionId)
guard !normalized.isEmpty else { return nil }
return try withLockedState { state in
state.sessions[normalized]
}
}
func upsert(
sessionId: String,
workspaceId: String,
surfaceId: String,
cwd: String?,
lastSubtitle: String? = nil,
lastBody: String? = nil
) throws {
let normalized = normalizeSessionId(sessionId)
guard !normalized.isEmpty else { return }
try withLockedState { state in
let now = Date().timeIntervalSince1970
var record = state.sessions[normalized] ?? ClaudeHookSessionRecord(
sessionId: normalized,
workspaceId: workspaceId,
surfaceId: surfaceId,
cwd: nil,
lastSubtitle: nil,
lastBody: nil,
startedAt: now,
updatedAt: now
)
record.workspaceId = workspaceId
record.surfaceId = surfaceId
if let cwd = normalizeOptional(cwd) {
record.cwd = cwd
}
if let subtitle = normalizeOptional(lastSubtitle) {
record.lastSubtitle = subtitle
}
if let body = normalizeOptional(lastBody) {
record.lastBody = body
}
record.updatedAt = now
state.sessions[normalized] = record
}
}
func consume(
sessionId: String?,
workspaceId: String?,
surfaceId: String?
) throws -> ClaudeHookSessionRecord? {
let normalizedSessionId = normalizeOptional(sessionId)
let normalizedWorkspace = normalizeOptional(workspaceId)
let normalizedSurface = normalizeOptional(surfaceId)
return try withLockedState { state in
if let normalizedSessionId,
let removed = state.sessions.removeValue(forKey: normalizedSessionId) {
return removed
}
guard let fallback = fallbackRecord(
sessions: Array(state.sessions.values),
workspaceId: normalizedWorkspace,
surfaceId: normalizedSurface
) else {
return nil
}
state.sessions.removeValue(forKey: fallback.sessionId)
return fallback
}
}
private func fallbackRecord(
sessions: [ClaudeHookSessionRecord],
workspaceId: String?,
surfaceId: String?
) -> ClaudeHookSessionRecord? {
if let surfaceId {
let matches = sessions.filter { $0.surfaceId == surfaceId }
return matches.max(by: { $0.updatedAt < $1.updatedAt })
}
if let workspaceId {
let matches = sessions.filter { $0.workspaceId == workspaceId }
if matches.count == 1 {
return matches[0]
}
}
return nil
}
private func withLockedState<T>(_ body: (inout ClaudeHookSessionStoreFile) throws -> T) throws -> T {
let lockPath = statePath + ".lock"
let fd = open(lockPath, O_CREAT | O_RDWR, mode_t(S_IRUSR | S_IWUSR))
if fd < 0 {
throw CLIError(message: "Failed to open Claude hook state lock: \(lockPath)")
}
defer { Darwin.close(fd) }
if flock(fd, LOCK_EX) != 0 {
throw CLIError(message: "Failed to lock Claude hook state: \(lockPath)")
}
defer { _ = flock(fd, LOCK_UN) }
var state = loadUnlocked()
pruneExpired(&state)
let result = try body(&state)
try saveUnlocked(state)
return result
}
private func loadUnlocked() -> ClaudeHookSessionStoreFile {
guard fileManager.fileExists(atPath: statePath) else {
return ClaudeHookSessionStoreFile()
}
guard let data = try? Data(contentsOf: URL(fileURLWithPath: statePath)),
let decoded = try? decoder.decode(ClaudeHookSessionStoreFile.self, from: data) else {
return ClaudeHookSessionStoreFile()
}
return decoded
}
private func saveUnlocked(_ state: ClaudeHookSessionStoreFile) throws {
let stateURL = URL(fileURLWithPath: statePath)
let parentURL = stateURL.deletingLastPathComponent()
try fileManager.createDirectory(at: parentURL, withIntermediateDirectories: true, attributes: nil)
let data = try encoder.encode(state)
try data.write(to: stateURL, options: .atomic)
}
private func pruneExpired(_ state: inout ClaudeHookSessionStoreFile) {
let now = Date().timeIntervalSince1970
let cutoff = now - Self.maxStateAgeSeconds
state.sessions = state.sessions.filter { _, record in
record.updatedAt >= cutoff
}
}
private func normalizeSessionId(_ value: String) -> String {
value.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func normalizeOptional(_ value: String?) -> String? {
guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
return nil
}
return value
}
}
enum CLIIDFormat: String {
case refs
case uuids
case both
static func parse(_ raw: String?) throws -> CLIIDFormat? {
guard let raw else { return nil }
guard let parsed = CLIIDFormat(rawValue: raw.lowercased()) else {
throw CLIError(message: "--id-format must be one of: refs, uuids, both")
}
return parsed
}
}
private enum SocketPasswordResolver {
private static let service = "com.cmuxterm.app.socket-control"
private static let account = "local-socket-password"
private static let directoryName = "cmux"
private static let fileName = "socket-control-password"
static func resolve(explicit: String?, socketPath: String) -> String? {
if let explicit = normalized(explicit) {
return explicit
}
if let env = normalized(ProcessInfo.processInfo.environment["CMUX_SOCKET_PASSWORD"]) {
return env
}
if let filePassword = loadFromFile() {
return filePassword
}
return loadFromKeychain(socketPath: socketPath)
}
private static func normalized(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .newlines)
return trimmed.isEmpty ? nil : trimmed
}
private static func loadFromFile() -> String? {
guard let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
return nil
}
let passwordURL = appSupport
.appendingPathComponent(directoryName, isDirectory: true)
.appendingPathComponent(fileName, isDirectory: false)
guard let data = try? Data(contentsOf: passwordURL) else {
return nil
}
guard let value = String(data: data, encoding: .utf8) else {
return nil
}
return normalized(value)
}
private static func keychainServices(socketPath: String) -> [String] {
guard let scope = keychainScope(socketPath: socketPath) else {
return [service]
}
return ["\(service).\(scope)"]
}
private static func keychainScope(socketPath: String) -> String? {
if let tag = normalized(ProcessInfo.processInfo.environment["CMUX_TAG"]) {
let scoped = sanitizeScope(tag)
if !scoped.isEmpty {
return scoped
}
}
let candidate = URL(fileURLWithPath: socketPath).lastPathComponent
let prefixes = ["cmux-debug-", "cmux-"]
for prefix in prefixes {
guard candidate.hasPrefix(prefix), candidate.hasSuffix(".sock") else { continue }
let start = candidate.index(candidate.startIndex, offsetBy: prefix.count)
let end = candidate.index(candidate.endIndex, offsetBy: -".sock".count)
guard start < end else { continue }
let rawScope = String(candidate[start..<end])
let scoped = sanitizeScope(rawScope)
if !scoped.isEmpty {
return scoped
}
}
return nil
}
private static func sanitizeScope(_ raw: String) -> String {
let lowered = raw.lowercased()
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: ".-"))
let mappedScalars = lowered.unicodeScalars.map { scalar -> Character in
allowed.contains(scalar) ? Character(scalar) : "."
}
var normalizedScope = String(mappedScalars)
normalizedScope = normalizedScope.replacingOccurrences(
of: "\\.+",
with: ".",
options: .regularExpression
)
normalizedScope = normalizedScope.trimmingCharacters(in: CharacterSet(charactersIn: "."))
return normalizedScope
}
private static func loadFromKeychain(socketPath: String) -> String? {
for service in keychainServices(socketPath: socketPath) {
let authContext = LAContext()
authContext.interactionNotAllowed = true
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
// Never trigger keychain UI from CLI commands; fail fast instead.
kSecUseAuthenticationContext as String: authContext,
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecItemNotFound || status == errSecInteractionNotAllowed || status == errSecAuthFailed {
continue
}
guard status == errSecSuccess else {
continue
}
guard let data = result as? Data,
let password = String(data: data, encoding: .utf8) else {
continue
}
return password
}
return nil
}
}
private enum CLISocketPathSource {
case explicitFlag
case environment
case implicitDefault
}
private enum CLISocketPathResolver {
static let defaultSocketPath = "/tmp/cmux.sock"
private static let fallbackSocketPath = "/tmp/cmux-debug.sock"
private static let stagingSocketPath = "/tmp/cmux-staging.sock"
private static let lastSocketPathFile = "/tmp/cmux-last-socket-path"
static func resolve(
requestedPath: String,
source: CLISocketPathSource,
environment: [String: String] = ProcessInfo.processInfo.environment
) -> String {
guard source == .implicitDefault else {
return requestedPath
}
let candidates = dedupe(candidatePaths(requestedPath: requestedPath, environment: environment))
// Prefer sockets that are currently accepting connections.
for path in candidates where canConnect(to: path) {
return path
}
// If the listener is still starting, prefer existing socket files.
for path in candidates where isSocketFile(path) {
return path
}
return requestedPath
}
private static func candidatePaths(requestedPath: String, environment: [String: String]) -> [String] {
var candidates: [String] = []
if let tag = normalized(environment["CMUX_TAG"]) {
let slug = sanitizeTagSlug(tag)
candidates.append("/tmp/cmux-debug-\(slug).sock")
candidates.append("/tmp/cmux-\(slug).sock")
}
candidates.append(requestedPath)
candidates.append(fallbackSocketPath)
candidates.append(stagingSocketPath)
candidates.append(contentsOf: discoverTaggedSockets(limit: 12))
if let last = readLastSocketPath() {
candidates.append(last)
}
return candidates
}
private static func readLastSocketPath() -> String? {
guard let data = try? String(contentsOfFile: lastSocketPathFile, encoding: .utf8) else {
return nil
}
return normalized(data)
}
private static func discoverTaggedSockets(limit: Int) -> [String] {
guard let entries = try? FileManager.default.contentsOfDirectory(atPath: "/tmp") else {
return []
}
var discovered: [(path: String, mtime: TimeInterval)] = []
discovered.reserveCapacity(min(limit, entries.count))
for name in entries where name.hasPrefix("cmux") && name.hasSuffix(".sock") {
let path = "/tmp/\(name)"
var st = stat()
guard lstat(path, &st) == 0 else { continue }
guard (st.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK) else { continue }
if path == defaultSocketPath || path == fallbackSocketPath || path == stagingSocketPath {
continue
}
let modified = TimeInterval(st.st_mtimespec.tv_sec) + TimeInterval(st.st_mtimespec.tv_nsec) / 1_000_000_000
discovered.append((path: path, mtime: modified))
}
discovered.sort { $0.mtime > $1.mtime }
return discovered.prefix(limit).map(\.path)
}
private static func isSocketFile(_ path: String) -> Bool {
var st = stat()
return lstat(path, &st) == 0 && (st.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK)
}
private static func canConnect(to path: String) -> Bool {
guard isSocketFile(path) else { return false }
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else { return false }
defer { Darwin.close(fd) }
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
let maxLength = MemoryLayout.size(ofValue: addr.sun_path)
path.withCString { ptr in
withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in
let buf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self)
strncpy(buf, ptr, maxLength - 1)
}
}
let result = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
Darwin.connect(fd, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_un>.size))
}
}
return result == 0
}
private static func sanitizeTagSlug(_ raw: String) -> String {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let slug = trimmed
.replacingOccurrences(of: "[^a-z0-9]+", with: "-", options: .regularExpression)
.replacingOccurrences(of: "-+", with: "-", options: .regularExpression)
.trimmingCharacters(in: CharacterSet(charactersIn: "-"))
return slug.isEmpty ? "agent" : slug
}
private static func normalized(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
private static func dedupe(_ paths: [String]) -> [String] {
var seen: Set<String> = []
var ordered: [String] = []
ordered.reserveCapacity(paths.count)
for path in paths where !path.isEmpty {
if seen.insert(path).inserted {
ordered.append(path)
}
}
return ordered
}
}
final class SocketClient {
private let path: String
private var socketFD: Int32 = -1
private static let connectRetryWindowSeconds: TimeInterval = 2.0
private static let connectRetryIntervalSeconds: TimeInterval = 0.1
private static let retriableConnectErrnos: Set<Int32> = [
ENOENT,
ECONNREFUSED,
EAGAIN,
EINTR
]
private static let defaultResponseTimeoutSeconds: TimeInterval = 15.0
private static let responseTimeoutSeconds: TimeInterval = {
let env = ProcessInfo.processInfo.environment
if let raw = env["CMUXTERM_CLI_RESPONSE_TIMEOUT_SEC"],
let seconds = Double(raw),
seconds > 0 {
return seconds
}
return defaultResponseTimeoutSeconds
}()
init(path: String) {
self.path = path
}
var socketPath: String {
path
}
func connect() throws {
if socketFD >= 0 { return }
let deadline = Date().addingTimeInterval(Self.connectRetryWindowSeconds)
var lastError: CLIError?
while true {
// Verify socket is owned by the current user to prevent fake-socket attacks.
var st = stat()
guard stat(path, &st) == 0 else {
let error = CLIError(message: "Socket not found at \(path)")
lastError = error
if errno == ENOENT, Date() < deadline {
Thread.sleep(forTimeInterval: Self.connectRetryIntervalSeconds)
continue
}
throw error
}
guard (st.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK) else {
throw CLIError(message: "Path exists at \(path) but is not a Unix socket")
}
guard st.st_uid == getuid() else {
throw CLIError(message: "Socket at \(path) is not owned by the current user — refusing to connect")
}
socketFD = socket(AF_UNIX, SOCK_STREAM, 0)
if socketFD < 0 {
throw CLIError(message: "Failed to create socket")
}
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
let maxLength = MemoryLayout.size(ofValue: addr.sun_path)
path.withCString { ptr in
withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in
let buf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self)
strncpy(buf, ptr, maxLength - 1)
}
}
let result = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
Darwin.connect(socketFD, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_un>.size))
}
}
if result == 0 {
return
}
let connectErrno = errno
Darwin.close(socketFD)
socketFD = -1
let error = CLIError(
message: "Failed to connect to socket at \(path) (\(String(cString: strerror(connectErrno))), errno \(connectErrno))"
)
lastError = error
if Self.retriableConnectErrnos.contains(connectErrno), Date() < deadline {
Thread.sleep(forTimeInterval: Self.connectRetryIntervalSeconds)
continue
}
throw error
}
throw lastError ?? CLIError(message: "Failed to connect to socket at \(path)")
}
func close() {
if socketFD >= 0 {
Darwin.close(socketFD)
socketFD = -1
}
}
func send(command: String) throws -> String {
guard socketFD >= 0 else { throw CLIError(message: "Not connected") }
let payload = command + "\n"
try payload.withCString { ptr in
let sent = Darwin.write(socketFD, ptr, strlen(ptr))
if sent < 0 {
throw CLIError(message: "Failed to write to socket")
}
}
var data = Data()
var sawNewline = false
let start = Date()
while true {
var pollFD = pollfd(fd: socketFD, events: Int16(POLLIN), revents: 0)
let ready = poll(&pollFD, 1, 100)
if ready < 0 {
throw CLIError(message: "Socket read error")
}
if ready == 0 {
if sawNewline {
break
}
if Date().timeIntervalSince(start) > Self.responseTimeoutSeconds {
throw CLIError(message: "Command timed out")
}
continue
}
var buffer = [UInt8](repeating: 0, count: 8192)
let count = Darwin.read(socketFD, &buffer, buffer.count)
if count <= 0 {
break
}
data.append(buffer, count: count)
if data.contains(UInt8(0x0A)) {
sawNewline = true
}
}
guard var response = String(data: data, encoding: .utf8) else {
throw CLIError(message: "Invalid UTF-8 response")
}
if response.hasSuffix("\n") {
response.removeLast()
}
return response
}
func sendV2(method: String, params: [String: Any] = [:]) throws -> [String: Any] {
let request: [String: Any] = [
"id": UUID().uuidString,
"method": method,
"params": params
]
guard JSONSerialization.isValidJSONObject(request) else {
throw CLIError(message: "Failed to encode v2 request")
}
let requestData = try JSONSerialization.data(withJSONObject: request, options: [])
guard let requestLine = String(data: requestData, encoding: .utf8) else {
throw CLIError(message: "Failed to encode v2 request")
}
let raw = try send(command: requestLine)
// The server may return plain-text errors (e.g., "ERROR: Access denied ...")
// before the JSON protocol starts. Surface these directly instead of letting
// JSONSerialization throw a confusing parse error.
if raw.hasPrefix("ERROR:") {
throw CLIError(message: raw)
}
guard let responseData = raw.data(using: .utf8) else {
throw CLIError(message: "Invalid UTF-8 v2 response")
}
guard let response = try JSONSerialization.jsonObject(with: responseData, options: []) as? [String: Any] else {
throw CLIError(message: "Invalid v2 response: \(raw)")
}
if let ok = response["ok"] as? Bool, ok {
return (response["result"] as? [String: Any]) ?? [:]
}
if let error = response["error"] as? [String: Any] {
let code = (error["code"] as? String) ?? "error"
let message = (error["message"] as? String) ?? "Unknown v2 error"
throw CLIError(message: "\(code): \(message)")
}
throw CLIError(message: "v2 request failed")
}
}
struct CMUXCLI {
let args: [String]
private static let debugLastSocketHintPath = "/tmp/cmux-last-socket-path"
private static func normalizedEnvValue(_ value: String?) -> String? {
guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
!trimmed.isEmpty else {
return nil
}
return trimmed
}
private static func pathIsSocket(_ path: String) -> Bool {
var st = stat()
guard lstat(path, &st) == 0 else { return false }
return (st.st_mode & S_IFMT) == S_IFSOCK
}
private static func debugSocketPathFromHintFile() -> String? {
#if DEBUG
guard let raw = try? String(contentsOfFile: debugLastSocketHintPath, encoding: .utf8) else {
return nil
}
guard let hinted = normalizedEnvValue(raw),
hinted.hasPrefix("/tmp/cmux-debug"),
hinted.hasSuffix(".sock"),
pathIsSocket(hinted) else {
return nil
}
return hinted
#else
return nil
#endif
}
private static func defaultSocketPath(environment: [String: String]) -> String {
if let explicit = normalizedEnvValue(environment["CMUX_SOCKET_PATH"]) {
return explicit
}
#if DEBUG
if let hinted = debugSocketPathFromHintFile() {
return hinted
}
return "/tmp/cmux-debug.sock"
#else
return "/tmp/cmux.sock"
#endif
}
func run() throws {
let processEnv = ProcessInfo.processInfo.environment
let envSocketPath: String? = {
for key in ["CMUX_SOCKET_PATH", "CMUX_SOCKET"] {
guard let raw = processEnv[key] else { continue }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
return trimmed
}
}
return nil
}()
var socketPath = envSocketPath ?? CLISocketPathResolver.defaultSocketPath
var socketPathSource: CLISocketPathSource
if let envSocketPath {
socketPathSource = envSocketPath == CLISocketPathResolver.defaultSocketPath ? .implicitDefault : .environment
} else {
socketPathSource = .implicitDefault
}
var jsonOutput = false
var idFormatArg: String? = nil
var windowId: String? = nil
var socketPasswordArg: String? = nil
var index = 1
while index < args.count {
let arg = args[index]
if arg == "--socket" {
guard index + 1 < args.count else {
throw CLIError(message: "--socket requires a path")
}
socketPath = args[index + 1]
socketPathSource = .explicitFlag
index += 2
continue
}
if arg == "--json" {
jsonOutput = true
index += 1
continue
}
if arg == "--id-format" {
guard index + 1 < args.count else {
throw CLIError(message: "--id-format requires a value (refs|uuids|both)")
}
idFormatArg = args[index + 1]
index += 2
continue
}
if arg == "--window" {
guard index + 1 < args.count else {
throw CLIError(message: "--window requires a window id")
}
windowId = args[index + 1]
index += 2
continue
}
if arg == "--password" {
guard index + 1 < args.count else {
throw CLIError(message: "--password requires a value")
}
socketPasswordArg = args[index + 1]
index += 2
continue
}
if arg == "-v" || arg == "--version" {
print(versionSummary())
return
}
if arg == "-h" || arg == "--help" {
print(usage())
return
}
break
}
guard index < args.count else {
print(usage())
throw CLIError(message: "Missing command")
}
let command = args[index]
let commandArgs = Array(args[(index + 1)...])
let cliTelemetry = CLISocketSentryTelemetry(
command: command,
commandArgs: commandArgs,
socketPath: socketPath,
processEnv: processEnv
)
let resolvedSocketPath = CLISocketPathResolver.resolve(
requestedPath: socketPath,
source: socketPathSource,
environment: processEnv
)
if command == "version" {
print(versionSummary())
return
}
if command == "remote-daemon-status" {
try runRemoteDaemonStatus(commandArgs: commandArgs, jsonOutput: jsonOutput)
return
}
// If the argument looks like a path (not a known command), open a workspace there.
if looksLikePath(command) {
try openPath(command, socketPath: resolvedSocketPath)
return
}
// Check for --help/-h on subcommands before connecting to the socket,
// so help text is available even when cmux is not running.
if command != "__tmux-compat",
command != "claude-teams",
(commandArgs.contains("--help") || commandArgs.contains("-h")) {
if dispatchSubcommandHelp(command: command, commandArgs: commandArgs) {
return
}
print("Unknown command '\(command)'. Run 'cmux help' to see available commands.")
return
}
if command == "welcome" {
printWelcome()
return
}
if command == "shortcuts" {
try runShortcuts(
commandArgs: commandArgs,
socketPath: resolvedSocketPath,
explicitPassword: socketPasswordArg,
jsonOutput: jsonOutput
)
return
}
if command == "feedback" {
try runFeedback(
commandArgs: commandArgs,
socketPath: resolvedSocketPath,
explicitPassword: socketPasswordArg,
jsonOutput: jsonOutput
)
return
}
if command == "claude-teams" {
try runClaudeTeams(
commandArgs: commandArgs,
socketPath: resolvedSocketPath,
explicitPassword: socketPasswordArg
)
return
}
let client = SocketClient(path: resolvedSocketPath)
if resolvedSocketPath != socketPath {
cliTelemetry.breadcrumb(
"socket.path.autodiscovered",
data: [
"requested_path": socketPath,
"resolved_path": resolvedSocketPath
]
)
}
cliTelemetry.breadcrumb(
"socket.connect.attempt",
data: [
"command": command,
"path": resolvedSocketPath
]
)
do {
try client.connect()
cliTelemetry.breadcrumb("socket.connect.success", data: ["path": resolvedSocketPath])
} catch {
cliTelemetry.breadcrumb("socket.connect.failure", data: ["path": resolvedSocketPath])
cliTelemetry.captureError(stage: "socket_connect", error: error)
throw error
}
defer { client.close() }
try authenticateClientIfNeeded(
client,
explicitPassword: socketPasswordArg,
socketPath: resolvedSocketPath
)
let idFormat = try resolvedIDFormat(jsonOutput: jsonOutput, raw: idFormatArg)
// If the user explicitly targets a window, focus it first so commands route correctly.
if let windowId {
let normalizedWindow = try normalizeWindowHandle(windowId, client: client) ?? windowId
_ = try client.sendV2(method: "window.focus", params: ["window_id": normalizedWindow])
}
switch command {
case "ping":
let response = try sendV1Command("ping", client: client)
print(response)
case "capabilities":
let response = try client.sendV2(method: "system.capabilities")
print(jsonString(formatIDs(response, mode: idFormat)))
case "identify":
var params: [String: Any] = [:]
let includeCaller = !hasFlag(commandArgs, name: "--no-caller")
if includeCaller {
let idWsFlag = optionValue(commandArgs, name: "--workspace")
let workspaceArg = idWsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let surfaceArg = optionValue(commandArgs, name: "--surface") ?? (idWsFlag == nil && windowId == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil)
if workspaceArg != nil || surfaceArg != nil {
let workspaceId = try normalizeWorkspaceHandle(
workspaceArg,
client: client,
allowCurrent: surfaceArg != nil
)
var caller: [String: Any] = [:]
if let workspaceId {
caller["workspace_id"] = workspaceId
}
if surfaceArg != nil {
guard let surfaceId = try normalizeSurfaceHandle(
surfaceArg,
client: client,
workspaceHandle: workspaceId
) else {
throw CLIError(message: "Invalid surface handle")
}
caller["surface_id"] = surfaceId
}
if !caller.isEmpty {
params["caller"] = caller
}
}
}
let response = try client.sendV2(method: "system.identify", params: params)
print(jsonString(formatIDs(response, mode: idFormat)))
case "list-windows":
let response = try sendV1Command("list_windows", client: client)
if jsonOutput {
let windows = parseWindows(response)
let payload = windows.map { item -> [String: Any] in
var dict: [String: Any] = [
"index": item.index,
"id": item.id,
"key": item.key,
"workspace_count": item.workspaceCount,
]
dict["selected_workspace_id"] = item.selectedWorkspaceId ?? NSNull()
return dict
}
print(jsonString(payload))
} else {
print(response)
}
case "current-window":
let response = try sendV1Command("current_window", client: client)
if jsonOutput {
print(jsonString(["window_id": response]))
} else {
print(response)
}
case "new-window":
let response = try sendV1Command("new_window", client: client)
print(response)
case "focus-window":
guard let target = optionValue(commandArgs, name: "--window") else {
throw CLIError(message: "focus-window requires --window")
}
let response = try sendV1Command("focus_window \(target)", client: client)
print(response)
case "close-window":
guard let target = optionValue(commandArgs, name: "--window") else {
throw CLIError(message: "close-window requires --window")
}
let response = try sendV1Command("close_window \(target)", client: client)
print(response)
case "move-workspace-to-window":
guard let workspaceRaw = optionValue(commandArgs, name: "--workspace") else {
throw CLIError(message: "move-workspace-to-window requires --workspace")
}
guard let windowRaw = optionValue(commandArgs, name: "--window") else {
throw CLIError(message: "move-workspace-to-window requires --window")
}
var params: [String: Any] = [:]
let wsId = try normalizeWorkspaceHandle(workspaceRaw, client: client)
if let wsId { params["workspace_id"] = wsId }
let winId = try normalizeWindowHandle(windowRaw, client: client)
if let winId { params["window_id"] = winId }
let payload = try client.sendV2(method: "workspace.move_to_window", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace", "window"]))
case "move-surface":
try runMoveSurface(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat)
case "reorder-surface":
try runReorderSurface(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat)
case "reorder-workspace":
try runReorderWorkspace(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat)
case "workspace-action":
try runWorkspaceAction(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat, windowOverride: windowId)
case "tab-action":
try runTabAction(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat, windowOverride: windowId)
case "rename-tab":
try runRenameTab(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat, windowOverride: windowId)
case "list-workspaces":
let payload = try client.sendV2(method: "workspace.list")
if jsonOutput {
print(jsonString(formatIDs(payload, mode: idFormat)))
} else {
let workspaces = payload["workspaces"] as? [[String: Any]] ?? []
if workspaces.isEmpty {
print("No workspaces")
} else {
for ws in workspaces {
let selected = (ws["selected"] as? Bool) == true
let handle = textHandle(ws, idFormat: idFormat)
let title = (ws["title"] as? String) ?? ""
let remoteTag: String = {
guard let remote = ws["remote"] as? [String: Any],
(remote["enabled"] as? Bool) == true else {
return ""
}
let state = (remote["state"] as? String) ?? "unknown"
return " [ssh:\(state)]"
}()
let prefix = selected ? "* " : " "
let selTag = selected ? " [selected]" : ""
let titlePart = title.isEmpty ? "" : " \(title)"
print("\(prefix)\(handle)\(titlePart)\(remoteTag)\(selTag)")
}
}
}
case "ssh":
try runSSH(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat)
case "ssh-session-end":
try runSSHSessionEnd(commandArgs: commandArgs, client: client)
case "new-workspace":
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>, --cwd <path>")
}
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 sendParams: [String: Any] = ["text": text, "workspace_id": wsId]
_ = try client.sendV2(method: "surface.send_text", params: sendParams)
}
case "new-split":
let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace")
let (panelArg, rem1) = parseOption(rem0, name: "--panel")
let (sfArg, rem2) = parseOption(rem1, name: "--surface")
let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let surfaceRaw = sfArg ?? panelArg ?? (wsArg == nil && windowId == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil)
guard let direction = rem2.first else {
throw CLIError(message: "new-split requires a direction")
}
var params: [String: Any] = ["direction": direction]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client)
if let wsId { params["workspace_id"] = wsId }
let sfId = try normalizeSurfaceHandle(surfaceRaw, client: client, workspaceHandle: wsId)
if let sfId { params["surface_id"] = sfId }
let payload = try client.sendV2(method: "surface.split", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat))
case "list-panes":
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowId)
var params: [String: Any] = [:]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client)
if let wsId { params["workspace_id"] = wsId }
let payload = try client.sendV2(method: "pane.list", params: params)
if jsonOutput {
print(jsonString(formatIDs(payload, mode: idFormat)))
} else {
let panes = payload["panes"] as? [[String: Any]] ?? []
if panes.isEmpty {
print("No panes")
} else {
for pane in panes {
let focused = (pane["focused"] as? Bool) == true
let handle = textHandle(pane, idFormat: idFormat)
let count = pane["surface_count"] as? Int ?? 0
let prefix = focused ? "* " : " "
let focusTag = focused ? " [focused]" : ""
print("\(prefix)\(handle) [\(count) surface\(count == 1 ? "" : "s")]\(focusTag)")
}
}
}
case "list-pane-surfaces":
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowId)
let paneRaw = optionValue(commandArgs, name: "--pane")
var params: [String: Any] = [:]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client)
if let wsId { params["workspace_id"] = wsId }
let paneId = try normalizePaneHandle(paneRaw, client: client, workspaceHandle: wsId)
if let paneId { params["pane_id"] = paneId }
let payload = try client.sendV2(method: "pane.surfaces", params: params)
if jsonOutput {
print(jsonString(formatIDs(payload, mode: idFormat)))
} else {
let surfaces = payload["surfaces"] as? [[String: Any]] ?? []
if surfaces.isEmpty {
print("No surfaces in pane")
} else {
for surface in surfaces {
let selected = (surface["selected"] as? Bool) == true
let handle = textHandle(surface, idFormat: idFormat)
let title = (surface["title"] as? String) ?? ""
let prefix = selected ? "* " : " "
let selTag = selected ? " [selected]" : ""
print("\(prefix)\(handle) \(title)\(selTag)")
}
}
}
case "tree":
try runTreeCommand(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat)
case "focus-pane":
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowId)
guard let paneRaw = optionValue(commandArgs, name: "--pane") ?? commandArgs.first else {
throw CLIError(message: "focus-pane requires --pane <id|ref>")
}
var params: [String: Any] = [:]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client)
if let wsId { params["workspace_id"] = wsId }
let paneId = try normalizePaneHandle(paneRaw, client: client, workspaceHandle: wsId)
if let paneId { params["pane_id"] = paneId }
let payload = try client.sendV2(method: "pane.focus", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["pane", "workspace"]))
case "new-pane":
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowId)
let type = optionValue(commandArgs, name: "--type")
let direction = optionValue(commandArgs, name: "--direction") ?? "right"
let url = optionValue(commandArgs, name: "--url")
var params: [String: Any] = ["direction": direction]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client)
if let wsId { params["workspace_id"] = wsId }
if let type { params["type"] = type }
if let url { params["url"] = url }
let payload = try client.sendV2(method: "pane.create", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["surface", "pane", "workspace"]))
case "new-surface":
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowId)
let type = optionValue(commandArgs, name: "--type")
let paneRaw = optionValue(commandArgs, name: "--pane")
let url = optionValue(commandArgs, name: "--url")
var params: [String: Any] = [:]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client)
if let wsId { params["workspace_id"] = wsId }
let paneId = try normalizePaneHandle(paneRaw, client: client, workspaceHandle: wsId)
if let paneId { params["pane_id"] = paneId }
if let type { params["type"] = type }
if let url { params["url"] = url }
let payload = try client.sendV2(method: "surface.create", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["surface", "pane", "workspace"]))
case "close-surface":
let csWsFlag = optionValue(commandArgs, name: "--workspace")
let workspaceArg = csWsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let surfaceRaw = optionValue(commandArgs, name: "--surface") ?? optionValue(commandArgs, name: "--panel") ?? (csWsFlag == nil && windowId == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil)
var params: [String: Any] = [:]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client)
if let wsId { params["workspace_id"] = wsId }
let sfId = try normalizeSurfaceHandle(surfaceRaw, client: client, workspaceHandle: wsId)
if let sfId { params["surface_id"] = sfId }
let payload = try client.sendV2(method: "surface.close", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat))
case "drag-surface-to-split":
let (surfaceArg, rem0) = parseOption(commandArgs, name: "--surface")
let (panelArg, rem1) = parseOption(rem0, name: "--panel")
let surface = surfaceArg ?? panelArg
guard let surface else {
throw CLIError(message: "drag-surface-to-split requires --surface <id|index>")
}
guard let direction = rem1.first else {
throw CLIError(message: "drag-surface-to-split requires a direction")
}
let response = try sendV1Command("drag_surface_to_split \(surface) \(direction)", client: client)
print(response)
case "refresh-surfaces":
let response = try sendV1Command("refresh_surfaces", client: client)
print(response)
case "surface-health":
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowId)
var params: [String: Any] = [:]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client)
if let wsId { params["workspace_id"] = wsId }
let payload = try client.sendV2(method: "surface.health", params: params)
if jsonOutput {
print(jsonString(formatIDs(payload, mode: idFormat)))
} else {
let surfaces = payload["surfaces"] as? [[String: Any]] ?? []
if surfaces.isEmpty {
print("No surfaces")
} else {
for surface in surfaces {
let handle = textHandle(surface, idFormat: idFormat)
let sType = (surface["type"] as? String) ?? ""
let inWindow = surface["in_window"]
let inWindowStr: String
if let b = inWindow as? Bool {
inWindowStr = " in_window=\(b)"
} else {
inWindowStr = ""
}
print("\(handle) type=\(sType)\(inWindowStr)")
}
}
}
case "trigger-flash":
let tfWsFlag = optionValue(commandArgs, name: "--workspace")
let workspaceArg = tfWsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let surfaceArg = optionValue(commandArgs, name: "--surface") ?? optionValue(commandArgs, name: "--panel") ?? (tfWsFlag == nil && windowId == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil)
var params: [String: Any] = [:]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client)
if let wsId { params["workspace_id"] = wsId }
let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId)
if let sfId { params["surface_id"] = sfId }
let payload = try client.sendV2(method: "surface.trigger_flash", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat))
case "list-panels":
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowId)
var params: [String: Any] = [:]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client)
if let wsId { params["workspace_id"] = wsId }
let payload = try client.sendV2(method: "surface.list", params: params)
if jsonOutput {
print(jsonString(formatIDs(payload, mode: idFormat)))
} else {
let surfaces = payload["surfaces"] as? [[String: Any]] ?? []
if surfaces.isEmpty {
print("No surfaces")
} else {
for surface in surfaces {
let focused = (surface["focused"] as? Bool) == true
let handle = textHandle(surface, idFormat: idFormat)
let sType = (surface["type"] as? String) ?? ""
let title = (surface["title"] as? String) ?? ""
let prefix = focused ? "* " : " "
let focusTag = focused ? " [focused]" : ""
let titlePart = title.isEmpty ? "" : " \"\(title)\""
print("\(prefix)\(handle) \(sType)\(focusTag)\(titlePart)")
}
}
}
case "focus-panel":
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowId)
guard let panelRaw = optionValue(commandArgs, name: "--panel") else {
throw CLIError(message: "focus-panel requires --panel")
}
var params: [String: Any] = [:]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client)
if let wsId { params["workspace_id"] = wsId }
let sfId = try normalizeSurfaceHandle(panelRaw, client: client, workspaceHandle: wsId)
if let sfId { params["surface_id"] = sfId }
let payload = try client.sendV2(method: "surface.focus", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat))
case "close-workspace":
guard let workspaceRaw = optionValue(commandArgs, name: "--workspace") else {
throw CLIError(message: "close-workspace requires --workspace")
}
var params: [String: Any] = [:]
let wsId = try normalizeWorkspaceHandle(workspaceRaw, client: client)
if let wsId { params["workspace_id"] = wsId }
let payload = try client.sendV2(method: "workspace.close", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"]))
case "select-workspace":
guard let workspaceRaw = optionValue(commandArgs, name: "--workspace") else {
throw CLIError(message: "select-workspace requires --workspace")
}
var params: [String: Any] = [:]
let wsId = try normalizeWorkspaceHandle(workspaceRaw, client: client)
if let wsId { params["workspace_id"] = wsId }
let payload = try client.sendV2(method: "workspace.select", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"]))
case "rename-workspace", "rename-window":
let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace")
let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let titleArgs = rem0.dropFirst(rem0.first == "--" ? 1 : 0)
let title = titleArgs.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
guard !title.isEmpty else {
throw CLIError(message: "\(command) requires a title")
}
let wsId = try resolveWorkspaceId(workspaceArg, client: client)
let params: [String: Any] = ["title": title, "workspace_id": wsId]
let payload = try client.sendV2(method: "workspace.rename", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"]))
case "current-workspace":
let response = try sendV1Command("current_workspace", client: client)
if jsonOutput {
print(jsonString(["workspace_id": response]))
} else {
print(response)
}
case "read-screen":
let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace")
let (sfArg, rem1) = parseOption(rem0, name: "--surface")
let (linesArg, rem2) = parseOption(rem1, name: "--lines")
let trailing = rem2.filter { $0 != "--scrollback" }
if !trailing.isEmpty {
throw CLIError(message: "read-screen: unexpected arguments: \(trailing.joined(separator: " "))")
}
let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let surfaceArg = sfArg ?? (wsArg == nil && windowId == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil)
var params: [String: Any] = [:]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client)
if let wsId { params["workspace_id"] = wsId }
let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId)
if let sfId { params["surface_id"] = sfId }
let includeScrollback = rem2.contains("--scrollback")
if includeScrollback {
params["scrollback"] = true
}
if let linesArg {
guard let lineCount = Int(linesArg), lineCount > 0 else {
throw CLIError(message: "--lines must be greater than 0")
}
params["lines"] = lineCount
params["scrollback"] = true
}
let payload = try client.sendV2(method: "surface.read_text", params: params)
if jsonOutput {
print(jsonString(payload))
} else {
print((payload["text"] as? String) ?? "")
}
case "send":
let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace")
let (sfArg, rem1) = parseOption(rem0, name: "--surface")
let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let surfaceArg = sfArg ?? (wsArg == nil && windowId == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil)
let rawText = rem1.dropFirst(rem1.first == "--" ? 1 : 0).joined(separator: " ")
guard !rawText.isEmpty else { throw CLIError(message: "send requires text") }
let text = unescapeSendText(rawText)
var params: [String: Any] = ["text": text]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client)
if let wsId { params["workspace_id"] = wsId }
let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId)
if let sfId { params["surface_id"] = sfId }
let payload = try client.sendV2(method: "surface.send_text", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat))
case "send-key":
let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace")
let (sfArg, rem1) = parseOption(rem0, name: "--surface")
let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let surfaceArg = sfArg ?? (wsArg == nil && windowId == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil)
let keyArgs = rem1.first == "--" ? Array(rem1.dropFirst()) : rem1
guard let key = keyArgs.first else { throw CLIError(message: "send-key requires a key") }
var params: [String: Any] = ["key": key]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client)
if let wsId { params["workspace_id"] = wsId }
let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId)
if let sfId { params["surface_id"] = sfId }
let payload = try client.sendV2(method: "surface.send_key", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat))
case "send-panel":
let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace")
let (panelArg, rem1) = parseOption(rem0, name: "--panel")
let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
guard let panelArg else {
throw CLIError(message: "send-panel requires --panel")
}
let rawText = rem1.dropFirst(rem1.first == "--" ? 1 : 0).joined(separator: " ")
guard !rawText.isEmpty else { throw CLIError(message: "send-panel requires text") }
let text = unescapeSendText(rawText)
var params: [String: Any] = ["text": text]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client)
if let wsId { params["workspace_id"] = wsId }
let sfId = try normalizeSurfaceHandle(panelArg, client: client, workspaceHandle: wsId)
if let sfId { params["surface_id"] = sfId }
let payload = try client.sendV2(method: "surface.send_text", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat))
case "send-key-panel":
let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace")
let (panelArg, rem1) = parseOption(rem0, name: "--panel")
let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
guard let panelArg else {
throw CLIError(message: "send-key-panel requires --panel")
}
let skpArgs = rem1.first == "--" ? Array(rem1.dropFirst()) : rem1
let key = skpArgs.first ?? ""
guard !key.isEmpty else { throw CLIError(message: "send-key-panel requires a key") }
var params: [String: Any] = ["key": key]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client)
if let wsId { params["workspace_id"] = wsId }
let sfId = try normalizeSurfaceHandle(panelArg, client: client, workspaceHandle: wsId)
if let sfId { params["surface_id"] = sfId }
let payload = try client.sendV2(method: "surface.send_key", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat))
case "notify":
let title = optionValue(commandArgs, name: "--title") ?? "Notification"
let subtitle = optionValue(commandArgs, name: "--subtitle") ?? ""
let body = optionValue(commandArgs, name: "--body") ?? ""
let notifyWsFlag = optionValue(commandArgs, name: "--workspace")
let workspaceArg = notifyWsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let surfaceArg = optionValue(commandArgs, name: "--surface") ?? (notifyWsFlag == nil && windowId == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil)
let targetWorkspace = try resolveWorkspaceId(workspaceArg, client: client)
let targetSurface = try resolveSurfaceId(surfaceArg, workspaceId: targetWorkspace, client: client)
let payload = "\(title)|\(subtitle)|\(body)"
let response = try sendV1Command("notify_target \(targetWorkspace) \(targetSurface) \(payload)", client: client)
print(response)
case "list-notifications":
let response = try sendV1Command("list_notifications", client: client)
if jsonOutput {
let notifications = parseNotifications(response)
let payload = notifications.map { item in
var dict: [String: Any] = [
"id": item.id,
"workspace_id": item.workspaceId,
"is_read": item.isRead,
"title": item.title,
"subtitle": item.subtitle,
"body": item.body
]
dict["surface_id"] = item.surfaceId ?? NSNull()
return dict
}
print(jsonString(payload))
} else {
print(response)
}
case "clear-notifications":
var socketCmd = "clear_notifications"
if let wsFlag = optionValue(commandArgs, name: "--workspace") {
let wsId = try resolveWorkspaceId(wsFlag, client: client)
socketCmd += " --tab=\(wsId)"
} else if windowId == nil,
let envWs = ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"],
let wsId = try? resolveWorkspaceId(envWs, client: client) {
socketCmd += " --tab=\(wsId)"
}
let response = try sendV1Command(socketCmd, client: client)
print(response)
case "claude-hook":
cliTelemetry.breadcrumb("claude-hook.dispatch")
do {
try runClaudeHook(commandArgs: commandArgs, client: client, telemetry: cliTelemetry)
cliTelemetry.breadcrumb("claude-hook.completed")
} catch {
cliTelemetry.breadcrumb("claude-hook.failure")
cliTelemetry.captureError(stage: "claude_hook_dispatch", error: error)
throw error
}
case "set-app-focus":
guard let value = commandArgs.first else { throw CLIError(message: "set-app-focus requires a value") }
let response = try sendV1Command("set_app_focus \(value)", client: client)
print(response)
case "simulate-app-active":
let response = try sendV1Command("simulate_app_active", client: client)
print(response)
case "__tmux-compat":
try runClaudeTeamsTmuxCompat(
commandArgs: commandArgs,
client: client,
jsonOutput: jsonOutput,
idFormat: idFormat,
windowOverride: windowId
)
case "capture-pane",
"resize-pane",
"pipe-pane",
"wait-for",
"swap-pane",
"break-pane",
"join-pane",
"last-window",
"last-pane",
"next-window",
"previous-window",
"find-window",
"clear-history",
"set-hook",
"popup",
"bind-key",
"unbind-key",
"copy-mode",
"set-buffer",
"paste-buffer",
"list-buffers",
"respawn-pane",
"display-message":
try runTmuxCompatCommand(
command: command,
commandArgs: commandArgs,
client: client,
jsonOutput: jsonOutput,
idFormat: idFormat,
windowOverride: windowId
)
case "help":
print(usage())
// Browser commands
case "browser":
try runBrowserCommand(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat)
// Legacy aliases shimmed onto the v2 browser command surface.
case "open-browser":
try runBrowserCommand(commandArgs: ["open"] + commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat)
case "navigate":
let bridged = replaceToken(commandArgs, from: "--panel", to: "--surface")
try runBrowserCommand(commandArgs: ["navigate"] + bridged, client: client, jsonOutput: jsonOutput, idFormat: idFormat)
case "browser-back":
let bridged = replaceToken(commandArgs, from: "--panel", to: "--surface")
try runBrowserCommand(commandArgs: ["back"] + bridged, client: client, jsonOutput: jsonOutput, idFormat: idFormat)
case "browser-forward":
let bridged = replaceToken(commandArgs, from: "--panel", to: "--surface")
try runBrowserCommand(commandArgs: ["forward"] + bridged, client: client, jsonOutput: jsonOutput, idFormat: idFormat)
case "browser-reload":
let bridged = replaceToken(commandArgs, from: "--panel", to: "--surface")
try runBrowserCommand(commandArgs: ["reload"] + bridged, client: client, jsonOutput: jsonOutput, idFormat: idFormat)
case "get-url":
let bridged = replaceToken(commandArgs, from: "--panel", to: "--surface")
try runBrowserCommand(commandArgs: ["get-url"] + bridged, client: client, jsonOutput: jsonOutput, idFormat: idFormat)
case "focus-webview":
let bridged = replaceToken(commandArgs, from: "--panel", to: "--surface")
try runBrowserCommand(commandArgs: ["focus-webview"] + bridged, client: client, jsonOutput: jsonOutput, idFormat: idFormat)
case "is-webview-focused":
let bridged = replaceToken(commandArgs, from: "--panel", to: "--surface")
try runBrowserCommand(commandArgs: ["is-webview-focused"] + bridged, client: client, jsonOutput: jsonOutput, idFormat: idFormat)
// Markdown commands
case "markdown":
try runMarkdownCommand(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat)
default:
print(usage())
throw CLIError(message: "Unknown command: \(command)")
}
}
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)
}
private func sanitizedFilenameComponent(_ raw: String) -> String {
let sanitized = raw.replacingOccurrences(
of: #"[^\p{L}\p{N}._-]+"#,
with: "-",
options: .regularExpression
)
let trimmed = sanitized.trimmingCharacters(in: CharacterSet(charactersIn: "-."))
return trimmed.isEmpty ? "item" : trimmed
}
private func bestEffortPruneTemporaryFiles(
in directoryURL: URL,
keepingMostRecent maxCount: Int = 50,
maxAge: TimeInterval = 24 * 60 * 60
) {
guard let entries = try? FileManager.default.contentsOfDirectory(
at: directoryURL,
includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey, .creationDateKey],
options: [.skipsHiddenFiles]
) else {
return
}
let now = Date()
let datedEntries = entries.compactMap { url -> (url: URL, date: Date)? in
guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .contentModificationDateKey, .creationDateKey]),
values.isRegularFile == true else {
return nil
}
return (url, values.contentModificationDate ?? values.creationDate ?? .distantPast)
}.sorted { $0.date > $1.date }
for (index, entry) in datedEntries.enumerated() {
if index >= maxCount || now.timeIntervalSince(entry.date) > maxAge {
try? FileManager.default.removeItem(at: entry.url)
}
}
}
// MARK: - Markdown Commands
private func runMarkdownCommand(
commandArgs: [String],
client: SocketClient,
jsonOutput: Bool,
idFormat: CLIIDFormat
) throws {
var args = commandArgs
// Parse routing flags
let (workspaceOpt, argsAfterWorkspace) = parseOption(args, name: "--workspace")
let (windowOpt, argsAfterWindow) = parseOption(argsAfterWorkspace, name: "--window")
let (surfaceOpt, argsAfterSurface) = parseOption(argsAfterWindow, name: "--surface")
args = argsAfterSurface
// Determine subcommand. Explicit "open" is supported, otherwise treat
// a single positional argument as shorthand path.
let subArgs: [String]
if let first = args.first, first.lowercased() == "open" {
subArgs = Array(args.dropFirst())
} else if args.count == 1, let first = args.first, !first.hasPrefix("-") {
subArgs = [first]
} else {
// Allow path-like first tokens (e.g. plan.md) with trailing args
// so we can surface specific trailing-arg/flag errors below.
if let first = args.first, first.hasPrefix("-") {
throw CLIError(
message:
"markdown open: unknown flag '\(first)'. Usage: cmux markdown open <path> [--workspace <id|ref|index>] [--surface <id|ref|index>] [--window <id|ref|index>]"
)
} else if let first = args.first, looksLikePath(first) || first.contains(".") {
subArgs = args
} else if let first = args.first {
throw CLIError(message: "Unknown markdown subcommand: \(first). Usage: cmux markdown open <path>")
} else {
subArgs = []
}
}
guard let rawPath = subArgs.first, !rawPath.isEmpty else {
throw CLIError(message: "markdown open requires a file path. Usage: cmux markdown open <path>")
}
let trailingArgs = Array(subArgs.dropFirst())
if let unknownFlag = trailingArgs.first(where: { $0.hasPrefix("-") }) {
throw CLIError(
message:
"markdown open: unknown flag '\(unknownFlag)'. Usage: cmux markdown open <path> [--workspace <id|ref|index>] [--surface <id|ref|index>] [--window <id|ref|index>]"
)
}
if let extraArg = trailingArgs.first {
throw CLIError(
message:
"markdown open: unexpected argument '\(extraArg)'. Usage: cmux markdown open <path> [--workspace <id|ref|index>] [--surface <id|ref|index>] [--window <id|ref|index>]"
)
}
let absolutePath = resolvePath(rawPath)
// Build params
var params: [String: Any] = ["path": absolutePath]
if let surfaceRaw = surfaceOpt {
if let surface = try normalizeSurfaceHandle(surfaceRaw, client: client) {
params["surface_id"] = surface
}
}
let workspaceRaw = workspaceOpt ?? (windowOpt == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
if let workspaceRaw {
if let workspace = try normalizeWorkspaceHandle(workspaceRaw, client: client) {
params["workspace_id"] = workspace
}
}
if let windowRaw = windowOpt {
if let window = try normalizeWindowHandle(windowRaw, client: client) {
params["window_id"] = window
}
}
let payload = try client.sendV2(method: "markdown.open", params: params)
if jsonOutput {
print(jsonString(formatIDs(payload, mode: idFormat)))
} else {
let surfaceText = formatHandle(payload, kind: "surface", idFormat: idFormat) ?? "unknown"
let paneText = formatHandle(payload, kind: "pane", idFormat: idFormat) ?? "unknown"
let filePath = (payload["path"] as? String) ?? absolutePath
print("OK surface=\(surfaceText) pane=\(paneText) path=\(filePath)")
}
}
/// 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 runFeedback(
commandArgs: [String],
socketPath: String,
explicitPassword: String?,
jsonOutput: Bool
) throws {
let (emailOpt, rem0) = parseOption(commandArgs, name: "--email")
let (bodyOpt, rem1) = parseOption(rem0, name: "--body")
let (imagePaths, rem2) = parseRepeatedOption(rem1, name: "--image")
let remaining = rem2.filter { $0 != "--" }
if let unknown = remaining.first {
throw CLIError(message: "feedback: unknown flag '\(unknown)'. Known flags: --email <email>, --body <text>, --image <path>")
}
let client = try connectClient(
socketPath: socketPath,
explicitPassword: explicitPassword,
launchIfNeeded: true
)
defer { client.close() }
if emailOpt == nil && bodyOpt == nil && imagePaths.isEmpty {
var params: [String: Any] = [:]
let env = ProcessInfo.processInfo.environment
if let workspaceId = env["CMUX_WORKSPACE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines),
!workspaceId.isEmpty {
params["workspace_id"] = workspaceId
params["activate"] = false
} else {
params["activate"] = true
}
let response = try client.sendV2(method: "feedback.open", params: params)
if jsonOutput {
print(jsonString(response))
} else {
print("OK")
}
return
}
guard let email = emailOpt?.trimmingCharacters(in: .whitespacesAndNewlines),
email.isEmpty == false else {
throw CLIError(message: "feedback requires --email <email> when sending feedback")
}
guard let body = bodyOpt, body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else {
throw CLIError(message: "feedback requires --body <text> when sending feedback")
}
let resolvedImages = imagePaths.map(resolvePath)
let response = try client.sendV2(method: "feedback.submit", params: [
"email": email,
"body": body,
"image_paths": resolvedImages,
])
if jsonOutput {
print(jsonString(response))
} else {
print("OK")
}
}
private func runShortcuts(
commandArgs: [String],
socketPath: String,
explicitPassword: String?,
jsonOutput: Bool
) throws {
let remaining = commandArgs.filter { $0 != "--" }
if let unknown = remaining.first {
throw CLIError(message: "shortcuts: unknown flag '\(unknown)'")
}
let client = try connectClient(
socketPath: socketPath,
explicitPassword: explicitPassword,
launchIfNeeded: true
)
defer { client.close() }
let response = try client.sendV2(method: "settings.open", params: [
"target": "keyboardShortcuts",
"activate": true,
])
if jsonOutput {
print(jsonString(response))
} else {
print("OK")
}
}
private func connectClient(
socketPath: String,
explicitPassword: String?,
launchIfNeeded: Bool
) throws -> SocketClient {
let client = SocketClient(path: socketPath)
if launchIfNeeded && (try? client.connect()) == nil {
client.close()
try launchApp()
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))")
}
try authenticateClientIfNeeded(
pollClient,
explicitPassword: explicitPassword,
socketPath: socketPath
)
return pollClient
}
try client.connect()
try authenticateClientIfNeeded(
client,
explicitPassword: explicitPassword,
socketPath: socketPath
)
return client
}
private func authenticateClientIfNeeded(
_ client: SocketClient,
explicitPassword: String?,
socketPath: String
) throws {
if let socketPassword = SocketPasswordResolver.resolve(
explicit: explicitPassword,
socketPath: socketPath
) {
let authResponse = try client.send(command: "auth \(socketPassword)")
if authResponse.hasPrefix("ERROR:"),
!authResponse.contains("Unknown command 'auth'") {
throw CLIError(message: authResponse)
}
}
}
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 resolvedIDFormat(jsonOutput: Bool, raw: String?) throws -> CLIIDFormat {
_ = jsonOutput
if let parsed = try CLIIDFormat.parse(raw) {
return parsed
}
return .refs
}
private func sendV1Command(_ command: String, client: SocketClient) throws -> String {
let response = try client.send(command: command)
if response.hasPrefix("ERROR:") {
throw CLIError(message: response)
}
return response
}
private func formatIDs(_ object: Any, mode: CLIIDFormat) -> Any {
switch object {
case let dict as [String: Any]:
var out: [String: Any] = [:]
for (k, v) in dict {
out[k] = formatIDs(v, mode: mode)
}
switch mode {
case .both:
break
case .refs:
if out["ref"] != nil && out["id"] != nil {
out.removeValue(forKey: "id")
}
let keys = Array(out.keys)
for key in keys where key.hasSuffix("_id") {
let prefix = String(key.dropLast(3))
if out["\(prefix)_ref"] != nil {
out.removeValue(forKey: key)
}
}
for key in keys where key.hasSuffix("_ids") {
let prefix = String(key.dropLast(4))
if out["\(prefix)_refs"] != nil {
out.removeValue(forKey: key)
}
}
case .uuids:
if out["id"] != nil && out["ref"] != nil {
out.removeValue(forKey: "ref")
}
let keys = Array(out.keys)
for key in keys where key.hasSuffix("_ref") {
let prefix = String(key.dropLast(4))
if out["\(prefix)_id"] != nil {
out.removeValue(forKey: key)
}
}
for key in keys where key.hasSuffix("_refs") {
let prefix = String(key.dropLast(5))
if out["\(prefix)_ids"] != nil {
out.removeValue(forKey: key)
}
}
}
return out
case let array as [Any]:
return array.map { formatIDs($0, mode: mode) }
default:
return object
}
}
private func intFromAny(_ value: Any?) -> Int? {
if let i = value as? Int { return i }
if let n = value as? NSNumber { return n.intValue }
if let s = value as? String { return Int(s) }
return nil
}
private func parseBoolString(_ raw: String) -> Bool? {
switch raw.lowercased() {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
return nil
}
}
private func parsePositiveInt(_ raw: String?, label: String) throws -> Int? {
guard let raw else { return nil }
guard let value = Int(raw) else {
throw CLIError(message: "\(label) must be an integer")
}
return value
}
private func isHandleRef(_ value: String) -> Bool {
let pieces = value.split(separator: ":", omittingEmptySubsequences: false)
guard pieces.count == 2 else { return false }
let kind = String(pieces[0]).lowercased()
guard ["window", "workspace", "pane", "surface"].contains(kind) else { return false }
return Int(String(pieces[1])) != nil
}
private func normalizeWindowHandle(_ raw: String?, client: SocketClient, allowCurrent: Bool = false) throws -> String? {
guard let raw else {
if !allowCurrent { return nil }
let current = try client.sendV2(method: "window.current")
return (current["window_ref"] as? String) ?? (current["window_id"] as? String)
}
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return nil }
if isUUID(trimmed) || isHandleRef(trimmed) {
return trimmed
}
guard let wantedIndex = Int(trimmed) else {
throw CLIError(message: "Invalid window handle: \(trimmed) (expected UUID, ref like window:1, or index)")
}
let listed = try client.sendV2(method: "window.list")
let windows = listed["windows"] as? [[String: Any]] ?? []
for item in windows where intFromAny(item["index"]) == wantedIndex {
return (item["ref"] as? String) ?? (item["id"] as? String)
}
throw CLIError(message: "Window index not found")
}
private func normalizeWorkspaceHandle(
_ raw: String?,
client: SocketClient,
windowHandle: String? = nil,
allowCurrent: Bool = false
) throws -> String? {
guard let raw else {
if !allowCurrent { return nil }
let current = try client.sendV2(method: "workspace.current")
return (current["workspace_ref"] as? String) ?? (current["workspace_id"] as? String)
}
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return nil }
if isUUID(trimmed) || isHandleRef(trimmed) {
return trimmed
}
guard let wantedIndex = Int(trimmed) else {
throw CLIError(message: "Invalid workspace handle: \(trimmed) (expected UUID, ref like workspace:1, or index)")
}
var params: [String: Any] = [:]
if let windowHandle {
params["window_id"] = windowHandle
}
let listed = try client.sendV2(method: "workspace.list", params: params)
let items = listed["workspaces"] as? [[String: Any]] ?? []
for item in items where intFromAny(item["index"]) == wantedIndex {
return (item["ref"] as? String) ?? (item["id"] as? String)
}
throw CLIError(message: "Workspace index not found")
}
private func normalizePaneHandle(
_ raw: String?,
client: SocketClient,
workspaceHandle: String? = nil,
allowFocused: Bool = false
) throws -> String? {
guard let raw else {
if !allowFocused { return nil }
let ident = try client.sendV2(method: "system.identify")
let focused = ident["focused"] as? [String: Any] ?? [:]
return (focused["pane_ref"] as? String) ?? (focused["pane_id"] as? String)
}
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return nil }
if isUUID(trimmed) || isHandleRef(trimmed) {
return trimmed
}
guard let wantedIndex = Int(trimmed) else {
throw CLIError(message: "Invalid pane handle: \(trimmed) (expected UUID, ref like pane:1, or index)")
}
var params: [String: Any] = [:]
if let workspaceHandle {
params["workspace_id"] = workspaceHandle
}
let listed = try client.sendV2(method: "pane.list", params: params)
let items = listed["panes"] as? [[String: Any]] ?? []
for item in items where intFromAny(item["index"]) == wantedIndex {
return (item["ref"] as? String) ?? (item["id"] as? String)
}
throw CLIError(message: "Pane index not found")
}
private func normalizeSurfaceHandle(
_ raw: String?,
client: SocketClient,
workspaceHandle: String? = nil,
allowFocused: Bool = false
) throws -> String? {
guard let raw else {
if !allowFocused { return nil }
let ident = try client.sendV2(method: "system.identify")
let focused = ident["focused"] as? [String: Any] ?? [:]
return (focused["surface_ref"] as? String) ?? (focused["surface_id"] as? String)
}
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return nil }
if isUUID(trimmed) || isHandleRef(trimmed) {
return trimmed
}
guard let wantedIndex = Int(trimmed) else {
throw CLIError(message: "Invalid surface handle: \(trimmed) (expected UUID, ref like surface:1, or index)")
}
var params: [String: Any] = [:]
if let workspaceHandle {
params["workspace_id"] = workspaceHandle
}
let listed = try client.sendV2(method: "surface.list", params: params)
let items = listed["surfaces"] as? [[String: Any]] ?? []
for item in items where intFromAny(item["index"]) == wantedIndex {
return (item["ref"] as? String) ?? (item["id"] as? String)
}
throw CLIError(message: "Surface index not found")
}
private func canonicalSurfaceHandleFromTabInput(_ value: String) -> String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
let pieces = trimmed.split(separator: ":", omittingEmptySubsequences: false)
guard pieces.count == 2,
String(pieces[0]).lowercased() == "tab",
let ordinal = Int(String(pieces[1])) else {
return trimmed
}
return "surface:\(ordinal)"
}
private func normalizeTabHandle(
_ raw: String?,
client: SocketClient,
workspaceHandle: String? = nil,
allowFocused: Bool = false
) throws -> String? {
guard let raw else {
return try normalizeSurfaceHandle(
nil,
client: client,
workspaceHandle: workspaceHandle,
allowFocused: allowFocused
)
}
let canonical = canonicalSurfaceHandleFromTabInput(raw)
return try normalizeSurfaceHandle(
canonical,
client: client,
workspaceHandle: workspaceHandle,
allowFocused: false
)
}
private func displayTabHandle(_ raw: String?) -> String? {
guard let raw else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
let pieces = trimmed.split(separator: ":", omittingEmptySubsequences: false)
guard pieces.count == 2,
String(pieces[0]).lowercased() == "surface",
let ordinal = Int(String(pieces[1])) else {
return trimmed
}
return "tab:\(ordinal)"
}
private func formatHandle(_ payload: [String: Any], kind: String, idFormat: CLIIDFormat) -> String? {
let id = payload["\(kind)_id"] as? String
let ref = payload["\(kind)_ref"] as? String
switch idFormat {
case .refs:
return ref ?? id
case .uuids:
return id ?? ref
case .both:
if let ref, let id {
return "\(ref) (\(id))"
}
return ref ?? id
}
}
private func formatTabHandle(_ payload: [String: Any], idFormat: CLIIDFormat) -> String? {
let id = (payload["tab_id"] as? String) ?? (payload["surface_id"] as? String)
let refRaw = (payload["tab_ref"] as? String) ?? (payload["surface_ref"] as? String)
let ref = displayTabHandle(refRaw)
switch idFormat {
case .refs:
return ref ?? id
case .uuids:
return id ?? ref
case .both:
if let ref, let id {
return "\(ref) (\(id))"
}
return ref ?? id
}
}
private func formatCreatedTabHandle(_ payload: [String: Any], idFormat: CLIIDFormat) -> String? {
let id = (payload["created_tab_id"] as? String) ?? (payload["created_surface_id"] as? String)
let refRaw = (payload["created_tab_ref"] as? String) ?? (payload["created_surface_ref"] as? String)
let ref = displayTabHandle(refRaw)
switch idFormat {
case .refs:
return ref ?? id
case .uuids:
return id ?? ref
case .both:
if let ref, let id {
return "\(ref) (\(id))"
}
return ref ?? id
}
}
private func printV2Payload(
_ payload: [String: Any],
jsonOutput: Bool,
idFormat: CLIIDFormat,
fallbackText: String
) {
if jsonOutput {
print(jsonString(formatIDs(payload, mode: idFormat)))
} else {
print(fallbackText)
}
}
private func runMoveSurface(
commandArgs: [String],
client: SocketClient,
jsonOutput: Bool,
idFormat: CLIIDFormat
) throws {
let surfaceRaw = optionValue(commandArgs, name: "--surface") ?? commandArgs.first
guard let surfaceRaw else {
throw CLIError(message: "move-surface requires --surface <id|ref|index>")
}
let workspaceRaw = optionValue(commandArgs, name: "--workspace")
let windowRaw = optionValue(commandArgs, name: "--window")
let paneRaw = optionValue(commandArgs, name: "--pane")
let beforeRaw = optionValue(commandArgs, name: "--before") ?? optionValue(commandArgs, name: "--before-surface")
let afterRaw = optionValue(commandArgs, name: "--after") ?? optionValue(commandArgs, name: "--after-surface")
let windowHandle = try normalizeWindowHandle(windowRaw, client: client)
let workspaceHandle = try normalizeWorkspaceHandle(workspaceRaw, client: client, windowHandle: windowHandle)
let surfaceHandle = try normalizeSurfaceHandle(surfaceRaw, client: client, workspaceHandle: workspaceHandle, allowFocused: false)
let paneHandle = try normalizePaneHandle(paneRaw, client: client, workspaceHandle: workspaceHandle)
let beforeHandle = try normalizeSurfaceHandle(beforeRaw, client: client, workspaceHandle: workspaceHandle)
let afterHandle = try normalizeSurfaceHandle(afterRaw, client: client, workspaceHandle: workspaceHandle)
var params: [String: Any] = [:]
if let surfaceHandle { params["surface_id"] = surfaceHandle }
if let paneHandle { params["pane_id"] = paneHandle }
if let workspaceHandle { params["workspace_id"] = workspaceHandle }
if let windowHandle { params["window_id"] = windowHandle }
if let beforeHandle { params["before_surface_id"] = beforeHandle }
if let afterHandle { params["after_surface_id"] = afterHandle }
if let indexRaw = optionValue(commandArgs, name: "--index") {
guard let index = Int(indexRaw) else {
throw CLIError(message: "--index must be an integer")
}
params["index"] = index
}
if let focusRaw = optionValue(commandArgs, name: "--focus") {
guard let focus = parseBoolString(focusRaw) else {
throw CLIError(message: "--focus must be true|false")
}
params["focus"] = focus
}
let payload = try client.sendV2(method: "surface.move", params: params)
let summary = "OK surface=\(formatHandle(payload, kind: "surface", idFormat: idFormat) ?? "unknown") pane=\(formatHandle(payload, kind: "pane", idFormat: idFormat) ?? "unknown") workspace=\(formatHandle(payload, kind: "workspace", idFormat: idFormat) ?? "unknown") window=\(formatHandle(payload, kind: "window", idFormat: idFormat) ?? "unknown")"
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: summary)
}
private func runReorderSurface(
commandArgs: [String],
client: SocketClient,
jsonOutput: Bool,
idFormat: CLIIDFormat
) throws {
let surfaceRaw = optionValue(commandArgs, name: "--surface") ?? commandArgs.first
guard let surfaceRaw else {
throw CLIError(message: "reorder-surface requires --surface <id|ref|index>")
}
let workspaceRaw = optionValue(commandArgs, name: "--workspace")
let workspaceHandle = try normalizeWorkspaceHandle(workspaceRaw, client: client)
let surfaceHandle = try normalizeSurfaceHandle(surfaceRaw, client: client, workspaceHandle: workspaceHandle)
let beforeRaw = optionValue(commandArgs, name: "--before") ?? optionValue(commandArgs, name: "--before-surface")
let afterRaw = optionValue(commandArgs, name: "--after") ?? optionValue(commandArgs, name: "--after-surface")
let beforeHandle = try normalizeSurfaceHandle(beforeRaw, client: client, workspaceHandle: workspaceHandle)
let afterHandle = try normalizeSurfaceHandle(afterRaw, client: client, workspaceHandle: workspaceHandle)
var params: [String: Any] = [:]
if let surfaceHandle { params["surface_id"] = surfaceHandle }
if let beforeHandle { params["before_surface_id"] = beforeHandle }
if let afterHandle { params["after_surface_id"] = afterHandle }
if let indexRaw = optionValue(commandArgs, name: "--index") {
guard let index = Int(indexRaw) else {
throw CLIError(message: "--index must be an integer")
}
params["index"] = index
}
let payload = try client.sendV2(method: "surface.reorder", params: params)
let summary = "OK surface=\(formatHandle(payload, kind: "surface", idFormat: idFormat) ?? "unknown") pane=\(formatHandle(payload, kind: "pane", idFormat: idFormat) ?? "unknown") workspace=\(formatHandle(payload, kind: "workspace", idFormat: idFormat) ?? "unknown")"
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: summary)
}
private func runReorderWorkspace(
commandArgs: [String],
client: SocketClient,
jsonOutput: Bool,
idFormat: CLIIDFormat
) throws {
let workspaceRaw = optionValue(commandArgs, name: "--workspace") ?? commandArgs.first
guard let workspaceRaw else {
throw CLIError(message: "reorder-workspace requires --workspace <id|ref|index>")
}
let windowRaw = optionValue(commandArgs, name: "--window")
let windowHandle = try normalizeWindowHandle(windowRaw, client: client)
let workspaceHandle = try normalizeWorkspaceHandle(workspaceRaw, client: client, windowHandle: windowHandle)
let beforeRaw = optionValue(commandArgs, name: "--before") ?? optionValue(commandArgs, name: "--before-workspace")
let afterRaw = optionValue(commandArgs, name: "--after") ?? optionValue(commandArgs, name: "--after-workspace")
let beforeHandle = try normalizeWorkspaceHandle(beforeRaw, client: client, windowHandle: windowHandle)
let afterHandle = try normalizeWorkspaceHandle(afterRaw, client: client, windowHandle: windowHandle)
var params: [String: Any] = [:]
if let workspaceHandle { params["workspace_id"] = workspaceHandle }
if let beforeHandle { params["before_workspace_id"] = beforeHandle }
if let afterHandle { params["after_workspace_id"] = afterHandle }
if let indexRaw = optionValue(commandArgs, name: "--index") {
guard let index = Int(indexRaw) else {
throw CLIError(message: "--index must be an integer")
}
params["index"] = index
}
if let windowHandle {
params["window_id"] = windowHandle
}
let payload = try client.sendV2(method: "workspace.reorder", params: params)
let summary = "OK workspace=\(formatHandle(payload, kind: "workspace", idFormat: idFormat) ?? "unknown") window=\(formatHandle(payload, kind: "window", idFormat: idFormat) ?? "unknown") index=\(payload["index"] ?? "?")"
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: summary)
}
private func runWorkspaceAction(
commandArgs: [String],
client: SocketClient,
jsonOutput: Bool,
idFormat: CLIIDFormat,
windowOverride: String?
) throws {
let (workspaceOpt, rem0) = parseOption(commandArgs, name: "--workspace")
let (actionOpt, rem1) = parseOption(rem0, name: "--action")
let (titleOpt, rem2) = parseOption(rem1, name: "--title")
var positional = rem2
let actionRaw: String
if let actionOpt {
actionRaw = actionOpt
} else if let first = positional.first {
actionRaw = first
positional.removeFirst()
} else {
throw CLIError(message: "workspace-action requires --action <name>")
}
if let unknown = positional.first(where: { $0.hasPrefix("--") }) {
throw CLIError(message: "workspace-action: unknown flag '\(unknown)'")
}
let action = actionRaw.lowercased().replacingOccurrences(of: "-", with: "_")
let workspaceArg = workspaceOpt ?? (windowOverride == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let workspaceId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true)
let inferredTitle = positional.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
let title = (titleOpt ?? (inferredTitle.isEmpty ? nil : inferredTitle))?.trimmingCharacters(in: .whitespacesAndNewlines)
if action == "rename", (title?.isEmpty ?? true) {
throw CLIError(message: "workspace-action rename requires --title <text> (or a trailing title)")
}
var params: [String: Any] = ["action": action]
if let workspaceId {
params["workspace_id"] = workspaceId
}
if let title, !title.isEmpty {
params["title"] = title
}
let payload = try client.sendV2(method: "workspace.action", params: params)
var summaryParts = ["OK", "action=\(action)"]
if let workspaceHandle = formatHandle(payload, kind: "workspace", idFormat: idFormat) {
summaryParts.append("workspace=\(workspaceHandle)")
}
if let windowHandle = formatHandle(payload, kind: "window", idFormat: idFormat) {
summaryParts.append("window=\(windowHandle)")
}
if let closed = payload["closed"] {
summaryParts.append("closed=\(closed)")
}
if let index = payload["index"] {
summaryParts.append("index=\(index)")
}
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: summaryParts.joined(separator: " "))
}
private func runTabAction(
commandArgs: [String],
client: SocketClient,
jsonOutput: Bool,
idFormat: CLIIDFormat,
windowOverride: String?
) throws {
let (workspaceOpt, rem0) = parseOption(commandArgs, name: "--workspace")
let (tabOpt, rem1) = parseOption(rem0, name: "--tab")
let (surfaceOpt, rem2) = parseOption(rem1, name: "--surface")
let (actionOpt, rem3) = parseOption(rem2, name: "--action")
let (titleOpt, rem4) = parseOption(rem3, name: "--title")
let (urlOpt, rem5) = parseOption(rem4, name: "--url")
var positional = rem5
let actionRaw: String
if let actionOpt {
actionRaw = actionOpt
} else if let first = positional.first {
actionRaw = first
positional.removeFirst()
} else {
throw CLIError(message: "tab-action requires --action <name>")
}
if let unknown = positional.first(where: { $0.hasPrefix("--") }) {
throw CLIError(message: "tab-action: unknown flag '\(unknown)'")
}
let action = actionRaw.lowercased().replacingOccurrences(of: "-", with: "_")
let workspaceArg = workspaceOpt ?? (windowOverride == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let tabArg = tabOpt
?? surfaceOpt
?? (workspaceOpt == nil && windowOverride == nil
? (ProcessInfo.processInfo.environment["CMUX_TAB_ID"] ?? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"])
: nil)
let workspaceId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true)
// If a workspace is explicitly targeted and no tab/surface is provided, let server-side
// tab.action resolve that workspace's focused tab instead of using global focus.
let allowFocusedFallback = (workspaceId == nil)
let surfaceId = try normalizeTabHandle(
tabArg,
client: client,
workspaceHandle: workspaceId,
allowFocused: allowFocusedFallback
)
let inferredTitle = positional.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
let title = (titleOpt ?? (inferredTitle.isEmpty ? nil : inferredTitle))?.trimmingCharacters(in: .whitespacesAndNewlines)
if action == "rename", (title?.isEmpty ?? true) {
throw CLIError(message: "tab-action rename requires --title <text> (or a trailing title)")
}
var params: [String: Any] = ["action": action]
if let workspaceId {
params["workspace_id"] = workspaceId
}
if let surfaceId {
params["surface_id"] = surfaceId
}
if let title, !title.isEmpty {
params["title"] = title
}
if let urlOpt, !urlOpt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
params["url"] = urlOpt.trimmingCharacters(in: .whitespacesAndNewlines)
}
let payload = try client.sendV2(method: "tab.action", params: params)
var summaryParts = ["OK", "action=\(action)"]
if let tabHandle = formatTabHandle(payload, idFormat: idFormat) {
summaryParts.append("tab=\(tabHandle)")
}
if let workspaceHandle = formatHandle(payload, kind: "workspace", idFormat: idFormat) {
summaryParts.append("workspace=\(workspaceHandle)")
}
if let closed = payload["closed"] {
summaryParts.append("closed=\(closed)")
}
if let created = formatCreatedTabHandle(payload, idFormat: idFormat) {
summaryParts.append("created=\(created)")
}
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: summaryParts.joined(separator: " "))
}
private func runRenameTab(
commandArgs: [String],
client: SocketClient,
jsonOutput: Bool,
idFormat: CLIIDFormat,
windowOverride: String?
) throws {
let (workspaceOpt, rem0) = parseOption(commandArgs, name: "--workspace")
let (tabOpt, rem1) = parseOption(rem0, name: "--tab")
let (surfaceOpt, rem2) = parseOption(rem1, name: "--surface")
let (titleOpt, rem3) = parseOption(rem2, name: "--title")
if rem3.contains("--action") {
throw CLIError(message: "rename-tab does not accept --action (it always performs rename)")
}
if let unknown = rem3.first(where: { $0.hasPrefix("--") && $0 != "--" }) {
throw CLIError(message: "rename-tab: unknown flag '\(unknown)'")
}
let inferredTitle = rem3
.dropFirst(rem3.first == "--" ? 1 : 0)
.joined(separator: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
let title = (titleOpt ?? (inferredTitle.isEmpty ? nil : inferredTitle))?
.trimmingCharacters(in: .whitespacesAndNewlines)
guard let title, !title.isEmpty else {
throw CLIError(message: "rename-tab requires a title")
}
var forwarded: [String] = ["--action", "rename", "--title", title]
if let workspaceOpt {
forwarded += ["--workspace", workspaceOpt]
}
if let tabOpt {
forwarded += ["--tab", tabOpt]
} else if let surfaceOpt {
forwarded += ["--surface", surfaceOpt]
}
try runTabAction(
commandArgs: forwarded,
client: client,
jsonOutput: jsonOutput,
idFormat: idFormat,
windowOverride: windowOverride
)
}
private struct SSHCommandOptions {
let destination: String
let port: Int?
let identityFile: String?
let workspaceName: String?
let sshOptions: [String]
let extraArguments: [String]
let localSocketPath: String
let remoteRelayPort: Int
}
private struct RemoteDaemonManifest: Decodable {
struct Entry: Decodable {
let goOS: String
let goArch: String
let assetName: String
let downloadURL: String
let sha256: String
}
let schemaVersion: Int
let appVersion: String
let releaseTag: String
let releaseURL: String
let checksumsAssetName: String
let checksumsURL: String
let entries: [Entry]
func entry(goOS: String, goArch: String) -> Entry? {
entries.first { $0.goOS == goOS && $0.goArch == goArch }
}
}
private func generateRemoteRelayPort() -> Int {
// Random port in the ephemeral range (49152-65535)
Int.random(in: 49152...65535)
}
private func randomHex(byteCount: Int) throws -> String {
var bytes = [UInt8](repeating: 0, count: byteCount)
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
guard status == errSecSuccess else {
throw CLIError(message: "failed to generate SSH relay credential")
}
return bytes.map { String(format: "%02x", $0) }.joined()
}
private func runSSH(
commandArgs: [String],
client: SocketClient,
jsonOutput: Bool,
idFormat: CLIIDFormat
) throws {
// Use the socket path from this invocation (supports --socket overrides).
let localSocketPath = client.socketPath
let remoteRelayPort = generateRemoteRelayPort()
let relayID = UUID().uuidString.lowercased()
let relayToken = try randomHex(byteCount: 32)
let sshOptions = try parseSSHCommandOptions(commandArgs, localSocketPath: localSocketPath, remoteRelayPort: remoteRelayPort)
prepareSSHTerminfoIfNeeded(sshOptions)
let sshCommand = buildSSHCommandText(sshOptions)
let shellFeaturesValue = scopedGhosttyShellFeaturesValue()
let sshStartupCommand = buildSSHStartupCommand(
sshCommand: sshCommand,
shellFeatures: shellFeaturesValue,
remoteRelayPort: sshOptions.remoteRelayPort
)
let remoteSSHOptions = effectiveSSHOptions(
sshOptions.sshOptions,
remoteRelayPort: sshOptions.remoteRelayPort
)
cliDebugLog(
"cli.ssh.start target=\(sshOptions.destination) port=\(sshOptions.port.map(String.init) ?? "nil") " +
"relayPort=\(sshOptions.remoteRelayPort) localSocket=\(sshOptions.localSocketPath) " +
"controlPath=\(sshOptionValue(named: "ControlPath", in: remoteSSHOptions) ?? "nil") " +
"workspaceName=\(sshOptions.workspaceName?.replacingOccurrences(of: " ", with: "_") ?? "nil") " +
"extraArgs=\(sshOptions.extraArguments.count)"
)
let workspaceCreateParams: [String: Any] = [
"initial_command": sshStartupCommand,
]
let workspaceCreate = try client.sendV2(method: "workspace.create", params: workspaceCreateParams)
guard let workspaceId = workspaceCreate["workspace_id"] as? String, !workspaceId.isEmpty else {
throw CLIError(message: "workspace.create did not return workspace_id")
}
let workspaceWindowId = (workspaceCreate["window_id"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
cliDebugLog(
"cli.ssh.workspace.created workspace=\(String(workspaceId.prefix(8))) " +
"window=\(workspaceWindowId.map { String($0.prefix(8)) } ?? "nil")"
)
let configuredPayload: [String: Any]
do {
if let workspaceName = sshOptions.workspaceName?.trimmingCharacters(in: .whitespacesAndNewlines),
!workspaceName.isEmpty {
_ = try client.sendV2(method: "workspace.rename", params: [
"workspace_id": workspaceId,
"title": workspaceName,
])
}
var configureParams: [String: Any] = [
"workspace_id": workspaceId,
"destination": sshOptions.destination,
"auto_connect": true,
]
if let port = sshOptions.port {
configureParams["port"] = port
}
if let identityFile = normalizedSSHIdentityPath(sshOptions.identityFile) {
configureParams["identity_file"] = identityFile
}
if !remoteSSHOptions.isEmpty {
configureParams["ssh_options"] = remoteSSHOptions
}
if sshOptions.remoteRelayPort > 0 {
configureParams["relay_port"] = sshOptions.remoteRelayPort
configureParams["relay_id"] = relayID
configureParams["relay_token"] = relayToken
configureParams["local_socket_path"] = sshOptions.localSocketPath
}
configureParams["terminal_startup_command"] = sshStartupCommand
cliDebugLog(
"cli.ssh.remote.configure workspace=\(String(workspaceId.prefix(8))) " +
"target=\(sshOptions.destination) relayPort=\(sshOptions.remoteRelayPort) " +
"controlPath=\(sshOptionValue(named: "ControlPath", in: remoteSSHOptions) ?? "nil") " +
"sshOptions=\(remoteSSHOptions.joined(separator: "|"))"
)
configuredPayload = try client.sendV2(method: "workspace.remote.configure", params: configureParams)
var selectParams: [String: Any] = ["workspace_id": workspaceId]
if let workspaceWindowId, !workspaceWindowId.isEmpty {
selectParams["window_id"] = workspaceWindowId
}
_ = try client.sendV2(method: "workspace.select", params: selectParams)
let remoteState = ((configuredPayload["remote"] as? [String: Any])?["state"] as? String) ?? "unknown"
cliDebugLog(
"cli.ssh.remote.configure.ok workspace=\(String(workspaceId.prefix(8))) state=\(remoteState)"
)
} catch {
cliDebugLog(
"cli.ssh.remote.configure.error workspace=\(String(workspaceId.prefix(8))) error=\(String(describing: error))"
)
do {
_ = try client.sendV2(method: "workspace.close", params: ["workspace_id": workspaceId])
} catch {
let warning = "Warning: failed to rollback workspace \(workspaceId): \(error)\n"
FileHandle.standardError.write(Data(warning.utf8))
}
throw error
}
var payload = configuredPayload
payload["ssh_command"] = sshCommand
payload["ssh_startup_command"] = sshStartupCommand
payload["ssh_env_overrides"] = [
"GHOSTTY_SHELL_FEATURES": shellFeaturesValue,
]
payload["remote_relay_port"] = remoteRelayPort
if jsonOutput {
print(jsonString(formatIDs(payload, mode: idFormat)))
} else {
let workspaceHandle = formatHandle(payload, kind: "workspace", idFormat: idFormat) ?? workspaceId
let remote = payload["remote"] as? [String: Any]
let state = (remote?["state"] as? String) ?? "unknown"
print("OK workspace=\(workspaceHandle) target=\(sshOptions.destination) state=\(state)")
}
}
private func parseSSHCommandOptions(_ commandArgs: [String], localSocketPath: String = "", remoteRelayPort: Int = 0) throws -> SSHCommandOptions {
var destination: String?
var port: Int?
var identityFile: String?
var workspaceName: String?
var sshOptions: [String] = []
var extraArguments: [String] = []
var passthrough = false
var index = 0
while index < commandArgs.count {
let arg = commandArgs[index]
if passthrough {
extraArguments.append(arg)
index += 1
continue
}
switch arg {
case "--":
passthrough = true
index += 1
case "--port":
guard index + 1 < commandArgs.count else {
throw CLIError(message: "ssh: --port requires a value")
}
guard let parsed = Int(commandArgs[index + 1]), parsed > 0, parsed <= 65535 else {
throw CLIError(message: "ssh: --port must be 1-65535")
}
port = parsed
index += 2
case "--identity":
guard index + 1 < commandArgs.count else {
throw CLIError(message: "ssh: --identity requires a path")
}
identityFile = commandArgs[index + 1]
index += 2
case "--name":
guard index + 1 < commandArgs.count else {
throw CLIError(message: "ssh: --name requires a workspace title")
}
workspaceName = commandArgs[index + 1]
index += 2
case "--ssh-option":
guard index + 1 < commandArgs.count else {
throw CLIError(message: "ssh: --ssh-option requires a value")
}
let value = commandArgs[index + 1].trimmingCharacters(in: .whitespacesAndNewlines)
if !value.isEmpty {
sshOptions.append(value)
}
index += 2
default:
if arg.hasPrefix("--") {
throw CLIError(message: "ssh: unknown flag '\(arg)'")
}
if destination == nil {
if arg.hasPrefix("-") {
throw CLIError(
message: "ssh: destination must be <user@host>. Use --port/--identity/--ssh-option for SSH flags and `--` for remote command args."
)
}
destination = arg
} else {
extraArguments.append(arg)
}
index += 1
}
}
guard let destination else {
throw CLIError(message: "ssh requires a destination (example: cmux ssh user@host)")
}
return SSHCommandOptions(
destination: destination,
port: port,
identityFile: identityFile,
workspaceName: workspaceName,
sshOptions: sshOptions,
extraArguments: extraArguments,
localSocketPath: localSocketPath,
remoteRelayPort: remoteRelayPort
)
}
private func buildSSHCommandText(_ options: SSHCommandOptions) -> String {
var parts = baseSSHArguments(options)
let shellFeaturesValue = scopedGhosttyShellFeaturesValue()
if options.extraArguments.isEmpty {
// No explicit remote command provided. Use RemoteCommand to bootstrap
// the relay wrapper and then hand off to an interactive shell.
if !hasSSHOptionKey(options.sshOptions, key: "RequestTTY") {
parts.append("-tt")
}
if !hasSSHOptionKey(options.sshOptions, key: "RemoteCommand") {
parts += [
"-o",
"RemoteCommand=\(buildInteractiveRemoteShellCommand(remoteRelayPort: options.remoteRelayPort, shellFeatures: shellFeaturesValue))",
]
}
parts.append(options.destination)
} else {
parts.append(options.destination)
parts.append(contentsOf: options.extraArguments)
}
return parts.map(shellQuote).joined(separator: " ")
}
private func effectiveSSHOptions(_ options: [String], remoteRelayPort: Int? = nil) -> [String] {
var merged = sshOptionsWithControlSocketDefaults(options, remoteRelayPort: remoteRelayPort)
if !hasSSHOptionKey(merged, key: "StrictHostKeyChecking") {
merged.append("StrictHostKeyChecking=accept-new")
}
return merged
}
private func buildInteractiveRemoteShellCommand(remoteRelayPort: Int, shellFeatures: String) -> String {
let relayExport = remoteRelayPort > 0
? "export CMUX_SOCKET_PATH=127.0.0.1:\(remoteRelayPort)"
: nil
let remoteEnvExports = interactiveRemoteShellExports(shellFeatures: shellFeatures)
let innerCommand = [
remoteEnvExports,
"export PATH=\"$HOME/.cmux/bin:$PATH\"",
relayExport,
"exec \"${SHELL:-/bin/zsh}\" -i",
]
.compactMap { $0 }
.joined(separator: "; ")
let outerCommand = [
"CMUX_LOGIN_SHELL=\"${SHELL:-/bin/zsh}\"",
"case \"${CMUX_LOGIN_SHELL##*/}\" in",
" zsh|bash)",
" exec \"$CMUX_LOGIN_SHELL\" -lc \(shellQuote(innerCommand))",
" ;;",
" *)",
remoteEnvExports,
" export PATH=\"$HOME/.cmux/bin:$PATH\"",
relayExport,
" exec \"$CMUX_LOGIN_SHELL\" -i",
" ;;",
"esac",
]
.compactMap { $0 }
.joined(separator: "; ")
return outerCommand
}
private func interactiveRemoteShellExports(shellFeatures: String) -> String {
let environment = ProcessInfo.processInfo.environment
let term = Self.normalizedEnvValue(environment["TERM"]) ?? "xterm-ghostty"
let colorTerm = Self.normalizedEnvValue(environment["COLORTERM"]) ?? "truecolor"
let termProgram = Self.normalizedEnvValue(environment["TERM_PROGRAM"]) ?? "ghostty"
let termProgramVersion = Self.normalizedEnvValue(environment["TERM_PROGRAM_VERSION"])
?? (Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String)
?? ""
let trimmedShellFeatures = shellFeatures.trimmingCharacters(in: .whitespacesAndNewlines)
var exports: [String] = [
"export TERM=\(shellQuote(term))",
"export COLORTERM=\(shellQuote(colorTerm))",
"export TERM_PROGRAM=\(shellQuote(termProgram))",
]
if !termProgramVersion.isEmpty {
exports.append("export TERM_PROGRAM_VERSION=\(shellQuote(termProgramVersion))")
}
if !trimmedShellFeatures.isEmpty {
exports.append("export GHOSTTY_SHELL_FEATURES=\(shellQuote(trimmedShellFeatures))")
}
return exports.joined(separator: "; ")
}
private func baseSSHArguments(_ options: SSHCommandOptions) -> [String] {
let effectiveSSHOptions = effectiveSSHOptions(
options.sshOptions,
remoteRelayPort: options.remoteRelayPort
)
var parts: [String] = ["ssh"]
if !hasSSHOptionKey(effectiveSSHOptions, key: "SetEnv") {
parts += ["-o", "SetEnv COLORTERM=truecolor"]
}
if !hasSSHOptionKey(effectiveSSHOptions, key: "SendEnv") {
parts += ["-o", "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION"]
}
if let port = options.port {
parts += ["-p", String(port)]
}
if let identityFile = normalizedSSHIdentityPath(options.identityFile) {
parts += ["-i", identityFile]
}
for option in effectiveSSHOptions {
parts += ["-o", option]
}
return parts
}
private func prepareSSHTerminfoIfNeeded(_ options: SSHCommandOptions) {
guard let terminfoSource = localXtermGhosttyTerminfoSource(), !terminfoSource.isEmpty else { return }
var args = baseSSHArguments(options)
args += ["-o", "BatchMode=yes", "-o", "ControlMaster=no", options.destination]
let installScript = """
infocmp xterm-ghostty >/dev/null 2>&1 && exit 0
command -v tic >/dev/null 2>&1 || exit 1
mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0
exit 1
"""
args.append(installScript)
_ = runProcess(
executablePath: "/usr/bin/ssh",
arguments: Array(args.dropFirst()),
stdinText: terminfoSource
)
}
private func localXtermGhosttyTerminfoSource() -> String? {
let result = runProcess(
executablePath: "/usr/bin/infocmp",
arguments: ["-0", "-x", "xterm-ghostty"]
)
guard result.status == 0 else { return nil }
let output = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
return output.isEmpty ? nil : output
}
private func sshOptionsWithControlSocketDefaults(
_ options: [String],
remoteRelayPort: Int? = nil
) -> [String] {
var merged: [String] = []
for option in options {
let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { continue }
merged.append(trimmed)
}
if !hasSSHOptionKey(merged, key: "ControlMaster") {
merged.append("ControlMaster=auto")
}
if !hasSSHOptionKey(merged, key: "ControlPersist") {
merged.append("ControlPersist=600")
}
if !hasSSHOptionKey(merged, key: "ControlPath") {
merged.append("ControlPath=\(defaultSSHControlPathTemplate(remoteRelayPort: remoteRelayPort))")
}
return merged
}
private func scopedGhosttyShellFeaturesValue() -> String {
let rawExisting = ProcessInfo.processInfo.environment["GHOSTTY_SHELL_FEATURES"] ?? ""
var seen: Set<String> = []
var merged: [String] = []
for token in rawExisting.split(separator: ",") {
let feature = token.trimmingCharacters(in: .whitespacesAndNewlines)
guard !feature.isEmpty else { continue }
if seen.insert(feature).inserted {
merged.append(feature)
}
}
for required in ["ssh-env", "ssh-terminfo"] {
if seen.insert(required).inserted {
merged.append(required)
}
}
return merged.joined(separator: ",")
}
private func buildSSHStartupCommand(sshCommand: String, shellFeatures: String, remoteRelayPort: Int) -> String {
let trimmedFeatures = shellFeatures.trimmingCharacters(in: .whitespacesAndNewlines)
let shellFeaturesBootstrap: String = trimmedFeatures.isEmpty
? ""
: "export GHOSTTY_SHELL_FEATURES=\(shellQuote(trimmedFeatures))"
let lifecycleCleanup = buildSSHSessionEndShellCommand(remoteRelayPort: remoteRelayPort)
let script = [
shellFeaturesBootstrap,
"CMUX_SSH_SESSION_ENDED=0",
"cmux_ssh_session_end() { if [ \"${CMUX_SSH_SESSION_ENDED:-0}\" = 1 ]; then return; fi; CMUX_SSH_SESSION_ENDED=1; \(lifecycleCleanup); }",
"trap 'cmux_ssh_session_end' EXIT HUP INT TERM",
"command \(sshCommand)",
"trap - EXIT HUP INT TERM",
"cmux_ssh_session_end",
"exec ${SHELL:-/bin/zsh} -l",
]
.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
.joined(separator: "\n")
return "/bin/zsh -ilc \(shellQuote(script))"
}
private func buildSSHSessionEndShellCommand(remoteRelayPort: Int) -> String {
[
"if [ -n \"${CMUX_BUNDLED_CLI_PATH:-}\" ]",
"&& [ -x \"${CMUX_BUNDLED_CLI_PATH}\" ]",
"&& [ -n \"${CMUX_SOCKET_PATH:-}\" ]",
"&& [ -n \"${CMUX_WORKSPACE_ID:-}\" ]",
"&& [ -n \"${CMUX_SURFACE_ID:-}\" ]; then",
"\"${CMUX_BUNDLED_CLI_PATH}\" --socket \"${CMUX_SOCKET_PATH}\" ssh-session-end --relay-port \(remoteRelayPort) --workspace \"${CMUX_WORKSPACE_ID}\" --surface \"${CMUX_SURFACE_ID}\" >/dev/null 2>&1 || true;",
"elif command -v cmux >/dev/null 2>&1",
"&& [ -n \"${CMUX_WORKSPACE_ID:-}\" ]",
"&& [ -n \"${CMUX_SURFACE_ID:-}\" ]; then",
"cmux ssh-session-end --relay-port \(remoteRelayPort) --workspace \"${CMUX_WORKSPACE_ID}\" --surface \"${CMUX_SURFACE_ID}\" >/dev/null 2>&1 || true;",
"fi",
].joined(separator: " ")
}
private func runSSHSessionEnd(commandArgs: [String], client: SocketClient) throws {
guard let relayPortRaw = optionValue(commandArgs, name: "--relay-port"),
let relayPort = Int(relayPortRaw),
relayPort > 0 else {
throw CLIError(message: "ssh-session-end requires --relay-port <port>")
}
let workspaceRaw = optionValue(commandArgs, name: "--workspace") ?? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"]
let surfaceRaw = optionValue(commandArgs, name: "--surface") ?? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"]
guard let workspaceRaw,
let workspaceId = try normalizeWorkspaceHandle(workspaceRaw, client: client),
!workspaceId.isEmpty else {
throw CLIError(message: "ssh-session-end requires --workspace or CMUX_WORKSPACE_ID")
}
guard let surfaceRaw,
let surfaceId = try normalizeSurfaceHandle(surfaceRaw, client: client, workspaceHandle: workspaceId),
!surfaceId.isEmpty else {
throw CLIError(message: "ssh-session-end requires --surface or CMUX_SURFACE_ID")
}
_ = try client.sendV2(method: "workspace.remote.terminal_session_end", params: [
"workspace_id": workspaceId,
"surface_id": surfaceId,
"relay_port": relayPort,
])
}
private func runRemoteDaemonStatus(commandArgs: [String], jsonOutput: Bool) throws {
let requestedOS = optionValue(commandArgs, name: "--os")?.trimmingCharacters(in: .whitespacesAndNewlines)
let requestedArch = optionValue(commandArgs, name: "--arch")?.trimmingCharacters(in: .whitespacesAndNewlines)
let info = resolvedVersionInfo()
let manifest = remoteDaemonManifest()
let platform = defaultRemoteDaemonPlatform(requestedOS: requestedOS, requestedArch: requestedArch)
let cacheURL = remoteDaemonCacheURL(version: manifest?.appVersion ?? remoteDaemonVersionString(from: info), goOS: platform.goOS, goArch: platform.goArch)
let cacheExists = FileManager.default.fileExists(atPath: cacheURL.path)
let cacheSHA = cacheExists ? try? sha256Hex(forFile: cacheURL) : nil
let entry = manifest?.entry(goOS: platform.goOS, goArch: platform.goArch)
let cacheVerified = (entry != nil && cacheSHA?.lowercased() == entry?.sha256.lowercased())
let releaseTag = manifest?.releaseTag ?? "unknown"
let assetName = entry?.assetName ?? "unknown"
let downloadURL = entry?.downloadURL ?? "unknown"
let checksumsAssetName = manifest?.checksumsAssetName ?? "unknown"
let checksumsURL = manifest?.checksumsURL ?? "unknown"
let downloadCommand = "gh release download \(releaseTag) --repo manaflow-ai/cmux --pattern \(assetName)"
let downloadChecksumsCommand = "gh release download \(releaseTag) --repo manaflow-ai/cmux --pattern \(checksumsAssetName)"
let checksumVerifyCommand = "shasum -a 256 -c \(checksumsAssetName) --ignore-missing"
let signerWorkflow = releaseTag == "nightly"
? "manaflow-ai/cmux/.github/workflows/nightly.yml"
: "manaflow-ai/cmux/.github/workflows/release.yml"
let verifyCommand = "gh attestation verify ./\(assetName) --repo manaflow-ai/cmux --signer-workflow \(signerWorkflow)"
let payload: [String: Any] = [
"app_version": remoteDaemonVersionString(from: info),
"build": info["CFBundleVersion"] ?? NSNull(),
"commit": info["CMUXCommit"] ?? NSNull(),
"manifest_present": manifest != nil,
"release_tag": releaseTag,
"release_url": manifest?.releaseURL ?? NSNull(),
"target_goos": platform.goOS,
"target_goarch": platform.goArch,
"asset_name": assetName,
"download_url": downloadURL,
"checksums_asset_name": checksumsAssetName,
"checksums_url": checksumsURL,
"expected_sha256": entry?.sha256 ?? NSNull(),
"cache_path": cacheURL.path,
"cache_exists": cacheExists,
"cache_sha256": cacheSHA ?? NSNull(),
"cache_verified": cacheVerified,
"dev_local_build_fallback": ProcessInfo.processInfo.environment["CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD"] == "1",
"download_command": downloadCommand,
"download_checksums_command": downloadChecksumsCommand,
"checksum_verify_command": checksumVerifyCommand,
"attestation_verify_command": verifyCommand,
]
if jsonOutput {
print(jsonString(payload))
return
}
print("app version: \(payload["app_version"] as? String ?? "unknown")")
if let build = payload["build"] as? String {
print("build: \(build)")
}
if let commit = payload["commit"] as? String {
print("commit: \(commit)")
}
print("manifest: \(manifest != nil ? "present" : "missing")")
print("platform: \(platform.goOS)/\(platform.goArch)")
print("release: \(releaseTag)")
print("asset: \(assetName)")
print("download url: \(downloadURL)")
print("checksums asset: \(checksumsAssetName)")
print("checksums: \(checksumsURL)")
if let expectedSHA = entry?.sha256 {
print("expected sha256: \(expectedSHA)")
}
print("cache: \(cacheURL.path)")
print("cache exists: \(cacheExists ? "yes" : "no")")
if let cacheSHA {
print("cache sha256: \(cacheSHA)")
}
print("cache verified: \(cacheVerified ? "yes" : "no")")
print("download command: \(downloadCommand)")
print("download checksums: \(downloadChecksumsCommand)")
print("verify checksum: \(checksumVerifyCommand)")
print("attestation verify: \(verifyCommand)")
if manifest == nil {
print("note: this build has no embedded remote daemon manifest. Set CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 only for dev builds.")
}
}
private func defaultRemoteDaemonPlatform(requestedOS: String?, requestedArch: String?) -> (goOS: String, goArch: String) {
let normalizedOS = requestedOS?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
let normalizedArch = requestedArch?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
let goOS = (normalizedOS?.isEmpty == false ? normalizedOS! : hostGoOS())
let goArch = (normalizedArch?.isEmpty == false ? normalizedArch! : hostGoArch())
return (goOS, goArch)
}
private func hostGoOS() -> String {
#if os(macOS)
return "darwin"
#elseif os(Linux)
return "linux"
#else
return "unknown"
#endif
}
private func hostGoArch() -> String {
#if arch(arm64)
return "arm64"
#elseif arch(x86_64)
return "amd64"
#else
return "unknown"
#endif
}
private func remoteDaemonManifest() -> RemoteDaemonManifest? {
for plistURL in candidateInfoPlistURLs() {
guard let raw = NSDictionary(contentsOf: plistURL) as? [String: Any],
let rawManifest = raw["CMUXRemoteDaemonManifestJSON"] as? String,
let data = rawManifest.trimmingCharacters(in: .whitespacesAndNewlines).data(using: .utf8),
let manifest = try? JSONDecoder().decode(RemoteDaemonManifest.self, from: data) else {
continue
}
return manifest
}
return nil
}
private func remoteDaemonVersionString(from info: [String: String]) -> String {
info["CFBundleShortVersionString"] ?? "dev"
}
private func remoteDaemonCacheURL(version: String, goOS: String, goArch: String) -> URL {
let root: URL
do {
root = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
} catch {
return URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
.appendingPathComponent("cmux-remote-daemons", isDirectory: true)
.appendingPathComponent(version, isDirectory: true)
.appendingPathComponent("\(goOS)-\(goArch)", isDirectory: true)
.appendingPathComponent("cmuxd-remote", isDirectory: false)
}
return root
.appendingPathComponent("cmux", isDirectory: true)
.appendingPathComponent("remote-daemons", isDirectory: true)
.appendingPathComponent(version, isDirectory: true)
.appendingPathComponent("\(goOS)-\(goArch)", isDirectory: true)
.appendingPathComponent("cmuxd-remote", isDirectory: false)
}
private func sha256Hex(forFile url: URL) throws -> String {
let data = try Data(contentsOf: url)
let digest = SHA256.hash(data: data)
return digest.map { String(format: "%02x", $0) }.joined()
}
private func hasSSHOptionKey(_ options: [String], key: String) -> Bool {
let loweredKey = key.lowercased()
for option in options {
let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { continue }
let token = trimmed.split(whereSeparator: { $0 == "=" || $0.isWhitespace }).first.map(String.init)?.lowercased()
if token == loweredKey {
return true
}
}
return false
}
private func defaultSSHControlPathTemplate(remoteRelayPort: Int? = nil) -> String {
if let remoteRelayPort, remoteRelayPort > 0 {
return "/tmp/cmux-ssh-\(getuid())-\(remoteRelayPort)-%C"
}
return "/tmp/cmux-ssh-\(getuid())-%C"
}
private func normalizedSSHIdentityPath(_ rawPath: String?) -> String? {
guard let rawPath else { return nil }
let trimmed = rawPath.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if trimmed.hasPrefix("~") {
let expanded = (trimmed as NSString).expandingTildeInPath
if !expanded.isEmpty {
return expanded
}
}
return trimmed
}
private func shellQuote(_ value: String) -> String {
let safePattern = "^[A-Za-z0-9_@%+=:,./-]+$"
if value.range(of: safePattern, options: .regularExpression) != nil {
return value
}
return "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'"
}
private func sshOptionValue(named key: String, in options: [String]) -> String? {
let loweredKey = key.lowercased()
for option in options {
let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { continue }
let parts = trimmed.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false)
if parts.count == 2,
parts[0].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == loweredKey {
return parts[1].trimmingCharacters(in: .whitespacesAndNewlines)
}
}
return nil
}
private func cliDebugLog(_ message: @autoclosure () -> String) {
#if DEBUG
let trimmedExplicit = ProcessInfo.processInfo.environment["CMUX_DEBUG_LOG"]?
.trimmingCharacters(in: .whitespacesAndNewlines)
let path: String? = {
if let trimmedExplicit, !trimmedExplicit.isEmpty {
return trimmedExplicit
}
guard let marker = try? String(contentsOfFile: "/tmp/cmux-last-debug-log-path", encoding: .utf8) else {
return nil
}
let trimmedMarker = marker.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmedMarker.isEmpty ? nil : trimmedMarker
}()
guard let path else { return }
let timestamp = ISO8601DateFormatter().string(from: Date())
let line = "\(timestamp) [cmux-cli] \(message())\n"
guard let data = line.data(using: .utf8) else { return }
if !FileManager.default.fileExists(atPath: path) {
FileManager.default.createFile(atPath: path, contents: nil)
}
guard let handle = FileHandle(forWritingAtPath: path) else { return }
defer { try? handle.close() }
do {
try handle.seekToEnd()
try handle.write(contentsOf: data)
} catch {
return
}
#endif
}
private func runProcess(
executablePath: String,
arguments: [String],
stdinText: String? = nil
) -> (status: Int32, stdout: String, stderr: String) {
let process = Process()
process.executableURL = URL(fileURLWithPath: executablePath)
process.arguments = arguments
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
let stdinPipe: Pipe?
if stdinText != nil {
let pipe = Pipe()
process.standardInput = pipe
stdinPipe = pipe
} else {
stdinPipe = nil
}
do {
try process.run()
} catch {
return (1, "", String(describing: error))
}
if let stdinText, let stdinPipe {
if let data = stdinText.data(using: .utf8) {
stdinPipe.fileHandleForWriting.write(data)
}
stdinPipe.fileHandleForWriting.closeFile()
}
process.waitUntilExit()
let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
return (process.terminationStatus, stdout, stderr)
}
private func runBrowserCommand(
commandArgs: [String],
client: SocketClient,
jsonOutput: Bool,
idFormat: CLIIDFormat
) throws {
guard !commandArgs.isEmpty else {
throw CLIError(message: "browser requires a subcommand")
}
var effectiveJSONOutput = jsonOutput
var effectiveIDFormat = idFormat
var browserArgs = commandArgs
// Browser-skill examples often place output flags at the end of the command.
// Strip trailing display flags so they don't become part of a URL or selector.
while !browserArgs.isEmpty {
if browserArgs.last == "--json" {
effectiveJSONOutput = true
browserArgs.removeLast()
continue
}
if browserArgs.count >= 2,
browserArgs[browserArgs.count - 2] == "--id-format" {
let raw = browserArgs.last!
guard let parsed = try CLIIDFormat.parse(raw) else {
throw CLIError(message: "--id-format must be one of: refs, uuids, both")
}
effectiveIDFormat = parsed
browserArgs.removeLast(2)
continue
}
break
}
let (surfaceOpt, argsWithoutSurfaceFlag) = parseOption(browserArgs, name: "--surface")
var surfaceRaw = surfaceOpt
var args = argsWithoutSurfaceFlag
let verbsWithoutSurface: Set<String> = ["open", "open-split", "new", "identify"]
if surfaceRaw == nil, let first = args.first {
if !first.hasPrefix("-") && !verbsWithoutSurface.contains(first.lowercased()) {
surfaceRaw = first
args = Array(args.dropFirst())
}
}
guard let subcommandRaw = args.first else {
throw CLIError(message: "browser requires a subcommand")
}
let subcommand = subcommandRaw.lowercased()
let subArgs = Array(args.dropFirst())
func requireSurface() throws -> String {
guard let raw = surfaceRaw else {
throw CLIError(message: "browser \(subcommand) requires a surface handle (use: browser <surface> \(subcommand) ... or --surface)")
}
guard let resolved = try normalizeSurfaceHandle(raw, client: client) else {
throw CLIError(message: "Invalid surface handle")
}
return resolved
}
func output(_ payload: [String: Any], fallback: String) {
if effectiveJSONOutput {
print(jsonString(formatIDs(payload, mode: effectiveIDFormat)))
return
}
print(fallback)
if let snapshot = payload["post_action_snapshot"] as? String,
!snapshot.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
print(snapshot)
}
}
func displaySnapshotText(_ payload: [String: Any]) -> String {
let snapshotText = (payload["snapshot"] as? String) ?? "Empty page"
guard snapshotText.contains("\n- (empty)") else {
return snapshotText
}
let url = ((payload["url"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let readyState = ((payload["ready_state"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
var lines = [snapshotText]
if !url.isEmpty {
lines.append("url: \(url)")
}
if !readyState.isEmpty {
lines.append("ready_state: \(readyState)")
}
if url.isEmpty || url == "about:blank" {
lines.append("hint: run 'cmux browser <surface> get url' to verify navigation")
}
return lines.joined(separator: "\n")
}
func displayBrowserValue(_ value: Any) -> String {
if let dict = value as? [String: Any],
let type = dict["__cmux_t"] as? String,
type == "undefined" {
return "undefined"
}
if value is NSNull {
return "null"
}
if let string = value as? String {
return string
}
if let bool = value as? Bool {
return bool ? "true" : "false"
}
if let number = value as? NSNumber {
return number.stringValue
}
if JSONSerialization.isValidJSONObject(value),
let data = try? JSONSerialization.data(withJSONObject: value, options: [.prettyPrinted]),
let text = String(data: data, encoding: .utf8) {
return text
}
return String(describing: value)
}
func displayBrowserLogItems(_ value: Any?) -> String? {
guard let items = value as? [Any], !items.isEmpty else {
return nil
}
let lines = items.map { item -> String in
guard let dict = item as? [String: Any] else {
return displayBrowserValue(item)
}
let text = (dict["text"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let levelRaw = (dict["level"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let level = levelRaw.isEmpty ? "log" : levelRaw
if text.isEmpty {
if let message = (dict["message"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines),
!message.isEmpty {
return "[error] \(message)"
}
return displayBrowserValue(dict)
}
return "[\(level)] \(text)"
}
return lines.joined(separator: "\n")
}
func nonFlagArgs(_ values: [String]) -> [String] {
values.filter { !$0.hasPrefix("-") }
}
if subcommand == "identify" {
let surface = try normalizeSurfaceHandle(surfaceRaw, client: client, allowFocused: true)
var payload = try client.sendV2(method: "system.identify")
if let surface {
let urlPayload = try client.sendV2(method: "browser.url.get", params: ["surface_id": surface])
let titlePayload = try client.sendV2(method: "browser.get.title", params: ["surface_id": surface])
var browser: [String: Any] = [:]
browser["surface"] = surface
browser["url"] = urlPayload["url"] ?? ""
browser["title"] = titlePayload["title"] ?? ""
payload["browser"] = browser
}
output(payload, fallback: "OK")
return
}
if subcommand == "open" || subcommand == "open-split" || subcommand == "new" {
// Parse routing flags before URL assembly so they never leak into the URL string.
let (workspaceOpt, argsAfterWorkspace) = parseOption(subArgs, name: "--workspace")
let (windowOpt, urlArgs) = parseOption(argsAfterWorkspace, name: "--window")
let url = urlArgs.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
let respectExternalOpenRules: Bool = {
guard let raw = ProcessInfo.processInfo.environment["CMUX_RESPECT_EXTERNAL_OPEN_RULES"] else {
return false
}
switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "1", "true", "yes", "on":
return true
default:
return false
}
}()
if surfaceRaw != nil, subcommand == "open" {
// Treat `browser <surface> open <url>` as navigate for agent-browser ergonomics.
let sid = try requireSurface()
guard !url.isEmpty else {
throw CLIError(message: "browser <surface> open requires a URL")
}
let payload = try client.sendV2(method: "browser.navigate", params: ["surface_id": sid, "url": url])
output(payload, fallback: "OK")
return
}
var params: [String: Any] = [:]
if !url.isEmpty {
params["url"] = url
}
if let sourceSurface = try normalizeSurfaceHandle(surfaceRaw, client: client) {
params["surface_id"] = sourceSurface
}
let workspaceRaw = workspaceOpt ?? (windowOpt == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
if let workspaceRaw {
if let workspace = try normalizeWorkspaceHandle(workspaceRaw, client: client) {
params["workspace_id"] = workspace
}
}
if respectExternalOpenRules {
params["respect_external_open_rules"] = true
}
if let windowRaw = windowOpt {
if let window = try normalizeWindowHandle(windowRaw, client: client) {
params["window_id"] = window
}
}
let payload = try client.sendV2(method: "browser.open_split", params: params)
let surfaceText = formatHandle(payload, kind: "surface", idFormat: effectiveIDFormat) ?? "unknown"
let paneText = formatHandle(payload, kind: "pane", idFormat: effectiveIDFormat) ?? "unknown"
let placement = ((payload["created_split"] as? Bool) == true) ? "split" : "reuse"
output(payload, fallback: "OK surface=\(surfaceText) pane=\(paneText) placement=\(placement)")
return
}
if subcommand == "goto" || subcommand == "navigate" {
let sid = try requireSurface()
var urlArgs = subArgs
let snapshotAfter = urlArgs.last == "--snapshot-after"
if snapshotAfter {
urlArgs.removeLast()
}
let url = urlArgs.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
guard !url.isEmpty else {
throw CLIError(message: "browser \(subcommand) requires a URL")
}
var params: [String: Any] = ["surface_id": sid, "url": url]
if snapshotAfter {
params["snapshot_after"] = true
}
let payload = try client.sendV2(method: "browser.navigate", params: params)
output(payload, fallback: "OK")
return
}
if subcommand == "back" || subcommand == "forward" || subcommand == "reload" {
let sid = try requireSurface()
let methodMap: [String: String] = [
"back": "browser.back",
"forward": "browser.forward",
"reload": "browser.reload",
]
var params: [String: Any] = ["surface_id": sid]
if hasFlag(subArgs, name: "--snapshot-after") {
params["snapshot_after"] = true
}
let payload = try client.sendV2(method: methodMap[subcommand]!, params: params)
output(payload, fallback: "OK")
return
}
if subcommand == "url" || subcommand == "get-url" {
let sid = try requireSurface()
let payload = try client.sendV2(method: "browser.url.get", params: ["surface_id": sid])
if effectiveJSONOutput {
print(jsonString(formatIDs(payload, mode: effectiveIDFormat)))
} else {
print((payload["url"] as? String) ?? "")
}
return
}
if ["focus-webview", "focus_webview"].contains(subcommand) {
let sid = try requireSurface()
let payload = try client.sendV2(method: "browser.focus_webview", params: ["surface_id": sid])
output(payload, fallback: "OK")
return
}
if ["is-webview-focused", "is_webview_focused"].contains(subcommand) {
let sid = try requireSurface()
let payload = try client.sendV2(method: "browser.is_webview_focused", params: ["surface_id": sid])
if effectiveJSONOutput {
print(jsonString(formatIDs(payload, mode: effectiveIDFormat)))
} else {
print((payload["focused"] as? Bool) == true ? "true" : "false")
}
return
}
if subcommand == "snapshot" {
let sid = try requireSurface()
let (selectorOpt, rem1) = parseOption(subArgs, name: "--selector")
let (depthOpt, _) = parseOption(rem1, name: "--max-depth")
var params: [String: Any] = ["surface_id": sid]
if let selectorOpt {
params["selector"] = selectorOpt
}
if hasFlag(subArgs, name: "--interactive") || hasFlag(subArgs, name: "-i") {
params["interactive"] = true
}
if hasFlag(subArgs, name: "--cursor") {
params["cursor"] = true
}
if hasFlag(subArgs, name: "--compact") {
params["compact"] = true
}
if let depthOpt {
guard let depth = Int(depthOpt), depth >= 0 else {
throw CLIError(message: "--max-depth must be a non-negative integer")
}
params["max_depth"] = depth
}
let payload = try client.sendV2(method: "browser.snapshot", params: params)
if effectiveJSONOutput {
print(jsonString(formatIDs(payload, mode: effectiveIDFormat)))
} else {
print(displaySnapshotText(payload))
}
return
}
if subcommand == "eval" {
let sid = try requireSurface()
let script = optionValue(subArgs, name: "--script") ?? subArgs.joined(separator: " ")
let trimmed = script.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
throw CLIError(message: "browser eval requires a script")
}
let payload = try client.sendV2(method: "browser.eval", params: ["surface_id": sid, "script": trimmed])
output(payload, fallback: "OK")
return
}
if subcommand == "wait" {
let sid = try requireSurface()
var params: [String: Any] = ["surface_id": sid]
let (selectorOpt, rem1) = parseOption(subArgs, name: "--selector")
let (textOpt, rem2) = parseOption(rem1, name: "--text")
let (urlContainsOptA, rem3) = parseOption(rem2, name: "--url-contains")
let (urlContainsOptB, rem4) = parseOption(rem3, name: "--url")
let (loadStateOpt, rem5) = parseOption(rem4, name: "--load-state")
let (functionOpt, rem6) = parseOption(rem5, name: "--function")
let (timeoutOptMs, rem7) = parseOption(rem6, name: "--timeout-ms")
let (timeoutOptSec, rem8) = parseOption(rem7, name: "--timeout")
if let selector = selectorOpt ?? rem8.first {
params["selector"] = selector
}
if let textOpt {
params["text_contains"] = textOpt
}
if let urlContains = urlContainsOptA ?? urlContainsOptB {
params["url_contains"] = urlContains
}
if let loadStateOpt {
params["load_state"] = loadStateOpt
}
if let functionOpt {
params["function"] = functionOpt
}
if let timeoutOptMs {
guard let ms = Int(timeoutOptMs) else {
throw CLIError(message: "--timeout-ms must be an integer")
}
params["timeout_ms"] = ms
} else if let timeoutOptSec {
guard let seconds = Double(timeoutOptSec) else {
throw CLIError(message: "--timeout must be a number")
}
params["timeout_ms"] = max(1, Int(seconds * 1000.0))
}
let payload = try client.sendV2(method: "browser.wait", params: params)
output(payload, fallback: "OK")
return
}
if ["click", "dblclick", "hover", "focus", "check", "uncheck", "scrollintoview", "scrollinto", "scroll-into-view"].contains(subcommand) {
let sid = try requireSurface()
let (selectorOpt, rem1) = parseOption(subArgs, name: "--selector")
let selector = selectorOpt ?? rem1.first
guard let selector else {
throw CLIError(message: "browser \(subcommand) requires a selector")
}
let methodMap: [String: String] = [
"click": "browser.click",
"dblclick": "browser.dblclick",
"hover": "browser.hover",
"focus": "browser.focus",
"check": "browser.check",
"uncheck": "browser.uncheck",
"scrollintoview": "browser.scroll_into_view",
"scrollinto": "browser.scroll_into_view",
"scroll-into-view": "browser.scroll_into_view",
]
var params: [String: Any] = ["surface_id": sid, "selector": selector]
if hasFlag(subArgs, name: "--snapshot-after") {
params["snapshot_after"] = true
}
let payload = try client.sendV2(method: methodMap[subcommand]!, params: params)
output(payload, fallback: "OK")
return
}
if ["type", "fill"].contains(subcommand) {
let sid = try requireSurface()
let (selectorOpt, rem1) = parseOption(subArgs, name: "--selector")
let (textOpt, rem2) = parseOption(rem1, name: "--text")
let selector = selectorOpt ?? rem2.first
guard let selector else {
throw CLIError(message: "browser \(subcommand) requires a selector")
}
let positional = selectorOpt != nil ? rem2 : Array(rem2.dropFirst())
let hasExplicitText = textOpt != nil || !positional.isEmpty
let text: String
if let textOpt {
text = textOpt
} else {
text = positional.joined(separator: " ")
}
if subcommand == "type" {
guard hasExplicitText, !text.isEmpty else {
throw CLIError(message: "browser type requires text")
}
}
let method = (subcommand == "type") ? "browser.type" : "browser.fill"
var params: [String: Any] = ["surface_id": sid, "selector": selector, "text": text]
if hasFlag(subArgs, name: "--snapshot-after") {
params["snapshot_after"] = true
}
let payload = try client.sendV2(method: method, params: params)
output(payload, fallback: "OK")
return
}
if ["press", "key", "keydown", "keyup"].contains(subcommand) {
let sid = try requireSurface()
let (keyOpt, rem1) = parseOption(subArgs, name: "--key")
let key = keyOpt ?? rem1.first
guard let key else {
throw CLIError(message: "browser \(subcommand) requires a key")
}
let methodMap: [String: String] = [
"press": "browser.press",
"key": "browser.press",
"keydown": "browser.keydown",
"keyup": "browser.keyup",
]
var params: [String: Any] = ["surface_id": sid, "key": key]
if hasFlag(subArgs, name: "--snapshot-after") {
params["snapshot_after"] = true
}
let payload = try client.sendV2(method: methodMap[subcommand]!, params: params)
output(payload, fallback: "OK")
return
}
if subcommand == "select" {
let sid = try requireSurface()
let (selectorOpt, rem1) = parseOption(subArgs, name: "--selector")
let (valueOpt, rem2) = parseOption(rem1, name: "--value")
let selector = selectorOpt ?? rem2.first
guard let selector else {
throw CLIError(message: "browser select requires a selector")
}
let value = valueOpt ?? (selectorOpt != nil ? rem2.first : rem2.dropFirst().first)
guard let value else {
throw CLIError(message: "browser select requires a value")
}
var params: [String: Any] = ["surface_id": sid, "selector": selector, "value": value]
if hasFlag(subArgs, name: "--snapshot-after") {
params["snapshot_after"] = true
}
let payload = try client.sendV2(method: "browser.select", params: params)
output(payload, fallback: "OK")
return
}
if subcommand == "scroll" {
let sid = try requireSurface()
let (selectorOpt, rem1) = parseOption(subArgs, name: "--selector")
let (dxOpt, rem2) = parseOption(rem1, name: "--dx")
let (dyOpt, rem3) = parseOption(rem2, name: "--dy")
var params: [String: Any] = ["surface_id": sid]
if let selectorOpt {
params["selector"] = selectorOpt
}
if let dxOpt {
guard let dx = Int(dxOpt) else {
throw CLIError(message: "--dx must be an integer")
}
params["dx"] = dx
}
if let dyOpt {
guard let dy = Int(dyOpt) else {
throw CLIError(message: "--dy must be an integer")
}
params["dy"] = dy
} else if let first = rem3.first, let dy = Int(first) {
params["dy"] = dy
}
if hasFlag(subArgs, name: "--snapshot-after") {
params["snapshot_after"] = true
}
let payload = try client.sendV2(method: "browser.scroll", params: params)
output(payload, fallback: "OK")
return
}
if subcommand == "screenshot" {
let sid = try requireSurface()
let (outPathOpt, _) = parseOption(subArgs, name: "--out")
let localJSONOutput = hasFlag(subArgs, name: "--json")
let outputAsJSON = effectiveJSONOutput || localJSONOutput
var payload = try client.sendV2(method: "browser.screenshot", params: ["surface_id": sid])
func fileURL(fromPath rawPath: String) -> URL {
let resolvedPath = resolvePath(rawPath)
return URL(fileURLWithPath: resolvedPath).standardizedFileURL
}
func writeScreenshot(_ data: Data, to destinationURL: URL) throws {
try FileManager.default.createDirectory(
at: destinationURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try data.write(to: destinationURL, options: .atomic)
}
func hasText(_ value: String?) -> Bool {
guard let value else { return false }
return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
var screenshotPath = payload["path"] as? String
var screenshotURL = payload["url"] as? String
func syncScreenshotLocationFields() {
if !hasText(screenshotPath),
let rawURL = screenshotURL,
let fileURL = URL(string: rawURL),
fileURL.isFileURL,
!fileURL.path.isEmpty {
screenshotPath = fileURL.path
}
if !hasText(screenshotURL),
let screenshotPath,
hasText(screenshotPath) {
screenshotURL = URL(fileURLWithPath: screenshotPath).standardizedFileURL.absoluteString
}
if let screenshotPath, hasText(screenshotPath) {
payload["path"] = screenshotPath
}
if let screenshotURL, hasText(screenshotURL) {
payload["url"] = screenshotURL
}
}
func persistPayloadScreenshot(to destinationURL: URL, allowFailure: Bool) throws -> Bool {
if let sourcePath = screenshotPath, hasText(sourcePath) {
let sourceURL = URL(fileURLWithPath: sourcePath).standardizedFileURL
do {
if sourceURL.path != destinationURL.path {
try FileManager.default.createDirectory(
at: destinationURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try? FileManager.default.removeItem(at: destinationURL)
try FileManager.default.copyItem(at: sourceURL, to: destinationURL)
}
return true
} catch {
if payload["png_base64"] == nil {
if allowFailure {
return false
}
throw error
}
}
}
if let b64 = payload["png_base64"] as? String,
let data = Data(base64Encoded: b64) {
do {
try writeScreenshot(data, to: destinationURL)
return true
} catch {
if allowFailure {
return false
}
throw error
}
}
return false
}
if let outPathOpt {
let outputURL = fileURL(fromPath: outPathOpt)
guard try persistPayloadScreenshot(to: outputURL, allowFailure: false) else {
throw CLIError(message: "browser screenshot missing image data")
}
screenshotPath = outputURL.path
screenshotURL = outputURL.absoluteString
payload["path"] = screenshotPath
payload["url"] = screenshotURL
} else {
syncScreenshotLocationFields()
if !hasText(screenshotPath) && !hasText(screenshotURL) {
let outputDir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-browser-screenshots-cli", isDirectory: true)
if (try? FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true)) != nil {
bestEffortPruneTemporaryFiles(in: outputDir)
let timestampMs = Int(Date().timeIntervalSince1970 * 1000)
let safeSid = sanitizedFilenameComponent(sid)
let filename = "surface-\(safeSid)-\(timestampMs)-\(String(UUID().uuidString.prefix(8))).png"
let outputURL = outputDir.appendingPathComponent(filename, isDirectory: false)
if (try? persistPayloadScreenshot(to: outputURL, allowFailure: true)) == true {
screenshotPath = outputURL.path
screenshotURL = outputURL.absoluteString
payload["path"] = screenshotPath
payload["url"] = screenshotURL
}
}
}
}
if outputAsJSON {
let formattedPayload = formatIDs(payload, mode: effectiveIDFormat)
if var outputPayload = formattedPayload as? [String: Any] {
if hasText(screenshotPath) || hasText(screenshotURL) {
outputPayload.removeValue(forKey: "png_base64")
}
print(jsonString(outputPayload))
} else {
print(jsonString(formattedPayload))
}
} else if let outPathOpt {
print("OK \(outPathOpt)")
} else if let screenshotURL,
hasText(screenshotURL) {
print("OK \(screenshotURL)")
} else if let screenshotPath,
hasText(screenshotPath) {
print("OK \(screenshotPath)")
} else {
print("OK")
}
return
}
if subcommand == "get" {
let sid = try requireSurface()
guard let getVerb = subArgs.first?.lowercased() else {
throw CLIError(message: "browser get requires a subcommand")
}
let getArgs = Array(subArgs.dropFirst())
switch getVerb {
case "url":
let payload = try client.sendV2(method: "browser.url.get", params: ["surface_id": sid])
output(payload, fallback: (payload["url"] as? String) ?? "")
case "title":
let payload = try client.sendV2(method: "browser.get.title", params: ["surface_id": sid])
output(payload, fallback: (payload["title"] as? String) ?? "")
case "text", "html", "value", "count", "box", "styles", "attr":
let (selectorOpt, rem1) = parseOption(getArgs, name: "--selector")
let selector = selectorOpt ?? rem1.first
if getVerb != "title" && getVerb != "url" {
guard selector != nil else {
throw CLIError(message: "browser get \(getVerb) requires a selector")
}
}
var params: [String: Any] = ["surface_id": sid]
if let selector {
params["selector"] = selector
}
if getVerb == "attr" {
let (attrOpt, rem2) = parseOption(rem1, name: "--attr")
let attr = attrOpt ?? rem2.dropFirst().first
guard let attr else {
throw CLIError(message: "browser get attr requires --attr <name>")
}
params["attr"] = attr
}
if getVerb == "styles" {
let (propOpt, _) = parseOption(rem1, name: "--property")
if let propOpt {
params["property"] = propOpt
}
}
let methodMap: [String: String] = [
"text": "browser.get.text",
"html": "browser.get.html",
"value": "browser.get.value",
"attr": "browser.get.attr",
"count": "browser.get.count",
"box": "browser.get.box",
"styles": "browser.get.styles",
]
let payload = try client.sendV2(method: methodMap[getVerb]!, params: params)
if effectiveJSONOutput {
print(jsonString(formatIDs(payload, mode: effectiveIDFormat)))
} else if let value = payload["value"] {
if let str = value as? String {
print(str)
} else {
print(jsonString(value))
}
} else if let count = payload["count"] {
print("\(count)")
} else {
print("OK")
}
default:
throw CLIError(message: "Unsupported browser get subcommand: \(getVerb)")
}
return
}
if subcommand == "is" {
let sid = try requireSurface()
guard let isVerb = subArgs.first?.lowercased() else {
throw CLIError(message: "browser is requires a subcommand")
}
let isArgs = Array(subArgs.dropFirst())
let (selectorOpt, rem1) = parseOption(isArgs, name: "--selector")
let selector = selectorOpt ?? rem1.first
guard let selector else {
throw CLIError(message: "browser is \(isVerb) requires a selector")
}
let methodMap: [String: String] = [
"visible": "browser.is.visible",
"enabled": "browser.is.enabled",
"checked": "browser.is.checked",
]
guard let method = methodMap[isVerb] else {
throw CLIError(message: "Unsupported browser is subcommand: \(isVerb)")
}
let payload = try client.sendV2(method: method, params: ["surface_id": sid, "selector": selector])
if effectiveJSONOutput {
print(jsonString(formatIDs(payload, mode: effectiveIDFormat)))
} else if let value = payload["value"] {
print("\(value)")
} else {
print("false")
}
return
}
if subcommand == "find" {
let sid = try requireSurface()
guard let locator = subArgs.first?.lowercased() else {
throw CLIError(message: "browser find requires a locator (role|text|label|placeholder|alt|title|testid|first|last|nth)")
}
let locatorArgs = Array(subArgs.dropFirst())
var params: [String: Any] = ["surface_id": sid]
let method: String
switch locator {
case "role":
let (nameOpt, rem1) = parseOption(locatorArgs, name: "--name")
let candidates = nonFlagArgs(rem1)
guard let role = candidates.first else {
throw CLIError(message: "browser find role requires <role>")
}
params["role"] = role
if let nameOpt {
params["name"] = nameOpt
}
if hasFlag(locatorArgs, name: "--exact") {
params["exact"] = true
}
method = "browser.find.role"
case "text", "label", "placeholder", "alt", "title", "testid":
let keyMap: [String: String] = [
"text": "text",
"label": "label",
"placeholder": "placeholder",
"alt": "alt",
"title": "title",
"testid": "testid",
]
let candidates = nonFlagArgs(locatorArgs)
guard let value = candidates.first else {
throw CLIError(message: "browser find \(locator) requires a value")
}
params[keyMap[locator]!] = value
if hasFlag(locatorArgs, name: "--exact") {
params["exact"] = true
}
method = "browser.find.\(locator)"
case "first", "last":
let (selectorOpt, rem1) = parseOption(locatorArgs, name: "--selector")
let candidates = nonFlagArgs(rem1)
guard let selector = selectorOpt ?? candidates.first else {
throw CLIError(message: "browser find \(locator) requires a selector")
}
params["selector"] = selector
method = "browser.find.\(locator)"
case "nth":
let (indexOpt, rem1) = parseOption(locatorArgs, name: "--index")
let (selectorOpt, rem2) = parseOption(rem1, name: "--selector")
let candidates = nonFlagArgs(rem2)
let indexRaw = indexOpt ?? candidates.first
guard let indexRaw,
let index = Int(indexRaw) else {
throw CLIError(message: "browser find nth requires an integer index")
}
let selector = selectorOpt ?? (candidates.count >= 2 ? candidates[1] : nil)
guard let selector else {
throw CLIError(message: "browser find nth requires a selector")
}
params["index"] = index
params["selector"] = selector
method = "browser.find.nth"
default:
throw CLIError(message: "Unsupported browser find locator: \(locator)")
}
let payload = try client.sendV2(method: method, params: params)
output(payload, fallback: "OK")
return
}
if subcommand == "frame" {
let sid = try requireSurface()
guard let frameVerb = subArgs.first?.lowercased() else {
throw CLIError(message: "browser frame requires <selector|main>")
}
if frameVerb == "main" {
let payload = try client.sendV2(method: "browser.frame.main", params: ["surface_id": sid])
output(payload, fallback: "OK")
return
}
let (selectorOpt, rem1) = parseOption(subArgs, name: "--selector")
let selector = selectorOpt ?? nonFlagArgs(rem1).first
guard let selector else {
throw CLIError(message: "browser frame requires a selector or 'main'")
}
let payload = try client.sendV2(method: "browser.frame.select", params: ["surface_id": sid, "selector": selector])
output(payload, fallback: "OK")
return
}
if subcommand == "dialog" {
let sid = try requireSurface()
guard let dialogVerb = subArgs.first?.lowercased() else {
throw CLIError(message: "browser dialog requires <accept|dismiss> [text]")
}
let remainder = Array(subArgs.dropFirst())
switch dialogVerb {
case "accept":
let text = remainder.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
var params: [String: Any] = ["surface_id": sid]
if !text.isEmpty {
params["text"] = text
}
let payload = try client.sendV2(method: "browser.dialog.accept", params: params)
output(payload, fallback: "OK")
case "dismiss":
let payload = try client.sendV2(method: "browser.dialog.dismiss", params: ["surface_id": sid])
output(payload, fallback: "OK")
default:
throw CLIError(message: "Unsupported browser dialog subcommand: \(dialogVerb)")
}
return
}
if subcommand == "download" {
let sid = try requireSurface()
let argsForDownload: [String]
if subArgs.first?.lowercased() == "wait" {
argsForDownload = Array(subArgs.dropFirst())
} else {
argsForDownload = subArgs
}
let (pathOpt, rem1) = parseOption(argsForDownload, name: "--path")
let (timeoutMsOpt, rem2) = parseOption(rem1, name: "--timeout-ms")
let (timeoutSecOpt, rem3) = parseOption(rem2, name: "--timeout")
var params: [String: Any] = ["surface_id": sid]
if let path = pathOpt ?? nonFlagArgs(rem3).first {
params["path"] = path
}
if let timeoutMsOpt {
guard let timeoutMs = Int(timeoutMsOpt) else {
throw CLIError(message: "--timeout-ms must be an integer")
}
params["timeout_ms"] = timeoutMs
} else if let timeoutSecOpt {
guard let seconds = Double(timeoutSecOpt) else {
throw CLIError(message: "--timeout must be a number")
}
params["timeout_ms"] = max(1, Int(seconds * 1000.0))
}
let payload = try client.sendV2(method: "browser.download.wait", params: params)
output(payload, fallback: "OK")
return
}
if subcommand == "cookies" {
let sid = try requireSurface()
let cookieVerb = subArgs.first?.lowercased() ?? "get"
let cookieArgs = subArgs.first != nil ? Array(subArgs.dropFirst()) : []
let (nameOpt, rem1) = parseOption(cookieArgs, name: "--name")
let (valueOpt, rem2) = parseOption(rem1, name: "--value")
let (urlOpt, rem3) = parseOption(rem2, name: "--url")
let (domainOpt, rem4) = parseOption(rem3, name: "--domain")
let (pathOpt, rem5) = parseOption(rem4, name: "--path")
let (expiresOpt, _) = parseOption(rem5, name: "--expires")
var params: [String: Any] = ["surface_id": sid]
if let nameOpt { params["name"] = nameOpt }
if let valueOpt { params["value"] = valueOpt }
if let urlOpt { params["url"] = urlOpt }
if let domainOpt { params["domain"] = domainOpt }
if let pathOpt { params["path"] = pathOpt }
if hasFlag(cookieArgs, name: "--secure") {
params["secure"] = true
}
if hasFlag(cookieArgs, name: "--all") {
params["all"] = true
}
if let expiresOpt {
guard let expires = Int(expiresOpt) else {
throw CLIError(message: "--expires must be an integer Unix timestamp")
}
params["expires"] = expires
}
switch cookieVerb {
case "get":
let payload = try client.sendV2(method: "browser.cookies.get", params: params)
output(payload, fallback: "OK")
case "set":
var setParams = params
let positional = nonFlagArgs(cookieArgs)
if setParams["name"] == nil, positional.count >= 1 {
setParams["name"] = positional[0]
}
if setParams["value"] == nil, positional.count >= 2 {
setParams["value"] = positional[1]
}
guard setParams["name"] != nil, setParams["value"] != nil else {
throw CLIError(message: "browser cookies set requires <name> <value> (or --name/--value)")
}
let payload = try client.sendV2(method: "browser.cookies.set", params: setParams)
output(payload, fallback: "OK")
case "clear":
let payload = try client.sendV2(method: "browser.cookies.clear", params: params)
output(payload, fallback: "OK")
default:
throw CLIError(message: "Unsupported browser cookies subcommand: \(cookieVerb)")
}
return
}
if subcommand == "storage" {
let sid = try requireSurface()
let storageArgs = subArgs
let storageType = storageArgs.first?.lowercased() ?? "local"
guard storageType == "local" || storageType == "session" else {
throw CLIError(message: "browser storage requires type: local|session")
}
let op = storageArgs.count >= 2 ? storageArgs[1].lowercased() : "get"
let rest = storageArgs.count > 2 ? Array(storageArgs.dropFirst(2)) : []
let positional = nonFlagArgs(rest)
var params: [String: Any] = ["surface_id": sid, "type": storageType]
switch op {
case "get":
if let key = positional.first {
params["key"] = key
}
let payload = try client.sendV2(method: "browser.storage.get", params: params)
output(payload, fallback: "OK")
case "set":
guard positional.count >= 2 else {
throw CLIError(message: "browser storage \(storageType) set requires <key> <value>")
}
params["key"] = positional[0]
params["value"] = positional[1]
let payload = try client.sendV2(method: "browser.storage.set", params: params)
output(payload, fallback: "OK")
case "clear":
let payload = try client.sendV2(method: "browser.storage.clear", params: params)
output(payload, fallback: "OK")
default:
throw CLIError(message: "Unsupported browser storage subcommand: \(op)")
}
return
}
if subcommand == "tab" {
let sid = try requireSurface()
let first = subArgs.first?.lowercased()
let tabVerb: String
let tabArgs: [String]
if let first, ["new", "list", "close", "switch"].contains(first) {
tabVerb = first
tabArgs = Array(subArgs.dropFirst())
} else if let first, Int(first) != nil {
tabVerb = "switch"
tabArgs = subArgs
} else {
tabVerb = "list"
tabArgs = subArgs
}
switch tabVerb {
case "list":
let payload = try client.sendV2(method: "browser.tab.list", params: ["surface_id": sid])
output(payload, fallback: "OK")
case "new":
var params: [String: Any] = ["surface_id": sid]
let url = tabArgs.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
if !url.isEmpty {
params["url"] = url
}
let payload = try client.sendV2(method: "browser.tab.new", params: params)
output(payload, fallback: "OK")
case "switch", "close":
let method = (tabVerb == "switch") ? "browser.tab.switch" : "browser.tab.close"
var params: [String: Any] = ["surface_id": sid]
let target = tabArgs.first
if let target {
if let index = Int(target) {
params["index"] = index
} else {
params["target_surface_id"] = target
}
}
let payload = try client.sendV2(method: method, params: params)
output(payload, fallback: "OK")
default:
throw CLIError(message: "Unsupported browser tab subcommand: \(tabVerb)")
}
return
}
if subcommand == "console" {
let sid = try requireSurface()
let consoleVerb = subArgs.first?.lowercased() ?? "list"
let method = (consoleVerb == "clear") ? "browser.console.clear" : "browser.console.list"
if consoleVerb != "list" && consoleVerb != "clear" {
throw CLIError(message: "Unsupported browser console subcommand: \(consoleVerb)")
}
let payload = try client.sendV2(method: method, params: ["surface_id": sid])
output(payload, fallback: "OK")
return
}
if subcommand == "errors" {
let sid = try requireSurface()
let errorsVerb = subArgs.first?.lowercased() ?? "list"
var params: [String: Any] = ["surface_id": sid]
if errorsVerb == "clear" {
params["clear"] = true
} else if errorsVerb != "list" {
throw CLIError(message: "Unsupported browser errors subcommand: \(errorsVerb)")
}
let payload = try client.sendV2(method: "browser.errors.list", params: params)
output(payload, fallback: "OK")
return
}
if subcommand == "highlight" {
let sid = try requireSurface()
let (selectorOpt, rem1) = parseOption(subArgs, name: "--selector")
let selector = selectorOpt ?? nonFlagArgs(rem1).first
guard let selector else {
throw CLIError(message: "browser highlight requires a selector")
}
let payload = try client.sendV2(method: "browser.highlight", params: ["surface_id": sid, "selector": selector])
output(payload, fallback: "OK")
return
}
if subcommand == "state" {
let sid = try requireSurface()
guard let stateVerb = subArgs.first?.lowercased() else {
throw CLIError(message: "browser state requires save|load <path>")
}
guard subArgs.count >= 2 else {
throw CLIError(message: "browser state \(stateVerb) requires a file path")
}
let path = subArgs[1]
let method: String
switch stateVerb {
case "save":
method = "browser.state.save"
case "load":
method = "browser.state.load"
default:
throw CLIError(message: "Unsupported browser state subcommand: \(stateVerb)")
}
let payload = try client.sendV2(method: method, params: ["surface_id": sid, "path": path])
output(payload, fallback: "OK")
return
}
if subcommand == "addinitscript" || subcommand == "addscript" || subcommand == "addstyle" {
let sid = try requireSurface()
let field = (subcommand == "addstyle") ? "css" : "script"
let flag = (subcommand == "addstyle") ? "--css" : "--script"
let (scriptOpt, rem1) = parseOption(subArgs, name: flag)
let content = (scriptOpt ?? rem1.joined(separator: " ")).trimmingCharacters(in: .whitespacesAndNewlines)
guard !content.isEmpty else {
throw CLIError(message: "browser \(subcommand) requires content")
}
let payload = try client.sendV2(method: "browser.\(subcommand)", params: ["surface_id": sid, field: content])
output(payload, fallback: "OK")
return
}
if subcommand == "viewport" {
let sid = try requireSurface()
guard subArgs.count >= 2,
let width = Int(subArgs[0]),
let height = Int(subArgs[1]) else {
throw CLIError(message: "browser viewport requires: <width> <height>")
}
let payload = try client.sendV2(method: "browser.viewport.set", params: ["surface_id": sid, "width": width, "height": height])
output(payload, fallback: "OK")
return
}
if subcommand == "geolocation" || subcommand == "geo" {
let sid = try requireSurface()
guard subArgs.count >= 2,
let latitude = Double(subArgs[0]),
let longitude = Double(subArgs[1]) else {
throw CLIError(message: "browser geolocation requires: <latitude> <longitude>")
}
let payload = try client.sendV2(method: "browser.geolocation.set", params: ["surface_id": sid, "latitude": latitude, "longitude": longitude])
output(payload, fallback: "OK")
return
}
if subcommand == "offline" {
let sid = try requireSurface()
guard let raw = subArgs.first,
let enabled = parseBoolString(raw) else {
throw CLIError(message: "browser offline requires true|false")
}
let payload = try client.sendV2(method: "browser.offline.set", params: ["surface_id": sid, "enabled": enabled])
output(payload, fallback: "OK")
return
}
if subcommand == "trace" {
let sid = try requireSurface()
guard let traceVerb = subArgs.first?.lowercased() else {
throw CLIError(message: "browser trace requires start|stop")
}
let method: String
switch traceVerb {
case "start":
method = "browser.trace.start"
case "stop":
method = "browser.trace.stop"
default:
throw CLIError(message: "Unsupported browser trace subcommand: \(traceVerb)")
}
var params: [String: Any] = ["surface_id": sid]
if subArgs.count >= 2 {
params["path"] = subArgs[1]
}
let payload = try client.sendV2(method: method, params: params)
output(payload, fallback: "OK")
return
}
if subcommand == "network" {
let sid = try requireSurface()
guard let networkVerb = subArgs.first?.lowercased() else {
throw CLIError(message: "browser network requires route|unroute|requests")
}
let networkArgs = Array(subArgs.dropFirst())
switch networkVerb {
case "route":
guard let pattern = networkArgs.first else {
throw CLIError(message: "browser network route requires a URL/pattern")
}
var params: [String: Any] = ["surface_id": sid, "url": pattern]
if hasFlag(networkArgs, name: "--abort") {
params["abort"] = true
}
let (bodyOpt, _) = parseOption(networkArgs, name: "--body")
if let bodyOpt {
params["body"] = bodyOpt
}
let payload = try client.sendV2(method: "browser.network.route", params: params)
output(payload, fallback: "OK")
case "unroute":
guard let pattern = networkArgs.first else {
throw CLIError(message: "browser network unroute requires a URL/pattern")
}
let payload = try client.sendV2(method: "browser.network.unroute", params: ["surface_id": sid, "url": pattern])
output(payload, fallback: "OK")
case "requests":
let payload = try client.sendV2(method: "browser.network.requests", params: ["surface_id": sid])
output(payload, fallback: "OK")
default:
throw CLIError(message: "Unsupported browser network subcommand: \(networkVerb)")
}
return
}
if subcommand == "screencast" {
let sid = try requireSurface()
guard let castVerb = subArgs.first?.lowercased() else {
throw CLIError(message: "browser screencast requires start|stop")
}
let method: String
switch castVerb {
case "start":
method = "browser.screencast.start"
case "stop":
method = "browser.screencast.stop"
default:
throw CLIError(message: "Unsupported browser screencast subcommand: \(castVerb)")
}
let payload = try client.sendV2(method: method, params: ["surface_id": sid])
output(payload, fallback: "OK")
return
}
if subcommand == "input" {
let sid = try requireSurface()
guard let inputVerb = subArgs.first?.lowercased() else {
throw CLIError(message: "browser input requires mouse|keyboard|touch")
}
let remainder = Array(subArgs.dropFirst())
let method: String
switch inputVerb {
case "mouse":
method = "browser.input_mouse"
case "keyboard":
method = "browser.input_keyboard"
case "touch":
method = "browser.input_touch"
default:
throw CLIError(message: "Unsupported browser input subcommand: \(inputVerb)")
}
var params: [String: Any] = ["surface_id": sid]
if !remainder.isEmpty {
params["args"] = remainder
}
let payload = try client.sendV2(method: method, params: params)
output(payload, fallback: "OK")
return
}
if ["input_mouse", "input_keyboard", "input_touch"].contains(subcommand) {
let sid = try requireSurface()
let payload = try client.sendV2(method: "browser.\(subcommand)", params: ["surface_id": sid])
output(payload, fallback: "OK")
return
}
throw CLIError(message: "Unsupported browser subcommand: \(subcommand)")
}
private func parseWindows(_ response: String) -> [WindowInfo] {
guard response != "No windows" else { return [] }
return response
.split(separator: "\n")
.compactMap { line in
let raw = String(line)
let key = raw.hasPrefix("*")
let cleaned = raw.trimmingCharacters(in: CharacterSet(charactersIn: "* "))
let parts = cleaned.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
guard parts.count >= 2 else { return nil }
let indexText = parts[0].replacingOccurrences(of: ":", with: "")
guard let index = Int(indexText) else { return nil }
let id = parts[1]
var selectedWorkspaceId: String?
var workspaceCount: Int = 0
for token in parts.dropFirst(2) {
if token.hasPrefix("selected_workspace=") {
let v = token.replacingOccurrences(of: "selected_workspace=", with: "")
selectedWorkspaceId = (v == "none") ? nil : v
} else if token.hasPrefix("workspaces=") {
let v = token.replacingOccurrences(of: "workspaces=", with: "")
workspaceCount = Int(v) ?? 0
}
}
return WindowInfo(
index: index,
id: id,
key: key,
selectedWorkspaceId: selectedWorkspaceId,
workspaceCount: workspaceCount
)
}
}
private func parseNotifications(_ response: String) -> [NotificationInfo] {
guard response != "No notifications" else { return [] }
return response
.split(separator: "\n")
.compactMap { line in
let raw = String(line)
let parts = raw.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
guard parts.count == 2 else { return nil }
let payload = parts[1].split(separator: "|", maxSplits: 6, omittingEmptySubsequences: false)
guard payload.count >= 7 else { return nil }
let notifId = String(payload[0])
let workspaceId = String(payload[1])
let surfaceRaw = String(payload[2])
let surfaceId = surfaceRaw == "none" ? nil : surfaceRaw
let readText = String(payload[3])
let title = String(payload[4])
let subtitle = String(payload[5])
let body = String(payload[6])
return NotificationInfo(
id: notifId,
workspaceId: workspaceId,
surfaceId: surfaceId,
isRead: readText == "read",
title: title,
subtitle: subtitle,
body: body
)
}
}
private func resolveWorkspaceId(_ raw: String?, client: SocketClient) throws -> String {
if let raw, isUUID(raw) {
return raw
}
if let raw, isHandleRef(raw) {
// Resolve ref to UUID search across all windows
let windows = try client.sendV2(method: "window.list")
let windowList = windows["windows"] as? [[String: Any]] ?? []
for window in windowList {
guard let windowId = window["id"] as? String else { continue }
let listed = try client.sendV2(method: "workspace.list", params: ["window_id": windowId])
let items = listed["workspaces"] as? [[String: Any]] ?? []
for item in items where (item["ref"] as? String) == raw {
if let id = item["id"] as? String { return id }
}
}
throw CLIError(message: "Workspace ref not found: \(raw)")
}
if let raw, let index = Int(raw) {
let listed = try client.sendV2(method: "workspace.list")
let items = listed["workspaces"] as? [[String: Any]] ?? []
for item in items where intFromAny(item["index"]) == index {
if let id = item["id"] as? String { return id }
}
throw CLIError(message: "Workspace index not found")
}
let current = try client.sendV2(method: "workspace.current")
if let wsId = current["workspace_id"] as? String { return wsId }
throw CLIError(message: "No workspace selected")
}
private func resolveSurfaceId(_ raw: String?, workspaceId: String, client: SocketClient) throws -> String {
if let raw, isUUID(raw) {
return raw
}
if let raw, isHandleRef(raw) {
let listed = try client.sendV2(method: "surface.list", params: ["workspace_id": workspaceId])
let items = listed["surfaces"] as? [[String: Any]] ?? []
for item in items where (item["ref"] as? String) == raw {
if let id = item["id"] as? String { return id }
}
throw CLIError(message: "Surface ref not found: \(raw)")
}
let listed = try client.sendV2(method: "surface.list", params: ["workspace_id": workspaceId])
let items = listed["surfaces"] as? [[String: Any]] ?? []
if let raw, let index = Int(raw) {
for item in items where intFromAny(item["index"]) == index {
if let id = item["id"] as? String { return id }
}
throw CLIError(message: "Surface index not found")
}
if let focused = items.first(where: { ($0["focused"] as? Bool) == true }) {
if let id = focused["id"] as? String { return id }
}
throw CLIError(message: "Unable to resolve surface ID")
}
/// Return the help/usage text for a subcommand, or nil if the command is unknown.
private func subcommandUsage(_ command: String) -> String? {
switch command {
case "ping":
return """
Usage: cmux ping
Check connectivity to the cmux socket server.
"""
case "capabilities":
return """
Usage: cmux capabilities
Print server capabilities as JSON.
"""
case "help":
return """
Usage: cmux help
Show top-level CLI usage and command list.
"""
case "welcome":
return """
Usage: cmux welcome
Show a welcome screen with the cmux logo and useful shortcuts.
Auto-runs once on first launch.
"""
case "shortcuts":
return """
Usage: cmux shortcuts
Open the Settings window to Keyboard Shortcuts.
"""
case "feedback":
return """
Usage: cmux feedback
cmux feedback --email <email> --body <text> [--image <path> ...]
Without args, open the Send Feedback modal in the running app.
With args, submit feedback through the app using the same feedback pipeline as the modal.
Flags:
--email <email> Contact email for follow-up
--body <text> Feedback body
--image <path> Attach an image file, repeat for multiple images
Coding agents:
Double check with the end user before sending anything. Review the message and attachments for secrets,
private code, credentials, tokens, and other sensitive information first.
"""
case "claude-teams":
return String(localized: "cli.claude-teams.usage", defaultValue: """
Usage: cmux claude-teams [claude-args...]
Launch Claude Code with agent teams enabled.
This command:
- defaults Claude teammate mode to auto
- sets a tmux-like environment so Claude auto mode uses cmux splits
- sets CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1
- prepends a private tmux shim to PATH
- forwards all remaining arguments to claude
The tmux shim translates supported tmux window/pane commands into cmux
workspace and split operations in the current cmux session.
Examples:
cmux claude-teams
cmux claude-teams --continue
cmux claude-teams --model sonnet
""")
case "identify":
return """
Usage: cmux identify [--workspace <id|ref|index>] [--surface <id|ref|index>] [--no-caller]
Print server identity and caller context details.
Flags:
--workspace <id|ref|index> Caller workspace context (default: $CMUX_WORKSPACE_ID)
--surface <id|ref|index> Caller surface context (default: $CMUX_SURFACE_ID)
--no-caller Omit caller context from the request
"""
case "list-windows":
return """
Usage: cmux list-windows
List open windows.
"""
case "current-window":
return """
Usage: cmux current-window
Print the currently selected window ID.
"""
case "new-window":
return """
Usage: cmux new-window
Create a new window.
Example:
cmux new-window
"""
case "focus-window":
return """
Usage: cmux focus-window --window <id|ref|index>
Focus (bring to front) the specified window.
Flags:
--window <id|ref|index> Window to focus (required)
Example:
cmux focus-window --window 0
cmux focus-window --window window:1
"""
case "close-window":
return """
Usage: cmux close-window --window <id|ref|index>
Close the specified window.
Flags:
--window <id|ref|index> Window to close (required)
Example:
cmux close-window --window 0
cmux close-window --window window:1
"""
case "move-workspace-to-window":
return """
Usage: cmux move-workspace-to-window --workspace <id|ref|index> --window <id|ref|index>
Move a workspace to a different window.
Flags:
--workspace <id|ref|index> Workspace to move (required)
--window <id|ref|index> Target window (required)
Example:
cmux move-workspace-to-window --workspace workspace:2 --window window:1
"""
case "move-surface":
return """
Usage: cmux move-surface [--surface <id|ref|index> | <id|ref|index>] [flags]
Move a surface to a different pane, workspace, or window.
Flags:
--surface <id|ref|index> Surface to move (required unless passed positionally)
--pane <id|ref|index> Target pane
--workspace <id|ref|index> Target workspace
--window <id|ref|index> Target window
--before <id|ref|index> Place before this surface
--before-surface <id|ref|index>
Alias for --before
--after <id|ref|index> Place after this surface
--after-surface <id|ref|index>
Alias for --after
--index <n> Place at this index
--focus <true|false> Focus the surface after moving
Example:
cmux move-surface --surface surface:1 --workspace workspace:2
cmux move-surface surface:1 --pane pane:2 --index 0
"""
case "reorder-surface":
return """
Usage: cmux reorder-surface [--surface <id|ref|index> | <id|ref|index>] [flags]
Reorder a surface within its pane.
Flags:
--surface <id|ref|index> Surface to reorder (required unless passed positionally)
--workspace <id|ref|index> Workspace context
--before <id|ref|index> Place before this surface
--before-surface <id|ref|index>
Alias for --before
--after <id|ref|index> Place after this surface
--after-surface <id|ref|index>
Alias for --after
--index <n> Place at this index
Example:
cmux reorder-surface --surface surface:1 --index 0
cmux reorder-surface --surface surface:3 --after surface:1
"""
case "reorder-workspace":
return """
Usage: cmux reorder-workspace [--workspace <id|ref|index> | <id|ref|index>] [flags]
Reorder a workspace within its window.
Flags:
--workspace <id|ref|index> Workspace to reorder (required unless passed positionally)
--index <n> Place at this index
--before <id|ref|index> Place before this workspace
--before-workspace <id|ref|index>
Alias for --before
--after <id|ref|index> Place after this workspace
--after-workspace <id|ref|index>
Alias for --after
--window <id|ref|index> Window context
Example:
cmux reorder-workspace --workspace workspace:2 --index 0
cmux reorder-workspace --workspace workspace:3 --after workspace:1
"""
case "workspace-action":
return """
Usage: cmux workspace-action --action <name> [flags]
Perform workspace context-menu actions from CLI/socket.
Actions:
pin | unpin
rename | clear-name
move-up | move-down | move-top
close-others | close-above | close-below
mark-read | mark-unread
Flags:
--action <name> Action name (required if not positional)
--workspace <id|ref|index> Target workspace (default: current/$CMUX_WORKSPACE_ID)
--title <text> Title for rename (or pass trailing title text)
Example:
cmux workspace-action --workspace workspace:2 --action pin
cmux workspace-action --action rename --title "infra"
cmux workspace-action close-others
"""
case "tab-action":
return """
Usage: cmux tab-action --action <name> [flags]
Perform horizontal tab context-menu actions from CLI/socket.
Actions:
rename | clear-name
close-left | close-right | close-others
new-terminal-right | new-browser-right
reload | duplicate
pin | unpin
mark-unread
Flags:
--action <name> Action name (required if not positional)
--tab <id|ref|index> Target tab (accepts tab:<n> or surface:<n>; default: $CMUX_TAB_ID, then $CMUX_SURFACE_ID, then focused tab)
--surface <id|ref|index> Alias for --tab (backward compatibility)
--workspace <id|ref|index> Workspace context (default: current/$CMUX_WORKSPACE_ID)
--title <text> Title for rename (or pass trailing title text)
--url <url> Optional URL for new-browser-right
Example:
cmux tab-action --tab tab:3 --action pin
cmux tab-action --action close-right
cmux tab-action --tab tab:2 --action rename --title "build logs"
"""
case "rename-tab":
return """
Usage: cmux rename-tab [--workspace <id|ref>] [--tab <id|ref>] [--surface <id|ref>] [--] <title>
Compatibility alias for tab-action rename.
Resolution order for target tab:
1) --tab
2) --surface
3) $CMUX_TAB_ID / $CMUX_SURFACE_ID
4) currently focused tab (optionally within --workspace)
Flags:
--workspace <id|ref> Workspace context (default: current/$CMUX_WORKSPACE_ID)
--tab <id|ref> Tab target (supports tab:<n> or surface:<n>)
--surface <id|ref> Alias for --tab
--title <text> Explicit title (or use trailing positional title)
Examples:
cmux rename-tab "build logs"
cmux rename-tab --tab tab:3 "staging server"
cmux rename-tab --workspace workspace:2 --surface surface:5 --title "agent run"
"""
case "new-workspace":
return """
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 --cwd ~/projects/myapp
cmux new-workspace --cwd . --command "npm test"
"""
case "list-workspaces":
return """
Usage: cmux list-workspaces
List workspaces in the current window.
Example:
cmux list-workspaces
"""
case "ssh":
return """
Usage: cmux ssh <destination> [flags] [-- <remote-command-args>]
Create a new workspace, mark it as remote-SSH, and start an SSH session in that workspace.
cmux will also establish a local SSH proxy endpoint so browser traffic can egress from the remote host.
Flags:
--name <title> Optional workspace title
--port <n> SSH port
--identity <path> SSH identity file path
--ssh-option <opt> Extra SSH -o option (repeatable)
Example:
cmux ssh dev@my-host
cmux ssh dev@my-host --name "gpu-box" --port 2222 --identity ~/.ssh/id_ed25519
cmux ssh dev@my-host --ssh-option UserKnownHostsFile=/dev/null --ssh-option StrictHostKeyChecking=no
"""
case "remote-daemon-status":
return """
Usage: cmux remote-daemon-status [--os <darwin|linux>] [--arch <arm64|amd64>]
Show the embedded cmuxd-remote release manifest, local cache status, checksum verification state,
and the GitHub attestation verification command for a target platform.
Example:
cmux remote-daemon-status
cmux remote-daemon-status --os linux --arch arm64
"""
case "new-split":
return """
Usage: cmux new-split <left|right|up|down> [flags]
Split the current pane in the given direction.
Flags:
--workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID)
--surface <id|ref> Surface to split from (default: $CMUX_SURFACE_ID)
--panel <id|ref> Alias for --surface
Example:
cmux new-split right
cmux new-split down --workspace workspace:1
"""
case "list-panes":
return """
Usage: cmux list-panes [--workspace <id|ref>]
List panes in a workspace.
Flags:
--workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID)
Example:
cmux list-panes
cmux list-panes --workspace workspace:2
"""
case "list-pane-surfaces":
return """
Usage: cmux list-pane-surfaces [--workspace <id|ref>] [--pane <id|ref>]
List surfaces in a pane.
Flags:
--workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID)
--pane <id|ref> Restrict to a specific pane (default: focused pane)
Example:
cmux list-pane-surfaces
cmux list-pane-surfaces --workspace workspace:2 --pane pane:1
"""
case "tree":
return """
Usage: cmux tree [flags]
Print the hierarchy of windows, workspaces, panes, and surfaces.
Flags:
--all Include all windows (default: current window only)
--workspace <id|ref|index> Show only one workspace
--json Structured JSON output
Output:
Text mode prints a box-drawing tree with markers:
- ◀ active (true focused window/workspace/pane/surface path)
- ◀ here (caller surface where `cmux tree` was invoked)
- workspace [selected]
- pane [focused]
- surface [selected]
Browser surfaces also include their current URL.
Example:
cmux tree
cmux tree --all
cmux tree --workspace workspace:2
cmux --json tree --all
"""
case "focus-pane":
return """
Usage: cmux focus-pane [--pane <id|ref> | <id|ref>] [flags]
Focus the specified pane.
Flags:
--pane <id|ref> Pane to focus (required unless passed positionally)
--workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID)
Example:
cmux focus-pane --pane pane:2
cmux focus-pane pane:1
cmux focus-pane --pane pane:1 --workspace workspace:2
"""
case "new-pane":
return """
Usage: cmux new-pane [flags]
Create a new pane in the workspace.
Flags:
--type <terminal|browser> Pane type (default: terminal)
--direction <left|right|up|down> Split direction (default: right)
--workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID)
--url <url> URL for browser panes
Example:
cmux new-pane
cmux new-pane --type browser --direction down --url https://example.com
"""
case "new-surface":
return """
Usage: cmux new-surface [flags]
Create a new surface (tab) in a pane.
Flags:
--type <terminal|browser> Surface type (default: terminal)
--pane <id|ref> Target pane
--workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID)
--url <url> URL for browser surfaces
Example:
cmux new-surface
cmux new-surface --type browser --pane pane:1 --url https://example.com
"""
case "close-surface":
return """
Usage: cmux close-surface [flags]
Close a surface. Defaults to the focused surface if none specified.
Flags:
--surface <id|ref> Surface to close (default: $CMUX_SURFACE_ID)
--panel <id|ref> Alias for --surface
--workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID)
Example:
cmux close-surface
cmux close-surface --surface surface:3
"""
case "drag-surface-to-split":
return """
Usage: cmux drag-surface-to-split --surface <id|ref> <left|right|up|down>
Drag a surface into a new split in the given direction.
Flags:
--surface <id|ref> Surface to drag (required)
--panel <id|ref> Alias for --surface
Example:
cmux drag-surface-to-split --surface surface:1 right
cmux drag-surface-to-split --panel surface:2 down
"""
case "refresh-surfaces":
return """
Usage: cmux refresh-surfaces
Refresh surface snapshots for the focused workspace.
"""
case "surface-health":
return """
Usage: cmux surface-health [--workspace <id|ref>]
List health details for surfaces in a workspace.
Flags:
--workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID)
Example:
cmux surface-health
cmux surface-health --workspace workspace:2
"""
case "trigger-flash":
return """
Usage: cmux trigger-flash [--workspace <id|ref>] [--surface <id|ref>] [--panel <id|ref>]
Trigger the unread flash indicator for a surface.
Flags:
--workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID)
--surface <id|ref> Target surface (default: $CMUX_SURFACE_ID)
--panel <id|ref> Alias for --surface
Example:
cmux trigger-flash
cmux trigger-flash --workspace workspace:2 --surface surface:3
"""
case "list-panels":
return """
Usage: cmux list-panels [--workspace <id|ref>]
List surfaces (panels) in a workspace.
Flags:
--workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID)
Example:
cmux list-panels
cmux list-panels --workspace workspace:2
"""
case "focus-panel":
return """
Usage: cmux focus-panel --panel <id|ref> [--workspace <id|ref>]
Focus a specific panel (surface).
Flags:
--panel <id|ref> Panel/surface to focus (required)
--workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID)
Example:
cmux focus-panel --panel surface:2
cmux focus-panel --panel surface:5 --workspace workspace:2
"""
case "close-workspace":
return """
Usage: cmux close-workspace --workspace <id|ref|index>
Close the specified workspace.
Flags:
--workspace <id|ref|index> Workspace to close (required)
Example:
cmux close-workspace --workspace workspace:2
"""
case "select-workspace":
return """
Usage: cmux select-workspace --workspace <id|ref|index>
Select (switch to) the specified workspace.
Flags:
--workspace <id|ref|index> Workspace to select (required)
Example:
cmux select-workspace --workspace workspace:2
cmux select-workspace --workspace 0
"""
case "rename-workspace", "rename-window":
return """
Usage: cmux rename-workspace [--workspace <id|ref|index>] [--] <title>
Rename a workspace. Defaults to the current workspace.
tmux-compatible alias: rename-window
Flags:
--workspace <id|ref|index> Workspace to rename (default: current/$CMUX_WORKSPACE_ID)
Example:
cmux rename-workspace "backend logs"
cmux rename-window --workspace workspace:2 "agent run"
"""
case "current-workspace":
return """
Usage: cmux current-workspace
Print the currently selected workspace ID.
"""
case "capture-pane":
return """
Usage: cmux capture-pane [--workspace <id|ref>] [--surface <id|ref>] [--scrollback] [--lines <n>]
tmux-compatible alias for reading terminal text from a pane.
Flags:
--workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID)
--surface <id|ref> Surface context (default: $CMUX_SURFACE_ID)
--scrollback Include scrollback
--lines <n> Return only the last N lines (implies --scrollback)
Example:
cmux capture-pane --workspace workspace:2 --surface surface:1 --scrollback --lines 200
"""
case "resize-pane":
return """
Usage: cmux resize-pane [--pane <id|ref>] [--workspace <id|ref>] [-L|-R|-U|-D] [--amount <n>]
tmux-compatible pane resize command.
Flags:
--pane <id|ref> Pane to resize (default: focused pane)
--workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID)
-L|-R|-U|-D Direction (default: -R)
--amount <n> Resize amount (default: 1)
"""
case "pipe-pane":
return """
Usage: cmux pipe-pane [--workspace <id|ref>] [--surface <id|ref>] [--command <shell-command> | <shell-command>]
Capture pane text and pipe it to a shell command via stdin.
Flags:
--workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID)
--surface <id|ref> Surface context (default: focused surface)
--command <command> Shell command to run (or pass as trailing text)
"""
case "wait-for":
return """
Usage: cmux wait-for [-S|--signal] <name> [--timeout <seconds>]
Wait for or signal a named synchronization token.
Flags:
-S, --signal Signal the token instead of waiting
--timeout <seconds> Wait timeout (default: 30)
"""
case "swap-pane":
return """
Usage: cmux swap-pane --pane <id|ref> --target-pane <id|ref> [--workspace <id|ref>]
Swap two panes.
Flags:
--pane <id|ref> Source pane (required)
--target-pane <id|ref> Target pane (required)
--workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID)
"""
case "break-pane":
return """
Usage: cmux break-pane [--workspace <id|ref>] [--pane <id|ref>] [--surface <id|ref>] [--no-focus]
Move a pane/surface out into its own pane context.
Flags:
--workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID)
--pane <id|ref> Source pane
--surface <id|ref> Source surface
--no-focus Do not focus the result
"""
case "join-pane":
return """
Usage: cmux join-pane --target-pane <id|ref> [--workspace <id|ref>] [--pane <id|ref>] [--surface <id|ref>] [--no-focus]
Join a pane/surface into another pane.
Flags:
--target-pane <id|ref> Target pane (required)
--workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID)
--pane <id|ref> Source pane
--surface <id|ref> Source surface
--no-focus Do not focus the result
"""
case "next-window", "previous-window", "last-window":
return """
Usage: cmux \(command)
Switch workspace selection (next/previous/last) in the current window.
"""
case "last-pane":
return """
Usage: cmux last-pane [--workspace <id|ref>]
Focus the previously focused pane in a workspace.
Flags:
--workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID)
"""
case "find-window":
return """
Usage: cmux find-window [--content] [--select] [query]
Find workspaces by title (and optionally terminal content).
Flags:
--content Search terminal content in addition to workspace titles
--select Select the first match
"""
case "clear-history":
return """
Usage: cmux clear-history [--workspace <id|ref>] [--surface <id|ref>]
Clear terminal scrollback history.
Flags:
--workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID)
--surface <id|ref> Surface context (default: focused surface)
"""
case "set-hook":
return """
Usage: cmux set-hook [--list] [--unset <event>] | <event> <command>
Manage tmux-compat hook definitions.
Flags:
--list List configured hooks
--unset <event> Remove a hook by event name
"""
case "popup":
return """
Usage: cmux popup
tmux compatibility placeholder. This command is currently not supported.
"""
case "bind-key", "unbind-key", "copy-mode":
return """
Usage: cmux \(command)
tmux compatibility placeholder. This command is currently not supported.
"""
case "set-buffer":
return """
Usage: cmux set-buffer [--name <name>] [--] <text>
Save text into a named tmux-compat buffer.
Flags:
--name <name> Buffer name (default: default)
"""
case "paste-buffer":
return """
Usage: cmux paste-buffer [--name <name>] [--workspace <id|ref>] [--surface <id|ref>]
Paste a named tmux-compat buffer into a surface.
Flags:
--name <name> Buffer name (default: default)
--workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID)
--surface <id|ref> Surface context (default: focused surface)
"""
case "list-buffers":
return """
Usage: cmux list-buffers
List tmux-compat buffers.
"""
case "respawn-pane":
return """
Usage: cmux respawn-pane [--workspace <id|ref>] [--surface <id|ref>] [--command <cmd> | <cmd>]
Send a command (or default shell restart command) to a surface.
Flags:
--workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID)
--surface <id|ref> Surface context (default: focused surface)
--command <cmd> Command text (or pass trailing command text)
"""
case "display-message":
return """
Usage: cmux display-message [-p|--print] <text>
Print text (or show it via notification bridge in parity mode).
Flags:
-p, --print Print to stdout only
"""
case "read-screen":
return """
Usage: cmux read-screen [flags]
Read terminal text from a surface as plain text.
Flags:
--workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID)
--surface <id|ref> Target surface (default: $CMUX_SURFACE_ID)
--scrollback Include scrollback (not just visible viewport)
--lines <n> Limit to the last n lines (implies --scrollback)
Example:
cmux read-screen
cmux read-screen --surface surface:2 --scrollback --lines 200
"""
case "send":
return """
Usage: cmux send [flags] [--] <text>
Send text to a terminal surface. Escape sequences: \\n and \\r send Enter, \\t sends Tab.
Flags:
--workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID)
--surface <id|ref> Target surface (default: $CMUX_SURFACE_ID)
Example:
cmux send "echo hello"
cmux send --surface surface:2 "ls -la\\n"
"""
case "send-key":
return """
Usage: cmux send-key [flags] [--] <key>
Send a key event to a terminal surface.
Flags:
--workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID)
--surface <id|ref> Target surface (default: $CMUX_SURFACE_ID)
Example:
cmux send-key enter
cmux send-key --surface surface:2 ctrl+c
"""
case "send-panel":
return """
Usage: cmux send-panel --panel <id|ref> [flags] [--] <text>
Send text to a specific panel (surface). Escape sequences: \\n and \\r send Enter, \\t sends Tab.
Flags:
--panel <id|ref> Target panel (required)
--workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID)
Example:
cmux send-panel --panel surface:2 "echo hello\\n"
"""
case "send-key-panel":
return """
Usage: cmux send-key-panel --panel <id|ref> [flags] [--] <key>
Send a key event to a specific panel (surface).
Flags:
--panel <id|ref> Target panel (required)
--workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID)
Example:
cmux send-key-panel --panel surface:2 enter
cmux send-key-panel --panel surface:2 ctrl+c
"""
case "notify":
return """
Usage: cmux notify [flags]
Send a notification to a workspace/surface.
Flags:
--title <text> Notification title (default: "Notification")
--subtitle <text> Notification subtitle
--body <text> Notification body
--workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID)
--surface <id|ref> Target surface (default: $CMUX_SURFACE_ID)
Example:
cmux notify --title "Build done" --body "All tests passed"
cmux notify --title "Error" --subtitle "test.swift" --body "Line 42: syntax error"
"""
case "list-notifications":
return """
Usage: cmux list-notifications
List queued notifications.
"""
case "clear-notifications":
return """
Usage: cmux clear-notifications
Clear all queued notifications.
"""
case "set-status":
return """
Usage: cmux set-status <key> <value> [flags]
Set a sidebar status entry for a workspace. Status entries appear as
pills in the sidebar tab row. Use a unique key so different tools
(e.g. "claude_code", "build") can manage their own entries.
Flags:
--icon <name> Icon name (e.g. "sparkle", "hammer")
--color <#hex> Pill color (e.g. "#ff9500")
--workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID)
Example:
cmux set-status build "compiling" --icon hammer --color "#ff9500"
cmux set-status deploy "v1.2.3" --workspace workspace:2
"""
case "clear-status":
return """
Usage: cmux clear-status <key> [flags]
Remove a sidebar status entry by key.
Flags:
--workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID)
Example:
cmux clear-status build
"""
case "list-status":
return """
Usage: cmux list-status [flags]
List all sidebar status entries for a workspace.
Flags:
--workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID)
Example:
cmux list-status
cmux list-status --workspace workspace:2
"""
case "set-progress":
return """
Usage: cmux set-progress <0.0-1.0> [flags]
Set a progress bar in the sidebar for a workspace.
Flags:
--label <text> Label shown next to the progress bar
--workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID)
Example:
cmux set-progress 0.5 --label "Building..."
cmux set-progress 1.0 --label "Done"
"""
case "clear-progress":
return """
Usage: cmux clear-progress [flags]
Clear the sidebar progress bar for a workspace.
Flags:
--workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID)
Example:
cmux clear-progress
"""
case "log":
return """
Usage: cmux log [flags] [--] <message>
Append a log entry to the sidebar for a workspace.
Flags:
--level <level> Log level: info, progress, success, warning, error (default: info)
--source <name> Source label (e.g. "build", "test")
--workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID)
Example:
cmux log "Build started"
cmux log --level error --source build "Compilation failed"
cmux log --level success -- "All 42 tests passed"
"""
case "clear-log":
return """
Usage: cmux clear-log [flags]
Clear all sidebar log entries for a workspace.
Flags:
--workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID)
Example:
cmux clear-log
"""
case "list-log":
return """
Usage: cmux list-log [flags]
List sidebar log entries for a workspace.
Flags:
--limit <n> Show only the last N entries
--workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID)
Example:
cmux list-log
cmux list-log --limit 5
"""
case "sidebar-state":
return """
Usage: cmux sidebar-state [flags]
Dump all sidebar metadata for a workspace (cwd, git branch, ports,
status entries, progress, log entries).
Flags:
--workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID)
Example:
cmux sidebar-state
cmux sidebar-state --workspace workspace:2
"""
case "set-app-focus":
return """
Usage: cmux set-app-focus <active|inactive|clear>
Override app focus state for notification routing tests.
Example:
cmux set-app-focus inactive
cmux set-app-focus clear
"""
case "simulate-app-active":
return """
Usage: cmux simulate-app-active
Trigger the app-active handler used by notification focus tests.
"""
case "claude-hook":
return """
Usage: cmux claude-hook <session-start|active|stop|idle|notification|notify|prompt-submit> [flags]
Hook for Claude Code integration. Reads JSON from stdin.
Subcommands:
session-start Signal that a Claude session has started
active Alias for session-start
stop Signal that a Claude session has stopped
idle Alias for stop
notification Forward a Claude notification
notify Alias for notification
prompt-submit Clear notification and set Running on user prompt
Flags:
--workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID)
--surface <id|ref> Target surface (default: $CMUX_SURFACE_ID)
Example:
echo '{"session_id":"abc"}' | cmux claude-hook session-start
echo '{}' | cmux claude-hook stop
"""
case "browser":
return """
Usage: cmux browser [--surface <id|ref|index> | <surface>] <subcommand> [args]
Browser automation commands. Most subcommands require a surface handle.
A surface can be passed as `--surface <handle>` or as the first positional token.
`open`/`open-split`/`new`/`identify` can run without an explicit surface.
Subcommands:
open|open-split|new [url] [--workspace <id|ref|index>] [--window <id|ref|index>]
open/open-split/new default to $CMUX_WORKSPACE_ID when --workspace is omitted and --window is not set
goto|navigate <url> [--snapshot-after]
back|forward|reload [--snapshot-after]
url|get-url
focus-webview | is-webview-focused
snapshot [--interactive|-i] [--cursor] [--compact] [--max-depth <n>] [--selector <css>]
eval [--script <js> | <js>]
wait [--selector <css>] [--text <text>] [--url-contains <text>|--url <text>] [--load-state <interactive|complete>] [--function <js>] [--timeout-ms <ms>|--timeout <seconds>]
click|dblclick|hover|focus|check|uncheck|scroll-into-view [--selector <css> | <css>] [--snapshot-after]
type|fill [--selector <css> | <css>] [--text <text> | <text>] [--snapshot-after]
press|key|keydown|keyup [--key <key> | <key>] [--snapshot-after]
select [--selector <css> | <css>] [--value <value> | <value>] [--snapshot-after]
scroll [--selector <css>] [--dx <n>] [--dy <n>] [--snapshot-after]
screenshot [--out <path>]
get <url|title|text|html|value|attr|count|box|styles> [...]
text|html|value|count|box|styles|attr: [--selector <css> | <css>]
attr: [--attr <name> | <name>]
styles: [--property <name>]
is <visible|enabled|checked> [--selector <css> | <css>]
find <role|text|label|placeholder|alt|title|testid|first|last|nth> [...]
role: [--name <text>] [--exact] <role>
text|label|placeholder|alt|title|testid: [--exact] <text>
first|last: [--selector <css> | <css>]
nth: [--index <n> | <n>] [--selector <css> | <css>]
frame <main|selector> [--selector <css>]
dialog <accept|dismiss> [text]
download [wait] [--path <path>] [--timeout-ms <ms>|--timeout <seconds>]
cookies <get|set|clear> [--name <name>] [--value <value>] [--url <url>] [--domain <domain>] [--path <path>] [--expires <unix>] [--secure] [--all]
storage <local|session> <get|set|clear> [...]
tab <new|list|switch|close|<index>> [...]
console <list|clear>
errors <list|clear>
highlight [--selector <css> | <css>]
state <save|load> <path>
addinitscript|addscript [--script <js> | <js>]
addstyle [--css <css> | <css>]
viewport <width> <height>
geolocation|geo <latitude> <longitude>
offline <true|false>
trace <start|stop> [path]
network <route|unroute|requests> ...
route <pattern> [--abort] [--body <text>]
unroute <pattern>
screencast <start|stop>
input <mouse|keyboard|touch> [args...]
input_mouse | input_keyboard | input_touch
identify [--surface <id|ref|index>]
Example:
cmux browser open https://example.com
cmux browser surface:1 navigate https://google.com
cmux browser --surface surface:1 snapshot --interactive
"""
// Legacy browser aliases point users to `cmux browser --help`
case "open-browser":
return "Legacy alias for 'cmux browser open'. Run 'cmux browser --help' for details."
case "navigate":
return "Legacy alias for 'cmux browser navigate'. Run 'cmux browser --help' for details."
case "browser-back":
return "Legacy alias for 'cmux browser back'. Run 'cmux browser --help' for details."
case "browser-forward":
return "Legacy alias for 'cmux browser forward'. Run 'cmux browser --help' for details."
case "browser-reload":
return "Legacy alias for 'cmux browser reload'. Run 'cmux browser --help' for details."
case "get-url":
return "Legacy alias for 'cmux browser get-url'. Run 'cmux browser --help' for details."
case "focus-webview":
return "Legacy alias for 'cmux browser focus-webview'. Run 'cmux browser --help' for details."
case "is-webview-focused":
return "Legacy alias for 'cmux browser is-webview-focused'. Run 'cmux browser --help' for details."
case "markdown":
return """
Usage: cmux markdown open <path> [options]
cmux markdown <path> (shorthand for 'open')
Open a markdown file in a formatted viewer panel with live file watching.
The file is rendered with rich formatting (headings, code blocks, tables,
lists, blockquotes) and automatically updates when the file changes on disk.
Options:
--workspace <id|ref|index> Target workspace (default: $CMUX_WORKSPACE_ID)
--surface <id|ref|index> Source surface to split from (default: focused surface)
--window <id|ref|index> Target window
Examples:
cmux markdown open plan.md
cmux markdown ~/project/CHANGELOG.md
cmux markdown open ./docs/design.md --workspace 0
"""
default:
return nil
}
}
/// Dispatch help for a subcommand. Returns true if help was printed.
private func dispatchSubcommandHelp(command: String, commandArgs: [String]) -> Bool {
guard commandArgs.contains("--help") || commandArgs.contains("-h") else { return false }
guard let text = subcommandUsage(command) else { return false }
print("cmux \(command)")
print("")
print(text)
return true
}
private func parseOption(_ args: [String], name: String) -> (String?, [String]) {
var remaining: [String] = []
var value: String?
var skipNext = false
var pastTerminator = false
for (idx, arg) in args.enumerated() {
if skipNext {
skipNext = false
continue
}
if arg == "--" {
pastTerminator = true
remaining.append(arg)
continue
}
if !pastTerminator, arg == name, idx + 1 < args.count {
value = args[idx + 1]
skipNext = true
continue
}
remaining.append(arg)
}
return (value, remaining)
}
private func parseRepeatedOption(_ args: [String], name: String) -> ([String], [String]) {
var remaining: [String] = []
var values: [String] = []
var skipNext = false
var pastTerminator = false
for (idx, arg) in args.enumerated() {
if skipNext {
skipNext = false
continue
}
if arg == "--" {
pastTerminator = true
remaining.append(arg)
continue
}
if !pastTerminator, arg == name, idx + 1 < args.count {
values.append(args[idx + 1])
skipNext = true
continue
}
remaining.append(arg)
}
return (values, remaining)
}
private func optionValue(_ args: [String], name: String) -> String? {
guard let index = args.firstIndex(of: name), index + 1 < args.count else { return nil }
return args[index + 1]
}
private func hasFlag(_ args: [String], name: String) -> Bool {
args.contains(name)
}
private func replaceToken(_ args: [String], from: String, to: String) -> [String] {
args.map { $0 == from ? to : $0 }
}
/// Unescape CLI escape sequences to match legacy v1 send behavior.
/// \n and \r carriage return (Enter), \t tab.
private func unescapeSendText(_ text: String) -> String {
return text
.replacingOccurrences(of: "\\n", with: "\r")
.replacingOccurrences(of: "\\r", with: "\r")
.replacingOccurrences(of: "\\t", with: "\t")
}
private func workspaceFromArgsOrEnv(_ args: [String], windowOverride: String? = nil) -> String? {
if let explicit = optionValue(args, name: "--workspace") { return explicit }
// When --window is explicitly targeted, don't fall back to env workspace from a different window
if windowOverride != nil { return nil }
return ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"]
}
/// Pick the display handle for an item dict based on --id-format.
private func textHandle(_ item: [String: Any], idFormat: CLIIDFormat) -> String {
let ref = item["ref"] as? String
let id = item["id"] as? String
switch idFormat {
case .refs: return ref ?? id ?? "?"
case .uuids: return id ?? ref ?? "?"
case .both: return [ref, id].compactMap({ $0 }).joined(separator: " ")
}
}
private func v2OKSummary(_ payload: [String: Any], idFormat: CLIIDFormat, kinds: [String] = ["surface", "workspace"]) -> String {
var parts = ["OK"]
for kind in kinds {
if let handle = formatHandle(payload, kind: kind, idFormat: idFormat) {
parts.append(handle)
}
}
return parts.joined(separator: " ")
}
private struct TreeCommandOptions {
let includeAllWindows: Bool
let workspaceHandle: String?
let jsonOutput: Bool
}
private struct TreePath {
let windowHandle: String?
let workspaceHandle: String?
let paneHandle: String?
let surfaceHandle: String?
}
private func runTreeCommand(
commandArgs: [String],
client: SocketClient,
jsonOutput: Bool,
idFormat: CLIIDFormat
) throws {
let options = try parseTreeCommandOptions(commandArgs)
let payload = try buildTreePayload(options: options, client: client)
if jsonOutput || options.jsonOutput {
print(jsonString(formatIDs(payload, mode: idFormat)))
} else {
let windows = payload["windows"] as? [[String: Any]] ?? []
print(renderTreeText(windows: windows, idFormat: idFormat))
}
}
private func parseTreeCommandOptions(_ args: [String]) throws -> TreeCommandOptions {
let (workspaceOpt, rem0) = parseOption(args, name: "--workspace")
if rem0.contains("--workspace") {
throw CLIError(message: "tree requires --workspace <id|ref|index>")
}
var includeAll = false
var jsonOutput = false
var remaining: [String] = []
for arg in rem0 {
if arg == "--all" {
includeAll = true
continue
}
if arg == "--json" {
jsonOutput = true
continue
}
remaining.append(arg)
}
if let unknown = remaining.first(where: { $0.hasPrefix("--") }) {
throw CLIError(message: "tree: unknown flag '\(unknown)'. Known flags: --all --workspace <id|ref|index> --json")
}
if let extra = remaining.first {
throw CLIError(message: "tree: unexpected argument '\(extra)'")
}
return TreeCommandOptions(includeAllWindows: includeAll, workspaceHandle: workspaceOpt, jsonOutput: jsonOutput)
}
private func buildTreePayload(
options: TreeCommandOptions,
client: SocketClient
) throws -> [String: Any] {
var params: [String: Any] = ["all_windows": options.includeAllWindows]
if let workspaceRaw = options.workspaceHandle {
guard let workspaceHandle = try normalizeWorkspaceHandle(workspaceRaw, client: client) else {
throw CLIError(message: "Invalid workspace handle")
}
params["workspace_id"] = workspaceHandle
}
if let caller = treeCallerContextFromEnvironment() {
params["caller"] = caller
}
do {
let payload = try client.sendV2(method: "system.tree", params: params)
return treePayloadWithMarkers(payload)
} catch let error as CLIError where error.message.hasPrefix("method_not_found:") {
// Back-compat fallback for older servers that don't support system.tree.
return try buildLegacyTreePayload(options: options, params: params, client: client)
}
}
private func buildLegacyTreePayload(
options: TreeCommandOptions,
params: [String: Any],
client: SocketClient
) throws -> [String: Any] {
var identifyParams: [String: Any] = [:]
if let caller = params["caller"] as? [String: Any], !caller.isEmpty {
identifyParams["caller"] = caller
}
let identifyPayload = try client.sendV2(method: "system.identify", params: identifyParams)
let focused = identifyPayload["focused"] as? [String: Any] ?? [:]
let caller = identifyPayload["caller"] as? [String: Any] ?? [:]
let activePath = parseTreePath(payload: focused)
let windows = try buildTreeWindowNodes(options: options, activePath: activePath, client: client)
return treePayloadWithMarkers([
"active": focused.isEmpty ? NSNull() : focused,
"caller": caller.isEmpty ? NSNull() : caller,
"windows": windows
])
}
private func buildTreeWindowNodes(
options: TreeCommandOptions,
activePath: TreePath,
client: SocketClient
) throws -> [[String: Any]] {
let windowsPayload = try client.sendV2(method: "window.list")
let allWindows = windowsPayload["windows"] as? [[String: Any]] ?? []
if let workspaceRaw = options.workspaceHandle {
guard let workspaceHandle = try normalizeWorkspaceHandle(workspaceRaw, client: client) else {
throw CLIError(message: "Invalid workspace handle")
}
let workspaceListPayload = try client.sendV2(method: "workspace.list", params: ["workspace_id": workspaceHandle])
let workspaceWindowHandle = (workspaceListPayload["window_ref"] as? String) ?? (workspaceListPayload["window_id"] as? String)
let window = allWindows.first(where: { treeItemMatchesHandle($0, handle: workspaceWindowHandle) })
?? treeFallbackWindow(from: workspaceListPayload)
let workspaces = workspaceListPayload["workspaces"] as? [[String: Any]] ?? []
if workspaces.isEmpty {
throw CLIError(message: "Workspace not found")
}
let workspaceNodes = try workspaces.map { try buildTreeWorkspaceNode(workspace: $0, activePath: activePath, client: client) }
var node = window
let isActiveWindow = treeItemMatchesHandle(node, handle: activePath.windowHandle)
node["current"] = isActiveWindow
node["active"] = isActiveWindow
node["workspaces"] = workspaceNodes
node["workspace_count"] = workspaceNodes.count
return [node]
}
let targetWindows: [[String: Any]]
if options.includeAllWindows {
targetWindows = allWindows
} else if let currentWindowHandle = activePath.windowHandle {
let currentOnly = allWindows.filter { treeItemMatchesHandle($0, handle: currentWindowHandle) }
targetWindows = currentOnly.isEmpty ? Array(allWindows.prefix(1)) : currentOnly
} else {
targetWindows = Array(allWindows.prefix(1))
}
return try targetWindows.map {
try buildTreeWindowNode(
window: $0,
activePath: activePath,
client: client
)
}
}
private func treeFallbackWindow(from payload: [String: Any]) -> [String: Any] {
let workspaces = payload["workspaces"] as? [[String: Any]] ?? []
let selectedWorkspace = workspaces.first(where: { ($0["selected"] as? Bool) == true })
return [
"id": payload["window_id"] ?? NSNull(),
"ref": payload["window_ref"] ?? NSNull(),
"index": 0,
"key": false,
"visible": true,
"workspace_count": workspaces.count,
"selected_workspace_id": selectedWorkspace?["id"] ?? NSNull(),
"selected_workspace_ref": selectedWorkspace?["ref"] ?? NSNull(),
]
}
private func buildTreeWindowNode(
window: [String: Any],
activePath: TreePath,
client: SocketClient
) throws -> [String: Any] {
var workspaceParams: [String: Any] = [:]
if let windowHandle = treeItemHandle(window) {
workspaceParams["window_id"] = windowHandle
}
let workspacePayload = try client.sendV2(method: "workspace.list", params: workspaceParams)
let workspaces = workspacePayload["workspaces"] as? [[String: Any]] ?? []
let workspaceNodes = try workspaces.map { try buildTreeWorkspaceNode(workspace: $0, activePath: activePath, client: client) }
var windowNode = window
let isActiveWindow = treeItemMatchesHandle(windowNode, handle: activePath.windowHandle)
windowNode["current"] = isActiveWindow
windowNode["active"] = isActiveWindow
windowNode["workspaces"] = workspaceNodes
windowNode["workspace_count"] = workspaceNodes.count
return windowNode
}
private func buildTreeWorkspaceNode(
workspace: [String: Any],
activePath: TreePath,
client: SocketClient
) throws -> [String: Any] {
var workspaceNode = workspace
guard let workspaceHandle = treeItemHandle(workspace) else {
workspaceNode["panes"] = []
return workspaceNode
}
let panePayload = try client.sendV2(method: "pane.list", params: ["workspace_id": workspaceHandle])
let surfacePayload = try client.sendV2(method: "surface.list", params: ["workspace_id": workspaceHandle])
let panes = panePayload["panes"] as? [[String: Any]] ?? []
let surfaces = surfacePayload["surfaces"] as? [[String: Any]] ?? []
let browserURLsByHandle = fetchTreeBrowserURLs(
workspaceHandle: workspaceHandle,
surfaces: surfaces,
client: client
)
var surfacesByPane: [String: [[String: Any]]] = [:]
for surface in surfaces {
var surfaceNode = surface
if surfaceNode["selected"] == nil {
surfaceNode["selected"] = (surfaceNode["selected_in_pane"] as? Bool) == true
}
surfaceNode["active"] = treeItemMatchesHandle(surfaceNode, handle: activePath.surfaceHandle)
let surfaceType = ((surfaceNode["type"] as? String) ?? "").lowercased()
if surfaceType == "browser",
let url = treeBrowserURL(surface: surfaceNode, urlsByHandle: browserURLsByHandle),
!url.isEmpty {
surfaceNode["url"] = url
} else {
surfaceNode["url"] = NSNull()
}
guard let paneHandle = treeRelatedHandle(surfaceNode, refKey: "pane_ref", idKey: "pane_id") else {
continue
}
surfacesByPane[paneHandle, default: []].append(surfaceNode)
}
for paneHandle in surfacesByPane.keys {
surfacesByPane[paneHandle]?.sort {
let lhs = intFromAny($0["index_in_pane"]) ?? intFromAny($0["index"]) ?? Int.max
let rhs = intFromAny($1["index_in_pane"]) ?? intFromAny($1["index"]) ?? Int.max
return lhs < rhs
}
}
let paneNodes: [[String: Any]] = panes.map { pane in
var paneNode = pane
paneNode["active"] = treeItemMatchesHandle(paneNode, handle: activePath.paneHandle)
if let paneHandle = treeItemHandle(paneNode) {
paneNode["surfaces"] = surfacesByPane[paneHandle] ?? []
} else {
paneNode["surfaces"] = []
}
return paneNode
}
workspaceNode["active"] = treeItemMatchesHandle(workspaceNode, handle: activePath.workspaceHandle)
workspaceNode["panes"] = paneNodes
return workspaceNode
}
private func treeItemHandle(_ item: [String: Any]) -> String? {
if let ref = item["ref"] as? String, !ref.isEmpty {
return ref
}
if let id = item["id"] as? String, !id.isEmpty {
return id
}
return nil
}
private func treeRelatedHandle(_ item: [String: Any], refKey: String, idKey: String) -> String? {
if let ref = item[refKey] as? String, !ref.isEmpty {
return ref
}
if let id = item[idKey] as? String, !id.isEmpty {
return id
}
return nil
}
private func parseTreePath(payload: [String: Any]) -> TreePath {
return TreePath(
windowHandle: treeRelatedHandle(payload, refKey: "window_ref", idKey: "window_id"),
workspaceHandle: treeRelatedHandle(payload, refKey: "workspace_ref", idKey: "workspace_id"),
paneHandle: treeRelatedHandle(payload, refKey: "pane_ref", idKey: "pane_id"),
surfaceHandle: treeRelatedHandle(payload, refKey: "surface_ref", idKey: "surface_id")
)
}
private func treeCallerContextFromEnvironment() -> [String: Any]? {
let env = ProcessInfo.processInfo.environment
let workspaceRaw = env["CMUX_WORKSPACE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines)
let surfaceRaw = env["CMUX_SURFACE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines)
var caller: [String: Any] = [:]
if let workspaceRaw, !workspaceRaw.isEmpty {
caller["workspace_id"] = workspaceRaw
}
if let surfaceRaw, !surfaceRaw.isEmpty {
caller["surface_id"] = surfaceRaw
}
return caller.isEmpty ? nil : caller
}
private func treePayloadWithMarkers(_ payload: [String: Any]) -> [String: Any] {
let active = payload["active"] as? [String: Any] ?? [:]
let caller = payload["caller"] as? [String: Any] ?? [:]
let activePath = parseTreePath(payload: active)
let callerPath = parseTreePath(payload: caller)
var result = payload
let windows = payload["windows"] as? [[String: Any]] ?? []
result["windows"] = treeApplyMarkers(windows: windows, activePath: activePath, callerPath: callerPath)
if result["active"] == nil {
result["active"] = active.isEmpty ? NSNull() : active
}
if result["caller"] == nil {
result["caller"] = caller.isEmpty ? NSNull() : caller
}
return result
}
private func treeApplyMarkers(
windows: [[String: Any]],
activePath: TreePath,
callerPath: TreePath
) -> [[String: Any]] {
return windows.map { window in
var windowNode = window
let isActiveWindow = treeItemMatchesHandle(windowNode, handle: activePath.windowHandle)
windowNode["current"] = isActiveWindow
windowNode["active"] = isActiveWindow
let workspaces = window["workspaces"] as? [[String: Any]] ?? []
let workspaceNodes = workspaces.map { workspace in
var workspaceNode = workspace
workspaceNode["active"] = treeItemMatchesHandle(workspaceNode, handle: activePath.workspaceHandle)
let panes = workspace["panes"] as? [[String: Any]] ?? []
let paneNodes = panes.map { pane in
var paneNode = pane
paneNode["active"] = treeItemMatchesHandle(paneNode, handle: activePath.paneHandle)
let surfaces = pane["surfaces"] as? [[String: Any]] ?? []
paneNode["surfaces"] = surfaces.map { surface in
var surfaceNode = surface
surfaceNode["active"] = treeItemMatchesHandle(surfaceNode, handle: activePath.surfaceHandle)
surfaceNode["here"] = treeItemMatchesHandle(surfaceNode, handle: callerPath.surfaceHandle)
return surfaceNode
}
return paneNode
}
workspaceNode["panes"] = paneNodes
return workspaceNode
}
windowNode["workspaces"] = workspaceNodes
return windowNode
}
}
private func fetchTreeBrowserURLs(
workspaceHandle: String,
surfaces: [[String: Any]],
client: SocketClient
) -> [String: String] {
let hasBrowserSurfaces = surfaces.contains {
(($0["type"] as? String) ?? "").lowercased() == "browser"
}
guard hasBrowserSurfaces else { return [:] }
if let payload = try? client.sendV2(
method: "browser.tab.list",
params: ["workspace_id": workspaceHandle]
) {
let tabs = payload["tabs"] as? [[String: Any]] ?? []
var urlByHandle: [String: String] = [:]
for tab in tabs {
guard let url = tab["url"] as? String, !url.isEmpty else { continue }
if let id = tab["id"] as? String, !id.isEmpty {
urlByHandle[id] = url
}
if let ref = tab["ref"] as? String, !ref.isEmpty {
urlByHandle[ref] = url
}
}
return urlByHandle
}
// Fallback for older servers that may not support browser.tab.list.
var fallbackURLs: [String: String] = [:]
for surface in surfaces {
guard ((surface["type"] as? String) ?? "").lowercased() == "browser" else { continue }
guard let surfaceHandle = treeItemHandle(surface) else { continue }
guard let payload = try? client.sendV2(
method: "browser.url.get",
params: ["workspace_id": workspaceHandle, "surface_id": surfaceHandle]
),
let url = payload["url"] as? String,
!url.isEmpty else {
continue
}
fallbackURLs[surfaceHandle] = url
if let id = surface["id"] as? String, !id.isEmpty {
fallbackURLs[id] = url
}
if let ref = surface["ref"] as? String, !ref.isEmpty {
fallbackURLs[ref] = url
}
}
return fallbackURLs
}
private func treeBrowserURL(surface: [String: Any], urlsByHandle: [String: String]) -> String? {
if let id = surface["id"] as? String, let url = urlsByHandle[id] {
return url
}
if let ref = surface["ref"] as? String, let url = urlsByHandle[ref] {
return url
}
if let handle = treeItemHandle(surface), let url = urlsByHandle[handle] {
return url
}
return nil
}
private func treeItemMatchesHandle(_ item: [String: Any], handle: String?) -> Bool {
guard let handle = handle?.trimmingCharacters(in: .whitespacesAndNewlines), !handle.isEmpty else {
return false
}
return (item["id"] as? String) == handle || (item["ref"] as? String) == handle
}
private func renderTreeText(windows: [[String: Any]], idFormat: CLIIDFormat) -> String {
guard !windows.isEmpty else { return "No windows" }
var lines: [String] = []
for window in windows {
lines.append(treeWindowLabel(window, idFormat: idFormat))
let workspaces = window["workspaces"] as? [[String: Any]] ?? []
for (workspaceIndex, workspace) in workspaces.enumerated() {
let workspaceIsLast = workspaceIndex == workspaces.count - 1
let workspaceBranch = workspaceIsLast ? "└── " : "├── "
let workspaceIndent = workspaceIsLast ? " " : ""
lines.append("\(workspaceBranch)\(treeWorkspaceLabel(workspace, idFormat: idFormat))")
let panes = workspace["panes"] as? [[String: Any]] ?? []
for (paneIndex, pane) in panes.enumerated() {
let paneIsLast = paneIndex == panes.count - 1
let paneBranch = paneIsLast ? "└── " : "├── "
let paneIndent = paneIsLast ? " " : ""
lines.append("\(workspaceIndent)\(paneBranch)\(treePaneLabel(pane, idFormat: idFormat))")
let surfaces = pane["surfaces"] as? [[String: Any]] ?? []
for (surfaceIndex, surface) in surfaces.enumerated() {
let surfaceIsLast = surfaceIndex == surfaces.count - 1
let surfaceBranch = surfaceIsLast ? "└── " : "├── "
lines.append("\(workspaceIndent)\(paneIndent)\(surfaceBranch)\(treeSurfaceLabel(surface, idFormat: idFormat))")
}
}
}
}
return lines.joined(separator: "\n")
}
private func treeWindowLabel(_ window: [String: Any], idFormat: CLIIDFormat) -> String {
var parts = ["window \(textHandle(window, idFormat: idFormat))"]
if (window["current"] as? Bool) == true {
parts.append("[current]")
}
if (window["active"] as? Bool) == true {
parts.append("◀ active")
}
return parts.joined(separator: " ")
}
private func treeWorkspaceLabel(_ workspace: [String: Any], idFormat: CLIIDFormat) -> String {
var parts = ["workspace \(textHandle(workspace, idFormat: idFormat))"]
let title = (workspace["title"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !title.isEmpty {
parts.append("\"\(title)\"")
}
if (workspace["selected"] as? Bool) == true {
parts.append("[selected]")
}
if (workspace["active"] as? Bool) == true {
parts.append("◀ active")
}
return parts.joined(separator: " ")
}
private func treePaneLabel(_ pane: [String: Any], idFormat: CLIIDFormat) -> String {
var parts = ["pane \(textHandle(pane, idFormat: idFormat))"]
if (pane["focused"] as? Bool) == true {
parts.append("[focused]")
}
if (pane["active"] as? Bool) == true {
parts.append("◀ active")
}
return parts.joined(separator: " ")
}
private func treeSurfaceLabel(_ surface: [String: Any], idFormat: CLIIDFormat) -> String {
let rawType = ((surface["type"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let surfaceType = rawType.isEmpty ? "unknown" : rawType
var parts = ["surface \(textHandle(surface, idFormat: idFormat))", "[\(surfaceType)]"]
let title = (surface["title"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !title.isEmpty {
parts.append("\"\(title)\"")
}
if (surface["selected"] as? Bool) == true {
parts.append("[selected]")
}
if (surface["active"] as? Bool) == true {
parts.append("◀ active")
}
if (surface["here"] as? Bool) == true {
parts.append("◀ here")
}
if surfaceType.lowercased() == "browser",
let url = surface["url"] as? String,
!url.isEmpty {
parts.append(url)
}
return parts.joined(separator: " ")
}
private func isUUID(_ value: String) -> Bool {
return UUID(uuidString: value) != nil
}
private func jsonString(_ object: Any) -> String {
var options: JSONSerialization.WritingOptions = [.prettyPrinted]
options.insert(.withoutEscapingSlashes)
guard JSONSerialization.isValidJSONObject(object),
let data = try? JSONSerialization.data(withJSONObject: object, options: options),
let output = String(data: data, encoding: .utf8) else {
return "{}"
}
return output
}
private struct TmuxParsedArguments {
var flags: Set<String> = []
var options: [String: [String]] = [:]
var positional: [String] = []
func hasFlag(_ flag: String) -> Bool {
flags.contains(flag)
}
func value(_ flag: String) -> String? {
options[flag]?.last
}
}
private func parseTmuxArguments(
_ args: [String],
valueFlags: Set<String>,
boolFlags: Set<String>
) throws -> TmuxParsedArguments {
var parsed = TmuxParsedArguments()
var index = 0
var pastTerminator = false
while index < args.count {
let arg = args[index]
if pastTerminator {
parsed.positional.append(arg)
index += 1
continue
}
if arg == "--" {
pastTerminator = true
index += 1
continue
}
if !arg.hasPrefix("-") || arg == "-" {
parsed.positional.append(arg)
index += 1
continue
}
if arg.hasPrefix("--") {
parsed.positional.append(arg)
index += 1
continue
}
let cluster = Array(arg.dropFirst())
var cursor = 0
var recognizedArgument = false
while cursor < cluster.count {
let flag = "-" + String(cluster[cursor])
if boolFlags.contains(flag) {
parsed.flags.insert(flag)
cursor += 1
recognizedArgument = true
continue
}
if valueFlags.contains(flag) {
let remainder = String(cluster.dropFirst(cursor + 1))
let value: String
if !remainder.isEmpty {
value = remainder
} else {
guard index + 1 < args.count else {
throw CLIError(message: "\(flag) requires a value")
}
index += 1
value = args[index]
}
parsed.options[flag, default: []].append(value)
recognizedArgument = true
cursor = cluster.count
continue
}
recognizedArgument = false
break
}
if !recognizedArgument {
parsed.positional.append(arg)
}
index += 1
}
return parsed
}
private func splitTmuxCommand(_ args: [String]) throws -> (command: String, args: [String]) {
var index = 0
let globalValueFlags: Set<String> = ["-L", "-S", "-f"]
while index < args.count {
let arg = args[index]
if !arg.hasPrefix("-") || arg == "-" {
return (arg.lowercased(), Array(args.dropFirst(index + 1)))
}
if arg == "--" {
break
}
if let flag = globalValueFlags.first(where: { arg == $0 || arg.hasPrefix($0) }) {
if arg == flag {
index += 1
}
}
index += 1
}
throw CLIError(message: "tmux shim requires a command")
}
private func normalizedTmuxTarget(_ raw: String?) -> String? {
guard let raw else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
private func tmuxWindowSelector(from raw: String?) -> String? {
guard let trimmed = normalizedTmuxTarget(raw) else { return nil }
if trimmed.hasPrefix("%") || trimmed.hasPrefix("pane:") {
return nil
}
if let dot = trimmed.lastIndex(of: ".") {
return String(trimmed[..<dot])
}
return trimmed
}
private func tmuxPaneSelector(from raw: String?) -> String? {
guard let trimmed = normalizedTmuxTarget(raw) else { return nil }
if trimmed.hasPrefix("%") {
return String(trimmed.dropFirst())
}
if trimmed.hasPrefix("pane:") {
return trimmed
}
if let dot = trimmed.lastIndex(of: ".") {
return String(trimmed[trimmed.index(after: dot)...])
}
return nil
}
private func tmuxWorkspaceItems(client: SocketClient) throws -> [[String: Any]] {
let payload = try client.sendV2(method: "workspace.list")
return payload["workspaces"] as? [[String: Any]] ?? []
}
private func tmuxCallerWorkspaceHandle() -> String? {
normalizedTmuxTarget(ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"])
}
private func tmuxCallerPaneHandle() -> String? {
guard let pane = normalizedTmuxTarget(ProcessInfo.processInfo.environment["TMUX_PANE"])
?? normalizedTmuxTarget(ProcessInfo.processInfo.environment["CMUX_PANE_ID"]) else {
return nil
}
return pane.hasPrefix("%") ? String(pane.dropFirst()) : pane
}
private func tmuxCallerSurfaceHandle() -> String? {
normalizedTmuxTarget(ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"])
}
private func tmuxCanonicalPaneId(
_ handle: String,
workspaceId: String,
client: SocketClient
) throws -> String {
if isUUID(handle) {
return handle
}
let payload = try client.sendV2(method: "pane.list", params: ["workspace_id": workspaceId])
let panes = payload["panes"] as? [[String: Any]] ?? []
for pane in panes {
if (pane["ref"] as? String) == handle || (pane["id"] as? String) == handle {
if let id = pane["id"] as? String {
return id
}
}
}
if let index = Int(handle) {
for pane in panes where intFromAny(pane["index"]) == index {
if let id = pane["id"] as? String {
return id
}
}
}
throw CLIError(message: "Pane target not found")
}
private func tmuxCanonicalSurfaceId(
_ handle: String,
workspaceId: String,
client: SocketClient
) throws -> String {
if isUUID(handle) {
return handle
}
let payload = try client.sendV2(method: "surface.list", params: ["workspace_id": workspaceId])
let surfaces = payload["surfaces"] as? [[String: Any]] ?? []
for surface in surfaces {
if (surface["ref"] as? String) == handle || (surface["id"] as? String) == handle {
if let id = surface["id"] as? String {
return id
}
}
}
if let index = Int(handle) {
for surface in surfaces where intFromAny(surface["index"]) == index {
if let id = surface["id"] as? String {
return id
}
}
}
throw CLIError(message: "Surface target not found")
}
private func tmuxWorkspaceIdForPaneHandle(_ handle: String, client: SocketClient) throws -> String? {
guard isUUID(handle) || isHandleRef(handle) else {
return nil
}
let workspaces = try tmuxWorkspaceItems(client: client)
for workspace in workspaces {
guard let workspaceId = workspace["id"] as? String else { continue }
let payload = try client.sendV2(method: "pane.list", params: ["workspace_id": workspaceId])
let panes = payload["panes"] as? [[String: Any]] ?? []
if panes.contains(where: { ($0["id"] as? String) == handle || ($0["ref"] as? String) == handle }) {
return workspaceId
}
}
return nil
}
private func tmuxFocusedPaneId(workspaceId: String, client: SocketClient) throws -> String {
let payload = try client.sendV2(method: "surface.current", params: ["workspace_id": workspaceId])
if let paneId = payload["pane_id"] as? String {
return paneId
}
if let paneRef = payload["pane_ref"] as? String {
return try tmuxCanonicalPaneId(paneRef, workspaceId: workspaceId, client: client)
}
throw CLIError(message: "Pane target not found")
}
private func tmuxResolveWorkspaceTarget(_ raw: String?, client: SocketClient) throws -> String {
guard var token = normalizedTmuxTarget(raw) else {
if let callerWorkspace = tmuxCallerWorkspaceHandle() {
return try resolveWorkspaceId(callerWorkspace, client: client)
}
return try resolveWorkspaceId(nil, client: client)
}
if token == "!" || token == "^" || token == "-" {
let payload = try client.sendV2(method: "workspace.last")
if let workspaceId = payload["workspace_id"] as? String {
return workspaceId
}
throw CLIError(message: "Previous workspace not found")
}
if let dot = token.lastIndex(of: ".") {
token = String(token[..<dot])
}
if let colon = token.lastIndex(of: ":") {
let suffix = token[token.index(after: colon)...]
token = suffix.isEmpty ? String(token[..<colon]) : String(suffix)
}
if token.hasPrefix("@") {
token = String(token.dropFirst())
}
if let resolvedHandle = try? normalizeWorkspaceHandle(token, client: client, allowCurrent: true) {
return try resolveWorkspaceId(resolvedHandle, client: client)
}
let needle = token.trimmingCharacters(in: .whitespacesAndNewlines)
let items = try tmuxWorkspaceItems(client: client)
if let match = items.first(where: {
(($0["title"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines) == needle
}), let id = match["id"] as? String {
return id
}
throw CLIError(message: "Workspace target not found: \(token)")
}
private func tmuxResolvePaneTarget(_ raw: String?, client: SocketClient) throws -> (workspaceId: String, paneId: String) {
let paneSelector = tmuxPaneSelector(from: raw)
let workspaceSelector = tmuxWindowSelector(from: raw)
let workspaceId: String = {
if let workspaceSelector {
return (try? tmuxResolveWorkspaceTarget(workspaceSelector, client: client)) ?? ""
}
if let paneSelector,
let workspaceId = try? tmuxWorkspaceIdForPaneHandle(paneSelector, client: client) {
return workspaceId
}
return (try? tmuxResolveWorkspaceTarget(nil, client: client)) ?? ""
}()
guard !workspaceId.isEmpty else {
throw CLIError(message: "Workspace target not found")
}
let paneId: String
if let paneSelector {
paneId = try tmuxCanonicalPaneId(paneSelector, workspaceId: workspaceId, client: client)
} else if tmuxCallerWorkspaceHandle() == workspaceId,
let callerPane = tmuxCallerPaneHandle(),
let callerPaneId = try? tmuxCanonicalPaneId(callerPane, workspaceId: workspaceId, client: client) {
paneId = callerPaneId
} else {
paneId = try tmuxFocusedPaneId(workspaceId: workspaceId, client: client)
}
return (workspaceId, paneId)
}
private func tmuxSelectedSurfaceId(
workspaceId: String,
paneId: String,
client: SocketClient
) throws -> String {
let payload = try client.sendV2(
method: "pane.surfaces",
params: ["workspace_id": workspaceId, "pane_id": paneId]
)
let surfaces = payload["surfaces"] as? [[String: Any]] ?? []
if let selected = surfaces.first(where: { ($0["selected"] as? Bool) == true }),
let id = selected["id"] as? String {
return id
}
if let first = surfaces.first?["id"] as? String {
return first
}
throw CLIError(message: "Pane has no surface to target")
}
private func tmuxResolveSurfaceTarget(
_ raw: String?,
client: SocketClient
) throws -> (workspaceId: String, paneId: String?, surfaceId: String) {
if tmuxPaneSelector(from: raw) != nil {
let resolved = try tmuxResolvePaneTarget(raw, client: client)
let surfaceId = try tmuxSelectedSurfaceId(
workspaceId: resolved.workspaceId,
paneId: resolved.paneId,
client: client
)
return (resolved.workspaceId, resolved.paneId, surfaceId)
}
let workspaceId = try tmuxResolveWorkspaceTarget(tmuxWindowSelector(from: raw), client: client)
if tmuxWindowSelector(from: raw) == nil,
tmuxCallerWorkspaceHandle() == workspaceId,
let callerSurface = tmuxCallerSurfaceHandle(),
let surfaceId = try? tmuxCanonicalSurfaceId(callerSurface, workspaceId: workspaceId, client: client) {
return (workspaceId, nil, surfaceId)
}
let surfaceId = try resolveSurfaceId(nil, workspaceId: workspaceId, client: client)
return (workspaceId, nil, surfaceId)
}
private func tmuxRenderFormat(
_ format: String?,
context: [String: String],
fallback: String
) -> String {
guard let format, !format.isEmpty else { return fallback }
var rendered = format
for (key, value) in context {
rendered = rendered.replacingOccurrences(of: "#{\(key)}", with: value)
}
rendered = rendered.replacingOccurrences(
of: "#\\{[^}]+\\}",
with: "",
options: .regularExpression
)
let trimmed = rendered.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? fallback : trimmed
}
private func tmuxFormatContext(
workspaceId: String,
paneId: String? = nil,
surfaceId: String? = nil,
client: SocketClient
) throws -> [String: String] {
let canonicalWorkspaceId = try resolveWorkspaceId(workspaceId, client: client)
var context: [String: String] = [
"session_name": "cmux",
"window_id": "@\(canonicalWorkspaceId)",
"window_uuid": canonicalWorkspaceId
]
let workspaceItems = try tmuxWorkspaceItems(client: client)
if let workspace = workspaceItems.first(where: {
($0["id"] as? String) == canonicalWorkspaceId || ($0["ref"] as? String) == workspaceId
}) {
if let index = intFromAny(workspace["index"]) {
context["window_index"] = String(index)
}
let title = ((workspace["title"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if !title.isEmpty {
context["window_name"] = title
}
}
let currentPayload = try client.sendV2(method: "surface.current", params: ["workspace_id": canonicalWorkspaceId])
let resolvedPaneId: String? = try {
if let paneId {
return try tmuxCanonicalPaneId(paneId, workspaceId: canonicalWorkspaceId, client: client)
}
if let currentPaneId = currentPayload["pane_id"] as? String {
return currentPaneId
}
if let currentPaneRef = currentPayload["pane_ref"] as? String {
return try tmuxCanonicalPaneId(currentPaneRef, workspaceId: canonicalWorkspaceId, client: client)
}
return nil
}()
let resolvedSurfaceId: String? = try {
if let surfaceId {
return try tmuxCanonicalSurfaceId(surfaceId, workspaceId: canonicalWorkspaceId, client: client)
}
if let resolvedPaneId {
return try tmuxSelectedSurfaceId(
workspaceId: canonicalWorkspaceId,
paneId: resolvedPaneId,
client: client
)
}
return currentPayload["surface_id"] as? String
}()
if let resolvedPaneId {
context["pane_id"] = "%\(resolvedPaneId)"
context["pane_uuid"] = resolvedPaneId
let panePayload = try client.sendV2(method: "pane.list", params: ["workspace_id": canonicalWorkspaceId])
let panes = panePayload["panes"] as? [[String: Any]] ?? []
if let pane = panes.first(where: { ($0["id"] as? String) == resolvedPaneId }),
let index = intFromAny(pane["index"]) {
context["pane_index"] = String(index)
}
}
if let resolvedSurfaceId {
context["surface_id"] = resolvedSurfaceId
let surfacePayload = try client.sendV2(method: "surface.list", params: ["workspace_id": canonicalWorkspaceId])
let surfaces = surfacePayload["surfaces"] as? [[String: Any]] ?? []
if let surface = surfaces.first(where: { ($0["id"] as? String) == resolvedSurfaceId }) {
let title = ((surface["title"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if !title.isEmpty {
context["pane_title"] = title
context["window_name"] = context["window_name"] ?? title
}
}
}
return context
}
private func tmuxShellQuote(_ value: String) -> String {
"'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'"
}
private func tmuxShellCommandText(commandTokens: [String], cwd: String?) -> String? {
let trimmedCwd = cwd?.trimmingCharacters(in: .whitespacesAndNewlines)
let commandText = commandTokens.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
guard (trimmedCwd?.isEmpty == false) || !commandText.isEmpty else {
return nil
}
var pieces: [String] = []
if let trimmedCwd, !trimmedCwd.isEmpty {
pieces.append("cd -- \(tmuxShellQuote(resolvePath(trimmedCwd)))")
}
if !commandText.isEmpty {
pieces.append(commandText)
}
return pieces.joined(separator: " && ") + "\r"
}
private func tmuxSpecialKeyText(_ token: String) -> String? {
switch token.lowercased() {
case "enter", "c-m", "kpenter":
return "\r"
case "tab", "c-i":
return "\t"
case "space":
return " "
case "bspace", "backspace":
return "\u{7f}"
case "escape", "esc", "c-[":
return "\u{1b}"
case "c-c":
return "\u{03}"
case "c-d":
return "\u{04}"
case "c-z":
return "\u{1a}"
case "c-l":
return "\u{0c}"
default:
return nil
}
}
private func tmuxSendKeysText(from tokens: [String], literal: Bool) -> String {
if literal {
return tokens.joined(separator: " ")
}
var result = ""
var pendingSpace = false
for token in tokens {
if let special = tmuxSpecialKeyText(token) {
result += special
pendingSpace = false
continue
}
if pendingSpace {
result += " "
}
result += token
pendingSpace = true
}
return result
}
private func prependPathEntries(_ newEntries: [String], to currentPath: String?) -> String {
var ordered: [String] = []
var seen: Set<String> = []
for entry in newEntries + (currentPath?.split(separator: ":").map(String.init) ?? []) where !entry.isEmpty {
if seen.insert(entry).inserted {
ordered.append(entry)
}
}
return ordered.joined(separator: ":")
}
private struct ClaudeTeamsFocusedContext {
let socketPath: String
let workspaceId: String
let windowId: String?
let paneHandle: String
let paneId: String?
let surfaceId: String?
}
private func claudeTeamsResolvedSocketPath(processEnvironment: [String: String]) -> String {
let envSocketPath: String? = {
for key in ["CMUX_SOCKET_PATH", "CMUX_SOCKET"] {
guard let raw = processEnvironment[key] else { continue }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
return trimmed
}
}
return nil
}()
let requestedSocketPath = envSocketPath ?? CLISocketPathResolver.defaultSocketPath
let source: CLISocketPathSource
if let envSocketPath {
source = envSocketPath == CLISocketPathResolver.defaultSocketPath ? .implicitDefault : .environment
} else {
source = .implicitDefault
}
return CLISocketPathResolver.resolve(
requestedPath: requestedSocketPath,
source: source,
environment: processEnvironment
)
}
private func claudeTeamsFocusedContext(
processEnvironment: [String: String],
explicitPassword: String?
) -> ClaudeTeamsFocusedContext? {
let socketPath = claudeTeamsResolvedSocketPath(processEnvironment: processEnvironment)
let client = SocketClient(path: socketPath)
do {
try client.connect()
try authenticateClientIfNeeded(
client,
explicitPassword: explicitPassword,
socketPath: socketPath
)
defer { client.close() }
let payload = try client.sendV2(method: "system.identify")
let focused = payload["focused"] as? [String: Any] ?? [:]
let workspaceId = (focused["workspace_id"] as? String)
?? (focused["workspace_ref"] as? String)
let paneId = (focused["pane_id"] as? String)
?? (focused["pane_ref"] as? String)
guard let workspaceId, let paneId else {
return nil
}
let paneHandle = paneId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !paneHandle.isEmpty else {
return nil
}
let windowId = (focused["window_id"] as? String)
?? (focused["window_ref"] as? String)
let surfaceId = (focused["surface_id"] as? String)
?? (focused["surface_ref"] as? String)
return ClaudeTeamsFocusedContext(
socketPath: socketPath,
workspaceId: workspaceId,
windowId: windowId,
paneHandle: paneHandle,
paneId: focused["pane_id"] as? String,
surfaceId: surfaceId
)
} catch {
client.close()
return nil
}
}
private func isCmuxClaudeWrapper(at path: String) -> Bool {
guard let data = FileManager.default.contents(atPath: path) else { return false }
let prefixData = data.prefix(512)
guard let prefix = String(data: prefixData, encoding: .utf8) else { return false }
return prefix.contains("cmux claude wrapper - injects hooks and session tracking")
}
private func resolveClaudeExecutable(searchPath: String?) -> String? {
let entries = searchPath?.split(separator: ":").map(String.init) ?? []
for entry in entries where !entry.isEmpty {
let candidate = URL(fileURLWithPath: entry, isDirectory: true)
.appendingPathComponent("claude", isDirectory: false)
.path
guard FileManager.default.isExecutableFile(atPath: candidate) else { continue }
guard !isCmuxClaudeWrapper(at: candidate) else { continue }
return candidate
}
return nil
}
private func claudeTeamsHasExplicitTeammateMode(commandArgs: [String]) -> Bool {
commandArgs.contains { arg in
arg == "--teammate-mode" || arg.hasPrefix("--teammate-mode=")
}
}
private func claudeTeamsLaunchArguments(commandArgs: [String]) -> [String] {
guard !claudeTeamsHasExplicitTeammateMode(commandArgs: commandArgs) else {
return commandArgs
}
return ["--teammate-mode", "auto"] + commandArgs
}
private func configureClaudeTeamsEnvironment(
processEnvironment: [String: String],
shimDirectory: URL,
executablePath: String,
socketPath: String,
explicitPassword: String?,
focusedContext: ClaudeTeamsFocusedContext?
) {
let updatedPath = prependPathEntries(
[shimDirectory.path],
to: processEnvironment["PATH"]
)
let fakeTmuxValue: String = {
if let focusedContext {
let windowToken = focusedContext.windowId ?? focusedContext.workspaceId
return "/tmp/cmux-claude-teams/\(focusedContext.workspaceId),\(windowToken),\(focusedContext.paneHandle)"
}
return processEnvironment["TMUX"] ?? "/tmp/cmux-claude-teams/default,0,0"
}()
let fakeTmuxPane = focusedContext.map { "%\($0.paneHandle)" }
?? processEnvironment["TMUX_PANE"]
?? "%1"
let fakeTerm = processEnvironment["CMUX_CLAUDE_TEAMS_TERM"] ?? "screen-256color"
setenv("CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS", "1", 1)
setenv("CMUX_CLAUDE_TEAMS_CMUX_BIN", executablePath, 1)
setenv("PATH", updatedPath, 1)
setenv("TMUX", fakeTmuxValue, 1)
setenv("TMUX_PANE", fakeTmuxPane, 1)
setenv("TERM", fakeTerm, 1)
setenv("CMUX_SOCKET_PATH", socketPath, 1)
setenv("CMUX_SOCKET", socketPath, 1)
if let explicitPassword,
!explicitPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
setenv("CMUX_SOCKET_PASSWORD", explicitPassword, 1)
}
unsetenv("TERM_PROGRAM")
if let focusedContext {
setenv("CMUX_WORKSPACE_ID", focusedContext.workspaceId, 1)
if let surfaceId = focusedContext.surfaceId, !surfaceId.isEmpty {
setenv("CMUX_SURFACE_ID", surfaceId, 1)
}
}
}
private func createClaudeTeamsShimDirectory() throws -> URL {
let homePath = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory()
let rootPath = URL(fileURLWithPath: homePath, isDirectory: true)
.appendingPathComponent(".cmuxterm", isDirectory: true)
.appendingPathComponent("claude-teams-bin", isDirectory: true)
.path
let root = URL(fileURLWithPath: rootPath, isDirectory: true)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true, attributes: nil)
let tmuxURL = root.appendingPathComponent("tmux", isDirectory: false)
let script = """
#!/usr/bin/env bash
set -euo pipefail
exec "${CMUX_CLAUDE_TEAMS_CMUX_BIN:-cmux}" __tmux-compat "$@"
"""
let normalizedScript = script.trimmingCharacters(in: .whitespacesAndNewlines)
let existingScript = try? String(contentsOf: tmuxURL, encoding: .utf8)
if existingScript?.trimmingCharacters(in: .whitespacesAndNewlines) != normalizedScript {
try script.write(to: tmuxURL, atomically: false, encoding: .utf8)
}
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tmuxURL.path)
return root
}
private func runClaudeTeams(
commandArgs: [String],
socketPath: String,
explicitPassword: String?
) throws {
let processEnvironment = ProcessInfo.processInfo.environment
var launcherEnvironment = processEnvironment
launcherEnvironment["CMUX_SOCKET_PATH"] = socketPath
launcherEnvironment["CMUX_SOCKET"] = socketPath
if let explicitPassword,
!explicitPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
launcherEnvironment["CMUX_SOCKET_PASSWORD"] = explicitPassword
}
let shimDirectory = try createClaudeTeamsShimDirectory()
let executablePath = resolvedExecutableURL()?.path ?? (args.first ?? "cmux")
let focusedContext = claudeTeamsFocusedContext(
processEnvironment: launcherEnvironment,
explicitPassword: explicitPassword
)
let bundledClaudePath = resolvedExecutableURL()?
.deletingLastPathComponent()
.appendingPathComponent("claude", isDirectory: false)
.path
let claudeExecutablePath = resolveClaudeExecutable(searchPath: launcherEnvironment["PATH"])
?? {
guard let bundledClaudePath,
FileManager.default.isExecutableFile(atPath: bundledClaudePath) else { return nil }
return bundledClaudePath
}()
configureClaudeTeamsEnvironment(
processEnvironment: launcherEnvironment,
shimDirectory: shimDirectory,
executablePath: executablePath,
socketPath: socketPath,
explicitPassword: explicitPassword,
focusedContext: focusedContext
)
let launchPath = claudeExecutablePath ?? "claude"
let launchArguments = claudeTeamsLaunchArguments(commandArgs: commandArgs)
var argv = ([launchPath] + launchArguments).map { strdup($0) }
defer {
for item in argv {
free(item)
}
}
argv.append(nil)
if claudeExecutablePath != nil {
execv(launchPath, &argv)
} else {
execvp("claude", &argv)
}
let code = errno
throw CLIError(message: "Failed to launch claude: \(String(cString: strerror(code)))")
}
private func runClaudeTeamsTmuxCompat(
commandArgs: [String],
client: SocketClient,
jsonOutput: Bool,
idFormat: CLIIDFormat,
windowOverride: String?
) throws {
let (command, rawArgs) = try splitTmuxCommand(commandArgs)
switch command {
case "new-session", "new":
let parsed = try parseTmuxArguments(
rawArgs,
valueFlags: ["-c", "-F", "-n", "-s"],
boolFlags: ["-A", "-d", "-P"]
)
if parsed.hasFlag("-A") {
throw CLIError(message: "new-session -A is not supported in cmux claude-teams mode")
}
var params: [String: Any] = ["focus": false]
if let cwd = parsed.value("-c") {
params["cwd"] = resolvePath(cwd)
}
let created = try client.sendV2(method: "workspace.create", params: params)
guard let workspaceId = created["workspace_id"] as? String else {
throw CLIError(message: "workspace.create did not return workspace_id")
}
if let title = parsed.value("-n") ?? parsed.value("-s"),
!title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
_ = try client.sendV2(method: "workspace.rename", params: [
"workspace_id": workspaceId,
"title": title
])
}
if let text = tmuxShellCommandText(commandTokens: parsed.positional, cwd: parsed.value("-c")) {
Thread.sleep(forTimeInterval: 0.3)
let surfaceId = try resolveSurfaceId(nil, workspaceId: workspaceId, client: client)
_ = try client.sendV2(method: "surface.send_text", params: [
"workspace_id": workspaceId,
"surface_id": surfaceId,
"text": text
])
}
if parsed.hasFlag("-P") {
let context = try tmuxFormatContext(workspaceId: workspaceId, client: client)
print(tmuxRenderFormat(parsed.value("-F"), context: context, fallback: "@\(workspaceId)"))
}
case "new-window", "neww":
let parsed = try parseTmuxArguments(
rawArgs,
valueFlags: ["-c", "-F", "-n", "-t"],
boolFlags: ["-d", "-P"]
)
if parsed.value("-t") != nil {
throw CLIError(message: "new-window -t is not supported in cmux claude-teams mode")
}
var params: [String: Any] = ["focus": false]
if let cwd = parsed.value("-c") {
params["cwd"] = resolvePath(cwd)
}
let created = try client.sendV2(method: "workspace.create", params: params)
guard let workspaceId = created["workspace_id"] as? String else {
throw CLIError(message: "workspace.create did not return workspace_id")
}
if let title = parsed.value("-n"),
!title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
_ = try client.sendV2(method: "workspace.rename", params: [
"workspace_id": workspaceId,
"title": title
])
}
if let text = tmuxShellCommandText(commandTokens: parsed.positional, cwd: parsed.value("-c")) {
Thread.sleep(forTimeInterval: 0.3)
let surfaceId = try resolveSurfaceId(nil, workspaceId: workspaceId, client: client)
_ = try client.sendV2(method: "surface.send_text", params: [
"workspace_id": workspaceId,
"surface_id": surfaceId,
"text": text
])
}
if parsed.hasFlag("-P") {
let context = try tmuxFormatContext(workspaceId: workspaceId, client: client)
print(tmuxRenderFormat(parsed.value("-F"), context: context, fallback: "@\(workspaceId)"))
}
case "split-window", "splitw":
let parsed = try parseTmuxArguments(
rawArgs,
valueFlags: ["-c", "-F", "-l", "-t"],
boolFlags: ["-P", "-b", "-d", "-h", "-v"]
)
let target = try tmuxResolveSurfaceTarget(parsed.value("-t"), client: client)
let direction: String
if parsed.hasFlag("-h") {
direction = parsed.hasFlag("-b") ? "left" : "right"
} else {
direction = parsed.hasFlag("-b") ? "up" : "down"
}
let created = try client.sendV2(method: "surface.split", params: [
"workspace_id": target.workspaceId,
"surface_id": target.surfaceId,
"direction": direction
])
guard let surfaceId = created["surface_id"] as? String else {
throw CLIError(message: "surface.split did not return surface_id")
}
let paneId = created["pane_id"] as? String
// Keep the leader pane focused while Claude starts teammates beside it.
if let text = tmuxShellCommandText(commandTokens: parsed.positional, cwd: parsed.value("-c")) {
Thread.sleep(forTimeInterval: 0.3)
_ = try client.sendV2(method: "surface.send_text", params: [
"workspace_id": target.workspaceId,
"surface_id": surfaceId,
"text": text
])
}
if parsed.hasFlag("-P") {
let context = try tmuxFormatContext(
workspaceId: target.workspaceId,
paneId: paneId,
surfaceId: surfaceId,
client: client
)
let fallback = context["pane_id"] ?? surfaceId
print(tmuxRenderFormat(parsed.value("-F"), context: context, fallback: fallback))
}
case "select-window", "selectw":
let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: [])
let workspaceId = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client)
_ = try client.sendV2(method: "workspace.select", params: ["workspace_id": workspaceId])
case "select-pane", "selectp":
let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-P", "-T", "-t"], boolFlags: [])
if parsed.value("-P") != nil || parsed.value("-T") != nil {
return
}
let target = try tmuxResolvePaneTarget(parsed.value("-t"), client: client)
_ = try client.sendV2(method: "pane.focus", params: [
"workspace_id": target.workspaceId,
"pane_id": target.paneId
])
case "kill-window", "killw":
let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: [])
let workspaceId = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client)
_ = try client.sendV2(method: "workspace.close", params: ["workspace_id": workspaceId])
case "kill-pane", "killp":
let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: [])
let target = try tmuxResolveSurfaceTarget(parsed.value("-t"), client: client)
_ = try client.sendV2(method: "surface.close", params: [
"workspace_id": target.workspaceId,
"surface_id": target.surfaceId
])
case "send-keys", "send":
let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: ["-l"])
let target = try tmuxResolveSurfaceTarget(parsed.value("-t"), client: client)
let text = tmuxSendKeysText(from: parsed.positional, literal: parsed.hasFlag("-l"))
if !text.isEmpty {
_ = try client.sendV2(method: "surface.send_text", params: [
"workspace_id": target.workspaceId,
"surface_id": target.surfaceId,
"text": text
])
}
case "capture-pane", "capturep":
let parsed = try parseTmuxArguments(
rawArgs,
valueFlags: ["-E", "-S", "-t"],
boolFlags: ["-J", "-N", "-p"]
)
let target = try tmuxResolveSurfaceTarget(parsed.value("-t"), client: client)
var params: [String: Any] = [
"workspace_id": target.workspaceId,
"surface_id": target.surfaceId,
"scrollback": true
]
if let start = parsed.value("-S"), let lines = Int(start), lines < 0 {
params["lines"] = abs(lines)
}
let payload = try client.sendV2(method: "surface.read_text", params: params)
let text = (payload["text"] as? String) ?? ""
if parsed.hasFlag("-p") {
print(text)
} else {
var store = loadTmuxCompatStore()
store.buffers["default"] = text
try saveTmuxCompatStore(store)
}
case "display-message", "display", "displayp":
let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-F", "-t"], boolFlags: ["-p"])
let target = try tmuxResolveSurfaceTarget(parsed.value("-t"), client: client)
let context = try tmuxFormatContext(
workspaceId: target.workspaceId,
paneId: target.paneId,
surfaceId: target.surfaceId,
client: client
)
let format = parsed.positional.isEmpty ? parsed.value("-F") : parsed.positional.joined(separator: " ")
let rendered = tmuxRenderFormat(format, context: context, fallback: "")
if parsed.hasFlag("-p") || !rendered.isEmpty {
print(rendered)
}
case "list-windows", "lsw":
let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-F", "-t"], boolFlags: [])
let items = try tmuxWorkspaceItems(client: client)
for item in items {
guard let workspaceId = item["id"] as? String else { continue }
let context = try tmuxFormatContext(workspaceId: workspaceId, client: client)
let fallback = [
context["window_index"] ?? "?",
context["window_name"] ?? workspaceId
].joined(separator: " ")
print(tmuxRenderFormat(parsed.value("-F"), context: context, fallback: fallback))
}
case "list-panes", "lsp":
let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-F", "-t"], boolFlags: [])
let workspaceId = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client)
let payload = try client.sendV2(method: "pane.list", params: ["workspace_id": workspaceId])
let panes = payload["panes"] as? [[String: Any]] ?? []
for pane in panes {
guard let paneId = pane["id"] as? String else { continue }
let context = try tmuxFormatContext(workspaceId: workspaceId, paneId: paneId, client: client)
let fallback = context["pane_id"] ?? paneId
print(tmuxRenderFormat(parsed.value("-F"), context: context, fallback: fallback))
}
case "rename-window", "renamew":
let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: [])
let title = parsed.positional.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
guard !title.isEmpty else {
throw CLIError(message: "rename-window requires a title")
}
let workspaceId = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client)
_ = try client.sendV2(method: "workspace.rename", params: [
"workspace_id": workspaceId,
"title": title
])
case "resize-pane", "resizep":
let parsed = try parseTmuxArguments(
rawArgs,
valueFlags: ["-t", "-x", "-y"],
boolFlags: ["-D", "-L", "-R", "-U"]
)
let hasDirectionalFlags = parsed.hasFlag("-L")
|| parsed.hasFlag("-R")
|| parsed.hasFlag("-U")
|| parsed.hasFlag("-D")
if !hasDirectionalFlags {
return
}
let target = try tmuxResolvePaneTarget(parsed.value("-t"), client: client)
let direction: String
if parsed.hasFlag("-L") {
direction = "left"
} else if parsed.hasFlag("-U") {
direction = "up"
} else if parsed.hasFlag("-D") {
direction = "down"
} else {
direction = "right"
}
let rawAmount = (parsed.value("-x") ?? parsed.value("-y") ?? "5")
.replacingOccurrences(of: "%", with: "")
let amount = Int(rawAmount) ?? 5
_ = try client.sendV2(method: "pane.resize", params: [
"workspace_id": target.workspaceId,
"pane_id": target.paneId,
"direction": direction,
"amount": max(1, amount)
])
case "wait-for":
try runTmuxCompatCommand(
command: "wait-for",
commandArgs: rawArgs,
client: client,
jsonOutput: jsonOutput,
idFormat: idFormat,
windowOverride: windowOverride
)
case "last-pane":
let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: [])
let workspaceId = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client)
_ = try client.sendV2(method: "pane.last", params: ["workspace_id": workspaceId])
case "show-buffer", "showb":
let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-b"], boolFlags: [])
let name = parsed.value("-b") ?? "default"
let store = loadTmuxCompatStore()
if let buffer = store.buffers[name] {
print(buffer)
}
case "save-buffer", "saveb":
let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-b"], boolFlags: [])
let name = parsed.value("-b") ?? "default"
let store = loadTmuxCompatStore()
guard let buffer = store.buffers[name] else {
throw CLIError(message: "Buffer not found: \(name)")
}
if let outputPath = parsed.positional.last, !outputPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
try buffer.write(toFile: resolvePath(outputPath), atomically: true, encoding: .utf8)
} else {
print(buffer)
}
case "last-window", "next-window", "previous-window", "set-hook", "set-buffer", "list-buffers":
try runTmuxCompatCommand(
command: command,
commandArgs: rawArgs,
client: client,
jsonOutput: jsonOutput,
idFormat: idFormat,
windowOverride: windowOverride
)
case "has-session", "has":
let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: [])
_ = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client)
case "select-layout", "set-option", "set", "set-window-option", "setw", "source-file", "refresh-client", "attach-session", "detach-client":
return
default:
throw CLIError(message: "Unsupported tmux compatibility command: \(command)")
}
}
private struct TmuxCompatStore: Codable {
var buffers: [String: String] = [:]
var hooks: [String: String] = [:]
}
private func tmuxCompatStoreURL() -> URL {
let root = NSString(string: "~/.cmuxterm").expandingTildeInPath
return URL(fileURLWithPath: root).appendingPathComponent("tmux-compat-store.json")
}
private func loadTmuxCompatStore() -> TmuxCompatStore {
let url = tmuxCompatStoreURL()
guard let data = try? Data(contentsOf: url),
let decoded = try? JSONDecoder().decode(TmuxCompatStore.self, from: data) else {
return TmuxCompatStore()
}
return decoded
}
private func saveTmuxCompatStore(_ store: TmuxCompatStore) throws {
let url = tmuxCompatStoreURL()
let parent = url.deletingLastPathComponent()
try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true, attributes: nil)
let data = try JSONEncoder().encode(store)
try data.write(to: url, options: .atomic)
}
private func runShellCommand(_ command: String, stdinText: String) throws -> (status: Int32, stdout: String, stderr: String) {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
process.arguments = ["-lc", command]
let stdinPipe = Pipe()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.standardInput = stdinPipe
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
try process.run()
if let data = stdinText.data(using: .utf8) {
stdinPipe.fileHandleForWriting.write(data)
}
stdinPipe.fileHandleForWriting.closeFile()
process.waitUntilExit()
let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
return (process.terminationStatus, stdout, stderr)
}
private func tmuxWaitForSignalURL(name: String) -> URL {
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-"))
let sanitized = name.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" }
return URL(fileURLWithPath: "/tmp/cmux-wait-for-\(String(sanitized)).sig")
}
private func runTmuxCompatCommand(
command: String,
commandArgs: [String],
client: SocketClient,
jsonOutput: Bool,
idFormat: CLIIDFormat,
windowOverride: String?
) throws {
switch command {
case "capture-pane":
let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace")
let (sfArg, rem1) = parseOption(rem0, name: "--surface")
let (linesArg, rem2) = parseOption(rem1, name: "--lines")
let workspaceArg = wsArg ?? (windowOverride == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let surfaceArg = sfArg ?? (wsArg == nil && windowOverride == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil)
var params: [String: Any] = [:]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client)
if let wsId { params["workspace_id"] = wsId }
let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId)
if let sfId { params["surface_id"] = sfId }
let includeScrollback = rem2.contains("--scrollback")
if includeScrollback {
params["scrollback"] = true
}
if let linesArg {
guard let lineCount = Int(linesArg), lineCount > 0 else {
throw CLIError(message: "--lines must be greater than 0")
}
params["lines"] = lineCount
params["scrollback"] = true
}
let payload = try client.sendV2(method: "surface.read_text", params: params)
if jsonOutput {
print(jsonString(payload))
} else {
print((payload["text"] as? String) ?? "")
}
case "resize-pane":
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride)
let paneArg = optionValue(commandArgs, name: "--pane")
let amountArg = optionValue(commandArgs, name: "--amount")
let amount = Int(amountArg ?? "1") ?? 1
if amount <= 0 {
throw CLIError(message: "--amount must be greater than 0")
}
let direction: String = {
if commandArgs.contains("-L") { return "left" }
if commandArgs.contains("-R") { return "right" }
if commandArgs.contains("-U") { return "up" }
if commandArgs.contains("-D") { return "down" }
return "right"
}()
var params: [String: Any] = ["direction": direction, "amount": amount]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client)
if let wsId { params["workspace_id"] = wsId }
let paneId = try normalizePaneHandle(paneArg, client: client, workspaceHandle: wsId, allowFocused: true)
if let paneId { params["pane_id"] = paneId }
let payload = try client.sendV2(method: "pane.resize", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["pane"]))
case "pipe-pane":
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride)
let surfaceArg = optionValue(commandArgs, name: "--surface")
let (cmdOpt, rem0) = parseOption(commandArgs, name: "--command")
let commandText: String = {
if let cmdOpt { return cmdOpt }
let trimmed = rem0.dropFirst(rem0.first == "--" ? 1 : 0).joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed
}()
guard !commandText.isEmpty else {
throw CLIError(message: "pipe-pane requires --command <shell-command>")
}
var params: [String: Any] = ["scrollback": true]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true)
if let wsId { params["workspace_id"] = wsId }
let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId, allowFocused: true)
if let sfId { params["surface_id"] = sfId }
let payload = try client.sendV2(method: "surface.read_text", params: params)
let text = (payload["text"] as? String) ?? ""
let shell = try runShellCommand(commandText, stdinText: text)
if shell.status != 0 {
throw CLIError(message: "pipe-pane command failed (\(shell.status)): \(shell.stderr)")
}
if jsonOutput {
print(jsonString([
"ok": true,
"status": shell.status,
"stdout": shell.stdout,
"stderr": shell.stderr
]))
} else {
if !shell.stdout.isEmpty {
print(shell.stdout, terminator: "")
}
print("OK")
}
case "wait-for":
let signal = commandArgs.contains("-S") || commandArgs.contains("--signal")
let timeoutRaw = optionValue(commandArgs, name: "--timeout")
let timeout = timeoutRaw.flatMap { Double($0) } ?? 30.0
let name = commandArgs.first(where: { !$0.hasPrefix("-") }) ?? ""
guard !name.isEmpty else {
throw CLIError(message: "wait-for requires a name")
}
let signalURL = tmuxWaitForSignalURL(name: name)
if signal {
FileManager.default.createFile(atPath: signalURL.path, contents: Data())
print("OK")
return
}
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if FileManager.default.fileExists(atPath: signalURL.path) {
try? FileManager.default.removeItem(at: signalURL)
print("OK")
return
}
Thread.sleep(forTimeInterval: 0.05)
}
throw CLIError(message: "wait-for timed out waiting for '\(name)'")
case "swap-pane":
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride)
guard let sourcePaneRaw = optionValue(commandArgs, name: "--pane") else {
throw CLIError(message: "swap-pane requires --pane")
}
guard let targetPaneRaw = optionValue(commandArgs, name: "--target-pane") else {
throw CLIError(message: "swap-pane requires --target-pane")
}
var params: [String: Any] = [:]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client)
if let wsId { params["workspace_id"] = wsId }
let sourcePane = try normalizePaneHandle(sourcePaneRaw, client: client, workspaceHandle: wsId)
let targetPane = try normalizePaneHandle(targetPaneRaw, client: client, workspaceHandle: wsId)
if let sourcePane { params["pane_id"] = sourcePane }
if let targetPane { params["target_pane_id"] = targetPane }
let payload = try client.sendV2(method: "pane.swap", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: "OK")
case "break-pane":
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride)
let paneArg = optionValue(commandArgs, name: "--pane")
let surfaceArg = optionValue(commandArgs, name: "--surface")
var params: [String: Any] = ["focus": !commandArgs.contains("--no-focus")]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client)
if let wsId { params["workspace_id"] = wsId }
let paneId = try normalizePaneHandle(paneArg, client: client, workspaceHandle: wsId)
if let paneId { params["pane_id"] = paneId }
let surfaceId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId)
if let surfaceId { params["surface_id"] = surfaceId }
let payload = try client.sendV2(method: "pane.break", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: "OK")
case "join-pane":
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride)
let sourcePaneArg = optionValue(commandArgs, name: "--pane")
let surfaceArg = optionValue(commandArgs, name: "--surface")
guard let targetPaneArg = optionValue(commandArgs, name: "--target-pane") else {
throw CLIError(message: "join-pane requires --target-pane")
}
var params: [String: Any] = ["focus": !commandArgs.contains("--no-focus")]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client)
if let wsId { params["workspace_id"] = wsId }
let sourcePaneId = try normalizePaneHandle(sourcePaneArg, client: client, workspaceHandle: wsId)
if let sourcePaneId { params["pane_id"] = sourcePaneId }
let targetPaneId = try normalizePaneHandle(targetPaneArg, client: client, workspaceHandle: wsId)
if let targetPaneId { params["target_pane_id"] = targetPaneId }
let surfaceId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId)
if let surfaceId { params["surface_id"] = surfaceId }
let payload = try client.sendV2(method: "pane.join", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: "OK")
case "last-window":
let payload = try client.sendV2(method: "workspace.last")
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"]))
case "next-window":
let payload = try client.sendV2(method: "workspace.next")
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"]))
case "previous-window":
let payload = try client.sendV2(method: "workspace.previous")
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"]))
case "last-pane":
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride)
var params: [String: Any] = [:]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client)
if let wsId { params["workspace_id"] = wsId }
let payload = try client.sendV2(method: "pane.last", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["pane"]))
case "find-window":
let includeContent = commandArgs.contains("--content")
let shouldSelect = commandArgs.contains("--select")
let query = commandArgs
.filter { !$0.hasPrefix("-") }
.joined(separator: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
let listPayload = try client.sendV2(method: "workspace.list")
let workspaces = listPayload["workspaces"] as? [[String: Any]] ?? []
var matches: [[String: Any]] = []
for ws in workspaces {
let title = (ws["title"] as? String) ?? ""
let titleMatch = query.isEmpty || title.localizedCaseInsensitiveContains(query)
var contentMatch = false
if includeContent && !query.isEmpty, let wsId = ws["id"] as? String {
let textPayload = try? client.sendV2(method: "surface.read_text", params: ["workspace_id": wsId])
let text = (textPayload?["text"] as? String) ?? ""
contentMatch = text.localizedCaseInsensitiveContains(query)
}
if titleMatch || contentMatch {
matches.append(ws)
}
}
if shouldSelect, let first = matches.first, let wsId = first["id"] as? String {
_ = try client.sendV2(method: "workspace.select", params: ["workspace_id": wsId])
}
if jsonOutput {
let formatted = formatIDs(["matches": matches], mode: idFormat) as? [String: Any]
print(jsonString(["matches": formatted?["matches"] ?? []]))
} else if matches.isEmpty {
print("No matches")
} else {
for item in matches {
let handle = textHandle(item, idFormat: idFormat)
let title = (item["title"] as? String) ?? ""
print("\(handle) \"\(title)\"")
}
}
case "clear-history":
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride)
let surfaceArg = optionValue(commandArgs, name: "--surface")
var params: [String: Any] = [:]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client)
if let wsId { params["workspace_id"] = wsId }
let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId, allowFocused: true)
if let sfId { params["surface_id"] = sfId }
let payload = try client.sendV2(method: "surface.clear_history", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat))
case "set-hook":
var store = loadTmuxCompatStore()
if commandArgs.contains("--list") {
if jsonOutput {
print(jsonString(["hooks": store.hooks]))
} else if store.hooks.isEmpty {
print("No hooks configured")
} else {
for (event, hookCmd) in store.hooks.sorted(by: { $0.key < $1.key }) {
print("\(event) -> \(hookCmd)")
}
}
return
}
if commandArgs.contains("--unset") {
guard let event = commandArgs.last else {
throw CLIError(message: "set-hook --unset requires an event name")
}
store.hooks.removeValue(forKey: event)
try saveTmuxCompatStore(store)
print("OK")
return
}
guard let event = commandArgs.first(where: { !$0.hasPrefix("-") }) else {
throw CLIError(message: "set-hook requires <event> <command>")
}
let commandText = commandArgs.drop(while: { $0 != event }).dropFirst().joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
guard !commandText.isEmpty else {
throw CLIError(message: "set-hook requires <event> <command>")
}
store.hooks[event] = commandText
try saveTmuxCompatStore(store)
print("OK")
case "popup":
throw CLIError(message: "popup is not supported yet in cmux CLI parity mode")
case "bind-key", "unbind-key", "copy-mode":
throw CLIError(message: "\(command) is not supported yet in cmux CLI parity mode")
case "set-buffer":
let (nameArg, rem0) = parseOption(commandArgs, name: "--name")
let name = (nameArg?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? nameArg! : "default"
let content = rem0.dropFirst(rem0.first == "--" ? 1 : 0).joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
guard !content.isEmpty else {
throw CLIError(message: "set-buffer requires text")
}
var store = loadTmuxCompatStore()
store.buffers[name] = content
try saveTmuxCompatStore(store)
print("OK")
case "list-buffers":
let store = loadTmuxCompatStore()
if jsonOutput {
let payload = store.buffers.map { key, value in ["name": key, "size": value.count] }
print(jsonString(["buffers": payload.sorted { ($0["name"] as? String ?? "") < ($1["name"] as? String ?? "") }]))
} else if store.buffers.isEmpty {
print("No buffers")
} else {
for key in store.buffers.keys.sorted() {
let size = store.buffers[key]?.count ?? 0
print("\(key)\t\(size)")
}
}
case "paste-buffer":
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride)
let surfaceArg = optionValue(commandArgs, name: "--surface")
let name = optionValue(commandArgs, name: "--name") ?? "default"
let store = loadTmuxCompatStore()
guard let buffer = store.buffers[name] else {
throw CLIError(message: "Buffer not found: \(name)")
}
var params: [String: Any] = ["text": buffer]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true)
if let wsId { params["workspace_id"] = wsId }
let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId, allowFocused: true)
if let sfId { params["surface_id"] = sfId }
let payload = try client.sendV2(method: "surface.send_text", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: "OK")
case "respawn-pane":
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride)
let surfaceArg = optionValue(commandArgs, name: "--surface")
let (commandOpt, rem0) = parseOption(commandArgs, name: "--command")
let commandText = (commandOpt ?? rem0.dropFirst(rem0.first == "--" ? 1 : 0).joined(separator: " ")).trimmingCharacters(in: .whitespacesAndNewlines)
let finalCommand = commandText.isEmpty ? "exec ${SHELL:-/bin/zsh} -l" : commandText
var params: [String: Any] = ["text": finalCommand + "\n"]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true)
if let wsId { params["workspace_id"] = wsId }
let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId, allowFocused: true)
if let sfId { params["surface_id"] = sfId }
let payload = try client.sendV2(method: "surface.send_text", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: "OK")
case "display-message":
let printOnly = commandArgs.contains("-p") || commandArgs.contains("--print")
let message = commandArgs
.filter { !$0.hasPrefix("-") }
.joined(separator: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !message.isEmpty else {
throw CLIError(message: "display-message requires text")
}
if printOnly {
print(message)
return
}
let payload = try client.sendV2(method: "notification.create", params: ["title": "cmux", "body": message])
if jsonOutput {
print(jsonString(payload))
} else {
print(message)
}
default:
throw CLIError(message: "Unsupported tmux compatibility command: \(command)")
}
}
private func runClaudeHook(
commandArgs: [String],
client: SocketClient,
telemetry: CLISocketSentryTelemetry
) throws {
let subcommand = commandArgs.first?.lowercased() ?? "help"
let hookArgs = Array(commandArgs.dropFirst())
let hookWsFlag = optionValue(hookArgs, name: "--workspace")
let workspaceArg = hookWsFlag ?? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"]
let surfaceArg = optionValue(hookArgs, name: "--surface") ?? (hookWsFlag == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil)
let rawInput = String(data: FileHandle.standardInput.readDataToEndOfFile(), encoding: .utf8) ?? ""
let parsedInput = parseClaudeHookInput(rawInput: rawInput)
let sessionStore = ClaudeHookSessionStore()
telemetry.breadcrumb(
"claude-hook.input",
data: [
"subcommand": subcommand,
"has_session_id": parsedInput.sessionId != nil,
"has_workspace_flag": hookWsFlag != nil,
"has_surface_flag": optionValue(hookArgs, name: "--surface") != nil
]
)
let fallbackWorkspaceId = try resolveWorkspaceIdForClaudeHook(workspaceArg, client: client)
let fallbackSurfaceId = try? resolveSurfaceId(surfaceArg, workspaceId: fallbackWorkspaceId, client: client)
switch subcommand {
case "session-start", "active":
telemetry.breadcrumb("claude-hook.session-start")
let workspaceId = fallbackWorkspaceId
let surfaceId = try resolveSurfaceIdForClaudeHook(
surfaceArg,
workspaceId: workspaceId,
client: client
)
if let sessionId = parsedInput.sessionId {
try? sessionStore.upsert(
sessionId: sessionId,
workspaceId: workspaceId,
surfaceId: surfaceId,
cwd: parsedInput.cwd
)
}
try setClaudeStatus(
client: client,
workspaceId: workspaceId,
value: "Running",
icon: "bolt.fill",
color: "#4C8DFF"
)
print("OK")
case "stop", "idle":
telemetry.breadcrumb("claude-hook.stop")
let consumedSession = try? sessionStore.consume(
sessionId: parsedInput.sessionId,
workspaceId: fallbackWorkspaceId,
surfaceId: fallbackSurfaceId
)
let workspaceId = consumedSession?.workspaceId ?? fallbackWorkspaceId
try clearClaudeStatus(client: client, workspaceId: workspaceId)
if let completion = summarizeClaudeHookStop(
parsedInput: parsedInput,
sessionRecord: consumedSession
) {
let surfaceId = try resolveSurfaceIdForClaudeHook(
consumedSession?.surfaceId ?? surfaceArg,
workspaceId: workspaceId,
client: client
)
let title = "Claude Code"
let subtitle = sanitizeNotificationField(completion.subtitle)
let body = sanitizeNotificationField(completion.body)
let payload = "\(title)|\(subtitle)|\(body)"
let response = try client.send(command: "notify_target \(workspaceId) \(surfaceId) \(payload)")
print(response)
} else {
print("OK")
}
case "prompt-submit":
telemetry.breadcrumb("claude-hook.prompt-submit")
var workspaceId = fallbackWorkspaceId
if let sessionId = parsedInput.sessionId,
let mapped = try? sessionStore.lookup(sessionId: sessionId),
let mappedWorkspace = try? resolveWorkspaceIdForClaudeHook(mapped.workspaceId, client: client) {
workspaceId = mappedWorkspace
}
_ = try sendV1Command("clear_notifications --tab=\(workspaceId)", client: client)
try setClaudeStatus(
client: client,
workspaceId: workspaceId,
value: "Running",
icon: "bolt.fill",
color: "#4C8DFF"
)
print("OK")
case "notification", "notify":
telemetry.breadcrumb("claude-hook.notification")
let summary = summarizeClaudeHookNotification(rawInput: rawInput)
var workspaceId = fallbackWorkspaceId
var preferredSurface = surfaceArg
if let sessionId = parsedInput.sessionId,
let mapped = try? sessionStore.lookup(sessionId: sessionId),
let mappedWorkspace = try? resolveWorkspaceIdForClaudeHook(mapped.workspaceId, client: client) {
workspaceId = mappedWorkspace
preferredSurface = mapped.surfaceId
}
let surfaceId = try resolveSurfaceIdForClaudeHook(
preferredSurface,
workspaceId: workspaceId,
client: client
)
let title = "Claude Code"
let subtitle = sanitizeNotificationField(summary.subtitle)
let body = sanitizeNotificationField(summary.body)
let payload = "\(title)|\(subtitle)|\(body)"
if let sessionId = parsedInput.sessionId {
try? sessionStore.upsert(
sessionId: sessionId,
workspaceId: workspaceId,
surfaceId: surfaceId,
cwd: parsedInput.cwd,
lastSubtitle: summary.subtitle,
lastBody: summary.body
)
}
let response = try client.send(command: "notify_target \(workspaceId) \(surfaceId) \(payload)")
_ = try? setClaudeStatus(
client: client,
workspaceId: workspaceId,
value: "Needs input",
icon: "bell.fill",
color: "#4C8DFF"
)
print(response)
case "help", "--help", "-h":
telemetry.breadcrumb("claude-hook.help")
print(
"""
cmux claude-hook <session-start|stop|notification> [--workspace <id|index>] [--surface <id|index>]
"""
)
default:
throw CLIError(message: "Unknown claude-hook subcommand: \(subcommand)")
}
}
private func setClaudeStatus(
client: SocketClient,
workspaceId: String,
value: String,
icon: String,
color: String
) throws {
_ = try client.send(
command: "set_status claude_code \(value) --icon=\(icon) --color=\(color) --tab=\(workspaceId)"
)
}
private func clearClaudeStatus(client: SocketClient, workspaceId: String) throws {
_ = try client.send(command: "clear_status claude_code --tab=\(workspaceId)")
}
private func resolveWorkspaceIdForClaudeHook(_ raw: String?, client: SocketClient) throws -> String {
if let raw, !raw.isEmpty, let candidate = try? resolveWorkspaceId(raw, client: client) {
let probe = try? client.sendV2(method: "surface.list", params: ["workspace_id": candidate])
if probe != nil {
return candidate
}
}
return try resolveWorkspaceId(nil, client: client)
}
private func resolveSurfaceIdForClaudeHook(
_ raw: String?,
workspaceId: String,
client: SocketClient
) throws -> String {
if let raw, !raw.isEmpty, let candidate = try? resolveSurfaceId(raw, workspaceId: workspaceId, client: client) {
return candidate
}
return try resolveSurfaceId(nil, workspaceId: workspaceId, client: client)
}
private func parseClaudeHookInput(rawInput: String) -> ClaudeHookParsedInput {
let trimmed = rawInput.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty,
let data = trimmed.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data, options: []),
let object = json as? [String: Any] else {
return ClaudeHookParsedInput(rawInput: rawInput, object: nil, sessionId: nil, cwd: nil, transcriptPath: nil)
}
let sessionId = extractClaudeHookSessionId(from: object)
let cwd = extractClaudeHookCWD(from: object)
let transcriptPath = firstString(in: object, keys: ["transcript_path", "transcriptPath"])
return ClaudeHookParsedInput(rawInput: rawInput, object: object, sessionId: sessionId, cwd: cwd, transcriptPath: transcriptPath)
}
private func extractClaudeHookSessionId(from object: [String: Any]) -> String? {
if let id = firstString(in: object, keys: ["session_id", "sessionId"]) {
return id
}
if let nested = object["notification"] as? [String: Any],
let id = firstString(in: nested, keys: ["session_id", "sessionId"]) {
return id
}
if let nested = object["data"] as? [String: Any],
let id = firstString(in: nested, keys: ["session_id", "sessionId"]) {
return id
}
if let session = object["session"] as? [String: Any],
let id = firstString(in: session, keys: ["id", "session_id", "sessionId"]) {
return id
}
if let context = object["context"] as? [String: Any],
let id = firstString(in: context, keys: ["session_id", "sessionId"]) {
return id
}
return nil
}
private func extractClaudeHookCWD(from object: [String: Any]) -> String? {
let cwdKeys = ["cwd", "working_directory", "workingDirectory", "project_dir", "projectDir"]
if let cwd = firstString(in: object, keys: cwdKeys) {
return cwd
}
if let nested = object["notification"] as? [String: Any],
let cwd = firstString(in: nested, keys: cwdKeys) {
return cwd
}
if let nested = object["data"] as? [String: Any],
let cwd = firstString(in: nested, keys: cwdKeys) {
return cwd
}
if let context = object["context"] as? [String: Any],
let cwd = firstString(in: context, keys: cwdKeys) {
return cwd
}
return nil
}
private func summarizeClaudeHookStop(
parsedInput: ClaudeHookParsedInput,
sessionRecord: ClaudeHookSessionRecord?
) -> (subtitle: String, body: String)? {
let cwd = parsedInput.cwd ?? sessionRecord?.cwd
let transcriptPath = parsedInput.transcriptPath
let projectName: String? = {
guard let cwd = cwd, !cwd.isEmpty else { return nil }
let path = NSString(string: cwd).expandingTildeInPath
let tail = URL(fileURLWithPath: path).lastPathComponent
return tail.isEmpty ? path : tail
}()
// Try reading the transcript JSONL for a richer summary.
let transcript = transcriptPath.flatMap { readTranscriptSummary(path: $0) }
if let lastMsg = transcript?.lastAssistantMessage {
var subtitle = "Completed"
if let projectName, !projectName.isEmpty {
subtitle = "Completed in \(projectName)"
}
return (subtitle, truncate(lastMsg, maxLength: 200))
}
// Fallback: use session record data.
let lastMessage = sessionRecord?.lastBody ?? sessionRecord?.lastSubtitle
let hasContext = cwd != nil || lastMessage != nil
guard hasContext else { return nil }
var body = "Claude session completed"
if let projectName, !projectName.isEmpty {
body += " in \(projectName)"
}
if let lastMessage, !lastMessage.isEmpty {
body += ". Last: \(lastMessage)"
}
return ("Completed", body)
}
private struct TranscriptSummary {
let lastAssistantMessage: String?
}
private func readTranscriptSummary(path: String) -> TranscriptSummary? {
let expandedPath = NSString(string: path).expandingTildeInPath
guard let data = try? Data(contentsOf: URL(fileURLWithPath: expandedPath)) else {
return nil
}
guard let content = String(data: data, encoding: .utf8) else { return nil }
let lines = content.components(separatedBy: "\n")
var lastAssistantMessage: String?
for line in lines {
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty,
let lineData = trimmed.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any],
let message = obj["message"] as? [String: Any],
let role = message["role"] as? String,
role == "assistant" else {
continue
}
let text = extractMessageText(from: message)
guard let text, !text.isEmpty else { continue }
lastAssistantMessage = truncate(normalizedSingleLine(text), maxLength: 120)
}
guard lastAssistantMessage != nil else { return nil }
return TranscriptSummary(lastAssistantMessage: lastAssistantMessage)
}
private func extractMessageText(from message: [String: Any]) -> String? {
if let content = message["content"] as? String {
return content.trimmingCharacters(in: .whitespacesAndNewlines)
}
if let contentArray = message["content"] as? [[String: Any]] {
let texts = contentArray.compactMap { block -> String? in
guard (block["type"] as? String) == "text",
let text = block["text"] as? String else { return nil }
return text.trimmingCharacters(in: .whitespacesAndNewlines)
}
let joined = texts.joined(separator: " ")
return joined.isEmpty ? nil : joined
}
return nil
}
private func summarizeClaudeHookNotification(rawInput: String) -> (subtitle: String, body: String) {
let trimmed = rawInput.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
return ("Waiting", "Claude is waiting for your input")
}
guard let data = trimmed.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data, options: []),
let object = json as? [String: Any] else {
let fallback = truncate(normalizedSingleLine(trimmed), maxLength: 180)
return classifyClaudeNotification(signal: fallback, message: fallback)
}
let nested = (object["notification"] as? [String: Any]) ?? (object["data"] as? [String: Any]) ?? [:]
let signalParts = [
firstString(in: object, keys: ["event", "event_name", "hook_event_name", "type", "kind"]),
firstString(in: object, keys: ["notification_type", "matcher", "reason"]),
firstString(in: nested, keys: ["type", "kind", "reason"])
]
let messageCandidates = [
firstString(in: object, keys: ["message", "body", "text", "prompt", "error", "description"]),
firstString(in: nested, keys: ["message", "body", "text", "prompt", "error", "description"])
]
let session = firstString(in: object, keys: ["session_id", "sessionId"])
let message = messageCandidates.compactMap { $0 }.first ?? "Claude needs your input"
let normalizedMessage = normalizedSingleLine(message)
let signal = signalParts.compactMap { $0 }.joined(separator: " ")
var classified = classifyClaudeNotification(signal: signal, message: normalizedMessage)
if let session, !session.isEmpty {
let shortSession = String(session.prefix(8))
if !classified.body.contains(shortSession) {
classified.body = "\(classified.body) [\(shortSession)]"
}
}
classified.body = truncate(classified.body, maxLength: 180)
return classified
}
private func classifyClaudeNotification(signal: String, message: String) -> (subtitle: String, body: String) {
let lower = "\(signal) \(message)".lowercased()
if lower.contains("permission") || lower.contains("approve") || lower.contains("approval") {
let body = message.isEmpty ? "Approval needed" : message
return ("Permission", body)
}
if lower.contains("error") || lower.contains("failed") || lower.contains("exception") {
let body = message.isEmpty ? "Claude reported an error" : message
return ("Error", body)
}
if lower.contains("idle") || lower.contains("wait") || lower.contains("input") || lower.contains("prompt") {
let body = message.isEmpty ? "Claude is waiting for your input" : message
return ("Waiting", body)
}
let body = message.isEmpty ? "Claude needs your input" : message
return ("Attention", body)
}
private func firstString(in object: [String: Any], keys: [String]) -> String? {
for key in keys {
guard let value = object[key] else { continue }
if let string = value as? String {
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
return trimmed
}
}
}
return nil
}
private func normalizedSingleLine(_ value: String) -> String {
let collapsed = value.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
return collapsed.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func truncate(_ value: String, maxLength: Int) -> String {
guard value.count > maxLength else { return value }
let index = value.index(value.startIndex, offsetBy: max(0, maxLength - 1))
return String(value[..<index]) + ""
}
private func sanitizeNotificationField(_ value: String) -> String {
let normalized = normalizedSingleLine(value)
.replacingOccurrences(of: "|", with: "¦")
return truncate(normalized, maxLength: 180)
}
private func versionSummary() -> String {
let info = resolvedVersionInfo()
let commit = info["CMUXCommit"].flatMap { normalizedCommitHash($0) }
let baseSummary: String
if let version = info["CFBundleShortVersionString"], let build = info["CFBundleVersion"] {
baseSummary = "cmux \(version) (\(build))"
} else if let version = info["CFBundleShortVersionString"] {
baseSummary = "cmux \(version)"
} else if let build = info["CFBundleVersion"] {
baseSummary = "cmux build \(build)"
} else {
baseSummary = "cmux version unknown"
}
guard let commit else { return baseSummary }
return "\(baseSummary) [\(commit)]"
}
private func printWelcome() {
let reset = "\u{001B}[0m"
let bold = "\u{001B}[1m"
func trueColor(_ red: Int, _ green: Int, _ blue: Int) -> String {
"\u{001B}[38;2;\(red);\(green);\(blue)m"
}
let isDark = UserDefaults.standard.string(forKey: "AppleInterfaceStyle") == "Dark"
let c1 = trueColor(0, 212, 255)
let c2 = trueColor(24, 181, 250)
let c3 = trueColor(48, 150, 245)
let c4 = trueColor(72, 119, 241)
let c5 = trueColor(96, 88, 239)
let c6 = trueColor(110, 73, 238)
let c7 = trueColor(124, 58, 237)
let tagline: String
let subdued: String
if isDark {
tagline = trueColor(130, 130, 140)
subdued = "\u{001B}[2m"
} else {
tagline = trueColor(90, 90, 98)
subdued = trueColor(100, 100, 108)
}
let logo = """
\(c1) ::\(reset)
\(c2) ::::\(reset) \(c1)c\(c2)m\(c3)u\(c7)x\(reset)
\(c3) ::::::\(reset)
\(c4) ::::::\(reset) \(tagline)the open source terminal\(reset)
\(c5) ::::::\(reset) \(tagline)built for coding agents\(reset)
\(c6) ::::\(reset)
\(c7) ::\(reset)
"""
let shortcuts = """
\(bold)Shortcuts\(reset)
\(bold)\u{2318}N\(reset)\(subdued) New workspace\(reset)
\(bold)\u{2318}P\(reset)\(subdued) Go to workspace\(reset)
\(bold)\u{2318}D\(reset)\(subdued) Split right\(reset)
\(bold)\u{2318}\u{21E7}D\(reset)\(subdued) Split down\(reset)
\(bold)\u{2318}\u{21E7}P\(reset)\(subdued) Command palette\(reset)
\(bold)\u{2318}\u{21E7}R\(reset)\(subdued) Rename workspace\(reset)
\(bold)\u{2318}\u{21E7}L\(reset)\(subdued) New browser\(reset)
\(bold)\u{2318}\u{21E7}U\(reset)\(subdued) Jump to latest unread\(reset)
"""
print()
print(logo)
print()
print(shortcuts)
print()
print(" \(bold)Docs\(reset)\(subdued) https://cmux.dev/docs\(reset)")
print(" \(bold)Discord\(reset)\(subdued) https://discord.gg/xsgFEVrWCZ\(reset)")
print(" \(bold)GitHub\(reset)\(subdued) https://github.com/manaflow-ai/cmux (please leave a star ⭐)\(reset)")
print(" \(bold)Email\(reset)\(subdued) founders@manaflow.com\(reset)")
print()
print(" \(subdued)Run \(reset)\(bold)cmux --help\(reset)\(subdued) for all commands.\(reset)")
print(" \(subdued)Run \(reset)\(bold)cmux shortcuts\(reset)\(subdued) to edit shortcuts.\(reset)")
print(" \(subdued)Run \(reset)\(bold)cmux feedback\(reset)\(subdued) to report a bug.\(reset)")
print()
}
private func resolvedVersionInfo() -> [String: String] {
var info: [String: String] = [:]
if let main = versionInfo(from: Bundle.main.infoDictionary) {
info.merge(main, uniquingKeysWith: { current, _ in current })
}
let needsPlistFallback =
info["CFBundleShortVersionString"] == nil ||
info["CFBundleVersion"] == nil ||
info["CMUXCommit"] == nil
if needsPlistFallback {
for plistURL in candidateInfoPlistURLs() {
guard let data = try? Data(contentsOf: plistURL),
let raw = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil),
let dictionary = raw as? [String: Any],
let parsed = versionInfo(from: dictionary)
else {
continue
}
info.merge(parsed, uniquingKeysWith: { current, _ in current })
if info["CFBundleShortVersionString"] != nil,
info["CFBundleVersion"] != nil,
info["CMUXCommit"] != nil {
break
}
}
}
let needsProjectFallback =
info["CFBundleShortVersionString"] == nil ||
info["CFBundleVersion"] == nil ||
info["CMUXCommit"] == nil
if needsProjectFallback, let fromProject = versionInfoFromProjectFile() {
info.merge(fromProject, uniquingKeysWith: { current, _ in current })
}
if info["CMUXCommit"] == nil,
let commit = normalizedCommitHash(ProcessInfo.processInfo.environment["CMUX_COMMIT"]) {
info["CMUXCommit"] = commit
}
return info
}
private func versionInfo(from dictionary: [String: Any]?) -> [String: String]? {
guard let dictionary else { return nil }
var info: [String: String] = [:]
if let version = dictionary["CFBundleShortVersionString"] as? String {
let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty && !trimmed.contains("$(") {
info["CFBundleShortVersionString"] = trimmed
}
}
if let build = dictionary["CFBundleVersion"] as? String {
let trimmed = build.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty && !trimmed.contains("$(") {
info["CFBundleVersion"] = trimmed
}
}
if let commit = dictionary["CMUXCommit"] as? String,
let normalizedCommit = normalizedCommitHash(commit) {
info["CMUXCommit"] = normalizedCommit
}
return info.isEmpty ? nil : info
}
private func versionInfoFromProjectFile() -> [String: String]? {
guard let executableURL = resolvedExecutableURL() else {
return nil
}
let fileManager = FileManager.default
var current = executableURL.deletingLastPathComponent()
while true {
let projectFile = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj")
if fileManager.fileExists(atPath: projectFile.path),
let contents = try? String(contentsOf: projectFile, encoding: .utf8) {
var info: [String: String] = [:]
if let version = firstProjectSetting("MARKETING_VERSION", in: contents) {
info["CFBundleShortVersionString"] = version
}
if let build = firstProjectSetting("CURRENT_PROJECT_VERSION", in: contents) {
info["CFBundleVersion"] = build
}
if let commit = gitCommitHash(at: current) {
info["CMUXCommit"] = commit
}
if !info.isEmpty {
return info
}
}
let parent = current.deletingLastPathComponent()
if parent.path == current.path {
break
}
current = parent
}
return nil
}
private func firstProjectSetting(_ key: String, in source: String) -> String? {
let pattern = NSRegularExpression.escapedPattern(for: key) + "\\s*=\\s*([^;]+);"
guard let regex = try? NSRegularExpression(pattern: pattern) else {
return nil
}
let searchRange = NSRange(source.startIndex..<source.endIndex, in: source)
guard let match = regex.firstMatch(in: source, options: [], range: searchRange),
match.numberOfRanges > 1,
let valueRange = Range(match.range(at: 1), in: source)
else {
return nil
}
let value = source[valueRange]
.trimmingCharacters(in: .whitespacesAndNewlines)
.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
guard !value.isEmpty, !value.contains("$(") else {
return nil
}
return value
}
private func gitCommitHash(at directory: URL) -> String? {
let process = Process()
let stdout = Pipe()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = ["git", "-C", directory.path, "rev-parse", "--short=9", "HEAD"]
process.standardOutput = stdout
process.standardError = Pipe()
do {
try process.run()
} catch {
return nil
}
process.waitUntilExit()
guard process.terminationStatus == 0 else {
return nil
}
let data = stdout.fileHandleForReading.readDataToEndOfFile()
guard let output = String(data: data, encoding: .utf8) else {
return nil
}
return normalizedCommitHash(output)
}
private func normalizedCommitHash(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, !trimmed.contains("$(") else {
return nil
}
let normalized = trimmed.lowercased()
let allowed = CharacterSet(charactersIn: "0123456789abcdef")
guard normalized.unicodeScalars.allSatisfy({ allowed.contains($0) }) else {
return nil
}
return String(normalized.prefix(12))
}
private func candidateInfoPlistURLs() -> [URL] {
guard let executableURL = resolvedExecutableURL() else {
return []
}
let fileManager = FileManager.default
var candidates: [URL] = []
var seen: Set<String> = []
func appendIfExisting(_ url: URL) {
let path = url.path
guard !path.isEmpty else { return }
guard seen.insert(path).inserted else { return }
guard fileManager.fileExists(atPath: path) else { return }
candidates.append(url)
}
var current = executableURL.deletingLastPathComponent()
while true {
if current.pathExtension == "app" {
appendIfExisting(current.appendingPathComponent("Contents/Info.plist"))
}
if current.lastPathComponent == "Contents" {
appendIfExisting(current.appendingPathComponent("Info.plist"))
}
let projectMarker = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj")
let repoInfo = current.appendingPathComponent("Resources/Info.plist")
if fileManager.fileExists(atPath: projectMarker.path),
fileManager.fileExists(atPath: repoInfo.path) {
appendIfExisting(repoInfo)
break
}
let parent = current.deletingLastPathComponent()
if parent.path == current.path {
break
}
current = parent
}
// If we already found an ancestor bundle or repo Info.plist, avoid scanning
// sibling app bundles. Large Resources directories can otherwise balloon RSS.
guard candidates.isEmpty else {
return candidates
}
let searchRoots = [
executableURL.deletingLastPathComponent(),
executableURL.deletingLastPathComponent().deletingLastPathComponent()
]
for root in searchRoots {
guard let entries = fileManager.enumerator(
at: root,
includingPropertiesForKeys: nil,
options: [.skipsHiddenFiles, .skipsSubdirectoryDescendants],
errorHandler: { _, _ in true }
) else {
continue
}
for case let entry as URL in entries where entry.pathExtension == "app" {
appendIfExisting(entry.appendingPathComponent("Contents/Info.plist"))
}
}
return candidates
}
private func currentExecutablePath() -> String? {
var size: UInt32 = 0
_ = _NSGetExecutablePath(nil, &size)
if size > 0 {
var buffer = Array<CChar>(repeating: 0, count: Int(size))
if _NSGetExecutablePath(&buffer, &size) == 0 {
let path = String(cString: buffer).trimmingCharacters(in: .whitespacesAndNewlines)
if !path.isEmpty {
return path
}
}
}
return Bundle.main.executableURL?.path ?? args.first
}
private func resolvedExecutableURL() -> URL? {
guard let executable = currentExecutablePath(), !executable.isEmpty else {
return nil
}
let expanded = (executable as NSString).expandingTildeInPath
if let resolvedPath = realpath(expanded, nil) {
defer { free(resolvedPath) }
return URL(fileURLWithPath: String(cString: resolvedPath)).standardizedFileURL
}
return URL(fileURLWithPath: expanded).standardizedFileURL
}
private func usage() -> String {
return """
cmux - control cmux via Unix socket
Usage:
cmux <path> Open a directory in a new workspace (launches cmux if needed)
cmux [global-options] <command> [options]
Handle Inputs:
Use UUIDs, short refs (window:1/workspace:2/pane:3/surface:4), or indexes where commands accept window, workspace, pane, or surface inputs.
`tab-action` also accepts `tab:<n>` in addition to `surface:<n>`.
Output defaults to refs; pass --id-format uuids or --id-format both to include UUIDs.
Socket Auth:
--password takes precedence, then CMUX_SOCKET_PASSWORD env var, then password saved in Settings.
Commands:
welcome
shortcuts
feedback [--email <email> --body <text> [--image <path> ...]]
claude-teams [claude-args...]
ping
version
capabilities
identify [--workspace <id|ref|index>] [--surface <id|ref|index>] [--no-caller]
list-windows
current-window
new-window
focus-window --window <id>
close-window --window <id>
move-workspace-to-window --workspace <id|ref> --window <id|ref>
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 [--cwd <path>] [--command <text>]
ssh <destination> [--name <title>] [--port <n>] [--identity <path>] [--ssh-option <opt>] [-- <remote-command-args>]
remote-daemon-status [--os <darwin|linux>] [--arch <arm64|amd64>]
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>]
tree [--all] [--workspace <id|ref|index>]
focus-pane --pane <id|ref> [--workspace <id|ref>]
new-pane [--type <terminal|browser>] [--direction <left|right|up|down>] [--workspace <id|ref>] [--url <url>]
new-surface [--type <terminal|browser>] [--pane <id|ref>] [--workspace <id|ref>] [--url <url>]
close-surface [--surface <id|ref>] [--workspace <id|ref>]
move-surface --surface <id|ref|index> [--pane <id|ref|index>] [--workspace <id|ref|index>] [--window <id|ref|index>] [--before <id|ref|index>] [--after <id|ref|index>] [--index <n>] [--focus <true|false>]
reorder-surface --surface <id|ref|index> (--index <n> | --before <id|ref|index> | --after <id|ref|index>)
tab-action --action <name> [--tab <id|ref|index>] [--surface <id|ref|index>] [--workspace <id|ref|index>] [--title <text>] [--url <url>]
rename-tab [--workspace <id|ref>] [--tab <id|ref>] [--surface <id|ref>] <title>
drag-surface-to-split --surface <id|ref> <left|right|up|down>
refresh-surfaces
surface-health [--workspace <id|ref>]
trigger-flash [--workspace <id|ref>] [--surface <id|ref>]
list-panels [--workspace <id|ref>]
focus-panel --panel <id|ref> [--workspace <id|ref>]
close-workspace --workspace <id|ref>
select-workspace --workspace <id|ref>
rename-workspace [--workspace <id|ref>] <title>
rename-window [--workspace <id|ref>] <title>
current-workspace
read-screen [--workspace <id|ref>] [--surface <id|ref>] [--scrollback] [--lines <n>]
send [--workspace <id|ref>] [--surface <id|ref>] <text>
send-key [--workspace <id|ref>] [--surface <id|ref>] <key>
send-panel --panel <id|ref> [--workspace <id|ref>] <text>
send-key-panel --panel <id|ref> [--workspace <id|ref>] <key>
notify --title <text> [--subtitle <text>] [--body <text>] [--workspace <id|ref>] [--surface <id|ref>]
list-notifications
clear-notifications
claude-hook <session-start|stop|notification> [--workspace <id|ref>] [--surface <id|ref>]
set-app-focus <active|inactive|clear>
simulate-app-active
# tmux compatibility commands
capture-pane [--workspace <id|ref>] [--surface <id|ref>] [--scrollback] [--lines <n>]
resize-pane --pane <id|ref> [--workspace <id|ref>] (-L|-R|-U|-D) [--amount <n>]
pipe-pane --command <shell-command> [--workspace <id|ref>] [--surface <id|ref>]
wait-for [-S|--signal] <name> [--timeout <seconds>]
swap-pane --pane <id|ref> --target-pane <id|ref> [--workspace <id|ref>]
break-pane [--workspace <id|ref>] [--pane <id|ref>] [--surface <id|ref>] [--no-focus]
join-pane --target-pane <id|ref> [--workspace <id|ref>] [--pane <id|ref>] [--surface <id|ref>] [--no-focus]
next-window | previous-window | last-window
last-pane [--workspace <id|ref>]
find-window [--content] [--select] <query>
clear-history [--workspace <id|ref>] [--surface <id|ref>]
set-hook [--list] [--unset <event>] | <event> <command>
popup
bind-key | unbind-key | copy-mode
set-buffer [--name <name>] <text>
list-buffers
paste-buffer [--name <name>] [--workspace <id|ref>] [--surface <id|ref>]
respawn-pane [--workspace <id|ref>] [--surface <id|ref>] [--command <cmd>]
display-message [-p|--print] <text>
markdown [open] <path> (open markdown file in formatted viewer panel with live reload)
browser [--surface <id|ref|index> | <surface>] <subcommand> ...
browser open [url] (create browser split in caller's workspace; if surface supplied, behaves like navigate)
browser open-split [url]
browser goto|navigate <url> [--snapshot-after]
browser back|forward|reload [--snapshot-after]
browser url|get-url
browser snapshot [--interactive|-i] [--cursor] [--compact] [--max-depth <n>] [--selector <css>]
browser eval <script>
browser wait [--selector <css>] [--text <text>] [--url-contains <text>] [--load-state <interactive|complete>] [--function <js>] [--timeout-ms <ms>]
browser click|dblclick|hover|focus|check|uncheck|scroll-into-view <selector> [--snapshot-after]
browser type <selector> <text> [--snapshot-after]
browser fill <selector> [text] [--snapshot-after] (empty text clears input)
browser press|keydown|keyup <key> [--snapshot-after]
browser select <selector> <value> [--snapshot-after]
browser scroll [--selector <css>] [--dx <n>] [--dy <n>] [--snapshot-after]
browser screenshot [--out <path>] [--json]
browser get <url|title|text|html|value|attr|count|box|styles> [...]
browser is <visible|enabled|checked> <selector>
browser find <role|text|label|placeholder|alt|title|testid|first|last|nth> ...
browser frame <selector|main>
browser dialog <accept|dismiss> [text]
browser download [wait] [--path <path>] [--timeout-ms <ms>]
browser cookies <get|set|clear> [...]
browser storage <local|session> <get|set|clear> [...]
browser tab <new|list|switch|close|<index>> [...]
browser console <list|clear>
browser errors <list|clear>
browser highlight <selector>
browser state <save|load> <path>
browser addinitscript <script>
browser addscript <script>
browser addstyle <css>
browser identify [--surface <id|ref|index>]
help
Environment:
CMUX_WORKSPACE_ID Auto-set in cmux terminals. Used as default --workspace for
ALL commands (send, list-panels, new-split, notify, etc.).
CMUX_TAB_ID Optional alias used by `tab-action`/`rename-tab` as default --tab.
CMUX_SURFACE_ID Auto-set in cmux terminals. Used as default --surface.
CMUX_SOCKET_PATH Override the Unix socket path. Without this, the CLI defaults
to /tmp/cmux.sock and auto-discovers tagged/debug sockets.
"""
}
}
@main
struct CMUXTermMain {
static func main() {
// CLI tools should ignore SIGPIPE so closed stdout pipes do not terminate the process.
_ = signal(SIGPIPE, SIG_IGN)
let cli = CMUXCLI(args: CommandLine.arguments)
do {
try cli.run()
} catch {
FileHandle.standardError.write(Data("Error: \(error)\n".utf8))
exit(1)
}
}
}