cmux/CLI/cmux.swift
Austin Wang 2c5c4fcf8d
Fix tmux-compat split-window surface resolution (#2351)
* Add tmux-compat split-window ref regression tests

* Fix tmux-compat split-window surface resolution

* Fix stale tmux caller surface fallback

* Add stale tmux-compat split-window regressions

* Fix stale tmux-compat split-window anchors

* Preserve tmux fallback and column anchor
2026-03-30 03:28:25 -07:00

13161 lines
547 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"
private static func currentSentryReleaseName() -> String? {
guard let bundleIdentifier = currentSentryBundleIdentifier(),
let version = currentBundleVersionValue(forKey: "CFBundleShortVersionString"),
let build = currentBundleVersionValue(forKey: "CFBundleVersion")
else {
return nil
}
return "\(bundleIdentifier)@\(version)+\(build)"
}
private static func currentSentryBundleIdentifier() -> String? {
if let bundleIdentifier = ProcessInfo.processInfo.environment["CMUX_BUNDLE_ID"]?
.trimmingCharacters(in: .whitespacesAndNewlines),
!bundleIdentifier.isEmpty {
return bundleIdentifier
}
if let bundleIdentifier = currentSentryBundle()?.bundleIdentifier?
.trimmingCharacters(in: .whitespacesAndNewlines),
!bundleIdentifier.isEmpty {
return bundleIdentifier
}
return nil
}
private static func currentBundleVersionValue(forKey key: String) -> String? {
guard let value = currentSentryBundle()?.infoDictionary?[key] as? String else {
return nil
}
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, !trimmed.contains("$(") else {
return nil
}
return trimmed
}
private static func currentSentryBundle() -> Bundle? {
if Bundle.main.bundleIdentifier?.isEmpty == false {
return Bundle.main
}
guard let executableURL = currentExecutableURL() else {
return Bundle.main
}
var current = executableURL.deletingLastPathComponent().standardizedFileURL
while true {
if current.pathExtension == "app", let bundle = Bundle(url: current) {
return bundle
}
if current.lastPathComponent == "Contents" {
let appURL = current.deletingLastPathComponent().standardizedFileURL
if appURL.pathExtension == "app", let bundle = Bundle(url: appURL) {
return bundle
}
}
guard let parent = parentSearchURL(for: current) else {
break
}
current = parent
}
return Bundle.main
}
private static func currentExecutableURL() -> URL? {
var size: UInt32 = 0
_ = _NSGetExecutablePath(nil, &size)
if size > 0 {
var buffer = Array<CChar>(repeating: 0, count: Int(size))
if _NSGetExecutablePath(&buffer, &size) == 0 {
return URL(fileURLWithPath: String(cString: buffer)).standardizedFileURL
}
}
return Bundle.main.executableURL?.standardizedFileURL
}
private static func parentSearchURL(for url: URL) -> URL? {
let standardized = url.standardizedFileURL
let path = standardized.path
guard !path.isEmpty, path != "/" else {
return nil
}
let parent = standardized.deletingLastPathComponent().standardizedFileURL
guard parent.path != path else {
return nil
}
return parent
}
#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.discoverSockets(in: "/tmp", limit: 10)
if !tmpSockets.isEmpty {
context["tmp_cmux_sockets"] = tmpSockets
}
let taggedSockets = tmpSockets.filter { $0 != CLISocketPathResolver.legacyDefaultSocketPath }
if CLISocketPathResolver.isImplicitDefaultPath(socketPath),
(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 discoverSockets(in directory: String, limit: Int) -> [String] {
guard let entries = try? FileManager.default.contentsOfDirectory(atPath: directory) else {
return []
}
var sockets: [String] = []
for name in entries.sorted() {
guard name.hasPrefix("cmux"), name.hasSuffix(".sock") else { continue }
let fullPath = URL(fileURLWithPath: directory)
.appendingPathComponent(name, isDirectory: false)
.path
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
options.releaseName = currentSentryReleaseName()
#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 pid: Int?
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?,
pid: Int? = nil,
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,
pid: nil,
lastSubtitle: nil,
lastBody: nil,
startedAt: now,
updatedAt: now
)
record.workspaceId = workspaceId
if !surfaceId.isEmpty {
record.surfaceId = surfaceId
}
if let cwd = normalizeOptional(cwd) {
record.cwd = cwd
}
if let pid {
record.pid = pid
}
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
}
}
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)
}
static func keychainServices(
socketPath: String,
environment: [String: String] = ProcessInfo.processInfo.environment
) -> [String] {
guard let scope = keychainScope(socketPath: socketPath, environment: environment) else {
return [service]
}
return ["\(service).\(scope)", service]
}
private static func keychainScope(
socketPath: String,
environment: [String: String] = ProcessInfo.processInfo.environment
) -> String? {
if let tag = normalized(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 {
private static let appSupportDirectoryName = "cmux"
private static let stableSocketFileName = "cmux.sock"
private static let lastSocketPathFileName = "last-socket-path"
static let legacyDefaultSocketPath = "/tmp/cmux.sock"
private static let fallbackSocketPath = "/tmp/cmux-debug.sock"
private static let stagingSocketPath = "/tmp/cmux-staging.sock"
private static let legacyLastSocketPathFile = "/tmp/cmux-last-socket-path"
static var defaultSocketPath: String {
let stablePath: String? = stableSocketDirectoryURL()?
.appendingPathComponent(stableSocketFileName, isDirectory: false)
.path
return stablePath ?? legacyDefaultSocketPath
}
static func isImplicitDefaultPath(_ path: String) -> Bool {
path == defaultSocketPath || path == legacyDefaultSocketPath
}
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(defaultSocketPath)
candidates.append(legacyDefaultSocketPath)
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? {
let primaryCandidate: String? = stableSocketDirectoryURL()?
.appendingPathComponent(lastSocketPathFileName, isDirectory: false)
.path
let candidates = [primaryCandidate, legacyLastSocketPathFile].compactMap { $0 }
for candidate in candidates {
guard let data = try? String(contentsOfFile: candidate, encoding: .utf8) else {
continue
}
if let value = normalized(data) {
return value
}
}
return nil
}
private static func discoverTaggedSockets(limit: Int) -> [String] {
var discovered: [(path: String, mtime: TimeInterval)] = []
for directory in socketDiscoveryDirectories() {
guard let entries = try? FileManager.default.contentsOfDirectory(atPath: directory) else {
continue
}
discovered.reserveCapacity(min(limit, discovered.count + entries.count))
for name in entries where name.hasPrefix("cmux") && name.hasSuffix(".sock") {
let path = URL(fileURLWithPath: directory)
.appendingPathComponent(name, isDirectory: false)
.path
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 == legacyDefaultSocketPath || 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 dedupe(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 stableSocketDirectoryURL() -> URL? {
guard let appSupportDirectory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
return nil
}
return appSupportDirectory.appendingPathComponent(appSupportDirectoryName, isDirectory: true)
}
private static func socketDiscoveryDirectories() -> [String] {
let appSupportSocketDirectory: String = stableSocketDirectoryURL()?.path ?? ""
return dedupe([
"/tmp",
appSupportSocketDirectory,
])
}
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 defaultResponseTimeoutSeconds: TimeInterval = 15.0
private static let multilineResponseIdleTimeoutSeconds: TimeInterval = 0.12
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 }
try connectOnce()
}
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
while true {
try configureReceiveTimeout(
sawNewline ? Self.multilineResponseIdleTimeoutSeconds : Self.responseTimeoutSeconds
)
var buffer = [UInt8](repeating: 0, count: 8192)
let count = Darwin.read(socketFD, &buffer, buffer.count)
if count < 0 {
if errno == EINTR {
continue
}
if errno == EAGAIN || errno == EWOULDBLOCK {
if sawNewline {
break
}
throw CLIError(message: "Command timed out")
}
throw CLIError(message: "Socket read error")
}
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
}
private func connectOnce() throws {
// Verify socket is owned by the current user to prevent fake-socket attacks.
var st = stat()
guard stat(path, &st) == 0 else {
throw CLIError(message: "Socket not found at \(path)")
}
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
throw CLIError(
message: "Failed to connect to socket at \(path) (\(String(cString: strerror(connectErrno))), errno \(connectErrno))"
)
}
private func configureReceiveTimeout(_ timeout: TimeInterval) throws {
var interval = timeval(
tv_sec: Int(timeout.rounded(.down)),
tv_usec: __darwin_suseconds_t((timeout - floor(timeout)) * 1_000_000)
)
let result = withUnsafePointer(to: &interval) { ptr in
setsockopt(
socketFD,
SOL_SOCKET,
SO_RCVTIMEO,
ptr,
socklen_t(MemoryLayout<timeval>.size)
)
}
guard result == 0 else {
throw CLIError(message: "Failed to configure socket receive timeout")
}
}
static func waitForConnectableSocket(path: String, timeout: TimeInterval) throws -> SocketClient {
let client = SocketClient(path: path)
if (try? client.connect()) != nil {
return client
}
guard let watchDirectory = existingWatchDirectory(forPath: path) else {
throw CLIError(message: "cmux app did not start in time (socket not found at \(path))")
}
let watchFD = open(watchDirectory, O_EVTONLY)
guard watchFD >= 0 else {
throw CLIError(message: "cmux app did not start in time (socket not found at \(path))")
}
let queue = DispatchQueue(label: "com.cmux.cli.socket-watch.\(UUID().uuidString)")
let semaphore = DispatchSemaphore(value: 0)
var connected = false
let source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: watchFD,
eventMask: [.write, .rename, .delete, .attrib, .extend, .link],
queue: queue
)
func attemptConnect() {
guard !connected else { return }
if (try? client.connect()) != nil {
connected = true
semaphore.signal()
}
}
source.setEventHandler {
attemptConnect()
}
source.setCancelHandler {
Darwin.close(watchFD)
}
source.resume()
queue.async {
attemptConnect()
}
guard semaphore.wait(timeout: .now() + timeout) == .success else {
source.cancel()
client.close()
throw CLIError(message: "cmux app did not start in time (socket not found at \(path))")
}
source.cancel()
return client
}
static func waitForFilesystemPath(_ path: String, timeout: TimeInterval) throws {
if FileManager.default.fileExists(atPath: path) {
return
}
guard let watchDirectory = existingWatchDirectory(forPath: path) else {
throw CLIError(message: "Timed out waiting for \(path)")
}
let watchFD = open(watchDirectory, O_EVTONLY)
guard watchFD >= 0 else {
throw CLIError(message: "Timed out waiting for \(path)")
}
let queue = DispatchQueue(label: "com.cmux.cli.path-watch.\(UUID().uuidString)")
let semaphore = DispatchSemaphore(value: 0)
var found = false
let source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: watchFD,
eventMask: [.write, .rename, .delete, .attrib, .extend, .link],
queue: queue
)
func checkPath() {
guard !found else { return }
if FileManager.default.fileExists(atPath: path) {
found = true
semaphore.signal()
}
}
source.setEventHandler {
checkPath()
}
source.setCancelHandler {
Darwin.close(watchFD)
}
source.resume()
queue.async {
checkPath()
}
guard semaphore.wait(timeout: .now() + timeout) == .success else {
source.cancel()
throw CLIError(message: "Timed out waiting for \(path)")
}
source.cancel()
}
private static func existingWatchDirectory(forPath path: String) -> String? {
let fileManager = FileManager.default
var candidate = URL(fileURLWithPath: (path as NSString).deletingLastPathComponent, isDirectory: true)
while !candidate.path.isEmpty {
var isDirectory: ObjCBool = false
if fileManager.fileExists(atPath: candidate.path, isDirectory: &isDirectory), isDirectory.boolValue {
return candidate.path
}
let parent = candidate.deletingLastPathComponent()
if parent.path == candidate.path {
break
}
candidate = parent
}
return nil
}
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 CLIProcessResult {
let status: Int32
let stdout: String
let stderr: String
let timedOut: Bool
}
enum CLIProcessRunner {
static func runProcess(
executablePath: String,
arguments: [String],
stdinText: String? = nil,
timeout: TimeInterval? = nil
) -> CLIProcessResult {
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
}
let finished = DispatchSemaphore(value: 0)
process.terminationHandler = { _ in
finished.signal()
}
do {
try process.run()
} catch {
return CLIProcessResult(status: 1, stdout: "", stderr: String(describing: error), timedOut: false)
}
if let stdinText, let stdinPipe {
if let data = stdinText.data(using: .utf8) {
stdinPipe.fileHandleForWriting.write(data)
}
stdinPipe.fileHandleForWriting.closeFile()
}
let timedOut: Bool
if let timeout {
switch finished.wait(timeout: .now() + timeout) {
case .success:
timedOut = false
case .timedOut:
timedOut = true
terminate(process: process, finished: finished)
}
} else {
finished.wait()
timedOut = false
}
let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
var stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
if timedOut {
let timeoutMessage = "process timed out"
if stderr.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
stderr = timeoutMessage
} else if !stderr.contains(timeoutMessage) {
stderr += "\n\(timeoutMessage)"
}
}
return CLIProcessResult(
status: timedOut ? 124 : process.terminationStatus,
stdout: stdout,
stderr: stderr,
timedOut: timedOut
)
}
private static func terminate(process: Process, finished: DispatchSemaphore) {
guard process.isRunning else { return }
process.terminate()
if finished.wait(timeout: .now() + 0.5) == .success {
return
}
if process.isRunning {
kill(process.processIdentifier, SIGKILL)
}
_ = finished.wait(timeout: .now() + 0.5)
}
}
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 = CLISocketPathResolver.isImplicitDefaultPath(envSocketPath) ? .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",
command != "codex",
(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 == "themes" {
try runThemes(
commandArgs: commandArgs,
jsonOutput: jsonOutput
)
return
}
if command == "claude-teams" {
try runClaudeTeams(
commandArgs: commandArgs,
socketPath: resolvedSocketPath,
explicitPassword: socketPasswordArg
)
return
}
if command == "omo" {
try runOMO(
commandArgs: commandArgs,
socketPath: resolvedSocketPath,
explicitPassword: socketPasswordArg
)
return
}
// Codex hooks management (no socket needed)
if command == "codex" {
let sub = commandArgs.first?.lowercased() ?? "help"
if sub == "install-hooks" {
try runCodexInstallHooks()
return
} else if sub == "uninstall-hooks" {
try runCodexUninstallHooks()
return
}
}
// Codex hook handler: gracefully no-op when not inside cmux
// (before socket connection, so it doesn't fail when no socket exists)
if command == "codex-hook" {
guard ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] != nil else {
print("{}")
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, rem1) = parseOption(rem0, name: "--cwd")
let (nameOpt, remaining) = parseOption(rem1, name: "--name")
if let unknown = remaining.first(where: { $0.hasPrefix("--") }) {
throw CLIError(message: "new-workspace: unknown flag '\(unknown)'. Known flags: --name <title>, --command <text>, --cwd <path>")
}
var params: [String: Any] = [:]
if let cwdOpt {
let resolved = resolvePath(cwdOpt)
params["cwd"] = resolved
}
if let nameOpt {
params["title"] = nameOpt
}
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 {
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)
if let closedWorkspaceId = (payload["workspace_id"] as? String) ?? wsId,
let closedSurfaceId = (payload["surface_id"] as? String) ?? sfId {
try? tmuxPruneCompatSurfaceState(
workspaceId: closedWorkspaceId,
surfaceId: closedSurfaceId,
client: client
)
}
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 "debug-terminals":
let unexpected = commandArgs.filter { $0 != "--" }
if let extra = unexpected.first {
throw CLIError(message: "debug-terminals: unexpected argument '\(extra)'")
}
let payload = try client.sendV2(method: "debug.terminals")
if jsonOutput {
print(jsonString(formatIDs(payload, mode: idFormat)))
} else {
print(formatDebugTerminalsPayload(payload, idFormat: idFormat))
}
case "trigger-flash":
let tfWsFlag = optionValue(commandArgs, name: "--workspace")
let explicitWorkspaceArg = tfWsFlag
let preferTTYFallback = windowId == nil && ProcessInfo.processInfo.environment["TMUX"] != nil
let callerWorkspaceArg = preferTTYFallback
? nil
: (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let workspaceArg = explicitWorkspaceArg ?? callerWorkspaceArg
let explicitSurfaceArg = optionValue(commandArgs, name: "--surface") ?? optionValue(commandArgs, name: "--panel")
let callerSurfaceArg = explicitSurfaceArg == nil && preferTTYFallback == false && windowId == nil
? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"]
: nil
let surfaceArg = explicitSurfaceArg ?? callerSurfaceArg
var params: [String: Any] = [:]
let wsId = try {
if explicitWorkspaceArg != nil {
return try normalizeWorkspaceHandle(workspaceArg, client: client)
}
return try resolveWorkspaceIdAllowingFallback(workspaceArg, client: client)
}()
if let wsId { params["workspace_id"] = wsId }
let sfId = try {
if explicitSurfaceArg != nil {
return try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId)
}
guard let wsId else { return nil }
return try resolveSurfaceIdAllowingFallback(
surfaceArg,
workspaceId: wsId,
client: client
)
}()
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)
if let closedWorkspaceId = (payload["workspace_id"] as? String) ?? wsId {
try? tmuxPruneCompatWorkspaceState(workspaceId: closedWorkspaceId)
}
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 explicitWorkspaceArg = optionValue(commandArgs, name: "--workspace")
let preferTTYFallback = windowId == nil && ProcessInfo.processInfo.environment["TMUX"] != nil
let callerWorkspaceArg = preferTTYFallback
? nil
: (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let workspaceArg = explicitWorkspaceArg ?? callerWorkspaceArg
let explicitSurfaceArg = optionValue(commandArgs, name: "--surface")
let callerSurfaceArg = explicitSurfaceArg == nil && preferTTYFallback == false && windowId == nil
? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"]
: nil
let surfaceArg = explicitSurfaceArg ?? callerSurfaceArg
let targetWorkspace = try {
if explicitWorkspaceArg != nil {
return try resolveWorkspaceId(workspaceArg, client: client)
}
return try resolveWorkspaceIdAllowingFallback(workspaceArg, client: client)
}()
let targetSurface = try {
if explicitSurfaceArg != nil {
return try resolveSurfaceId(surfaceArg, workspaceId: targetWorkspace, client: client)
}
return try resolveSurfaceIdAllowingFallback(
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 "set-status":
let response = try forwardSidebarMetadataCommand(
"set_status",
commandArgs: commandArgs,
client: client,
windowOverride: windowId
)
print(response)
case "clear-status":
let response = try forwardSidebarMetadataCommand(
"clear_status",
commandArgs: commandArgs,
client: client,
windowOverride: windowId
)
print(response)
case "list-status":
let response = try forwardSidebarMetadataCommand(
"list_status",
commandArgs: commandArgs,
client: client,
windowOverride: windowId
)
print(response)
case "set-progress":
let response = try forwardSidebarMetadataCommand(
"set_progress",
commandArgs: commandArgs,
client: client,
windowOverride: windowId
)
print(response)
case "clear-progress":
let response = try forwardSidebarMetadataCommand(
"clear_progress",
commandArgs: commandArgs,
client: client,
windowOverride: windowId
)
print(response)
case "log":
let response = try forwardSidebarMetadataCommand(
"log",
commandArgs: commandArgs,
client: client,
windowOverride: windowId
)
print(response)
case "clear-log":
let response = try forwardSidebarMetadataCommand(
"clear_log",
commandArgs: commandArgs,
client: client,
windowOverride: windowId
)
print(response)
case "list-log":
let response = try forwardSidebarMetadataCommand(
"list_log",
commandArgs: commandArgs,
client: client,
windowOverride: windowId
)
print(response)
case "sidebar-state":
let response = try forwardSidebarMetadataCommand(
"sidebar_state",
commandArgs: commandArgs,
client: client,
windowOverride: windowId
)
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 "codex-hook":
cliTelemetry.breadcrumb("codex-hook.dispatch")
do {
try runCodexHook(commandArgs: commandArgs, client: client, telemetry: cliTelemetry)
cliTelemetry.breadcrumb("codex-hook.completed")
} catch {
cliTelemetry.breadcrumb("codex-hook.failure")
cliTelemetry.captureError(stage: "codex_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")
let (directionOpt, argsAfterDirection) = parseOption(argsAfterSurface, name: "--direction")
args = argsAfterDirection
// 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>] [--direction right|down|left|up]"
)
} 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>] [--direction right|down|left|up]"
)
}
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>] [--direction right|down|left|up]"
)
}
let absolutePath = resolvePath(rawPath)
// Build params
let direction = directionOpt ?? "right"
var params: [String: Any] = ["path": absolutePath, "direction": direction]
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()
let launchedClient = try SocketClient.waitForConnectableSocket(path: socketPath, timeout: 10)
defer { launchedClient.close() }
let params: [String: Any] = ["cwd": directory]
let response = try launchedClient.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 launchedClient = try SocketClient.waitForConnectableSocket(path: socketPath, timeout: 10)
try authenticateClientIfNeeded(
launchedClient,
explicitPassword: explicitPassword,
socketPath: socketPath
)
return launchedClient
}
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 doubleFromAny(_ value: Any?) -> Double? {
if let d = value as? Double { return d }
if let f = value as? Float { return Double(f) }
if let n = value as? NSNumber { return n.doubleValue }
if let s = value as? String { return Double(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 debugString(_ value: Any?) -> String? {
guard let value, !(value is NSNull) else { return nil }
if let string = value as? String {
return string
}
if let number = value as? NSNumber {
return number.stringValue
}
return String(describing: value)
}
private func debugBool(_ value: Any?) -> Bool? {
if let bool = value as? Bool {
return bool
}
if let number = value as? NSNumber {
return number.boolValue
}
if let string = value as? String {
return parseBoolString(string)
}
return nil
}
private func debugFlag(_ value: Any?) -> String {
guard let bool = debugBool(value) else { return "nil" }
return bool ? "1" : "0"
}
private func formatDebugRect(_ value: Any?) -> String? {
guard let rect = value as? [String: Any],
let x = doubleFromAny(rect["x"]),
let y = doubleFromAny(rect["y"]),
let width = doubleFromAny(rect["width"]),
let height = doubleFromAny(rect["height"]) else {
return nil
}
return String(format: "{%.1f,%.1f %.1fx%.1f}", x, y, width, height)
}
private func formatDebugPorts(_ value: Any?) -> String {
guard let array = value as? [Any], !array.isEmpty else { return "[]" }
let ports = array
.compactMap { intFromAny($0) }
.map(String.init)
return ports.isEmpty ? "[]" : ports.joined(separator: ",")
}
private func formatDebugList(_ value: Any?) -> String? {
guard let array = value as? [Any], !array.isEmpty else { return nil }
let items = array.compactMap { item -> String? in
if let string = item as? String {
return string
}
return debugString(item)
}
guard !items.isEmpty else { return nil }
return items.joined(separator: ">")
}
private func formatDebugAge(_ value: Any?) -> String? {
guard let seconds = doubleFromAny(value) else { return nil }
return String(format: "%.3fs", seconds)
}
private func formatDebugTerminalsPayload(_ payload: [String: Any], idFormat: CLIIDFormat) -> String {
let terminals = payload["terminals"] as? [[String: Any]] ?? []
guard !terminals.isEmpty else { return "No terminal surfaces" }
return terminals.map { item in
let index = intFromAny(item["index"]) ?? 0
let surface = formatHandle(item, kind: "surface", idFormat: idFormat) ?? "?"
let window = formatHandle(item, kind: "window", idFormat: idFormat) ?? "nil"
let workspace = formatHandle(item, kind: "workspace", idFormat: idFormat) ?? "nil"
let pane = formatHandle(item, kind: "pane", idFormat: idFormat) ?? "nil"
let bonsplitTab = debugString(item["bonsplit_tab_id"]) ?? "nil"
let lastKnownWorkspace = debugString(item["last_known_workspace_ref"]) ?? debugString(item["last_known_workspace_id"]) ?? "nil"
let titleSuffix: String = {
guard let title = debugString(item["surface_title"]), !title.isEmpty else { return "" }
let escaped = title.replacingOccurrences(of: "\"", with: "\\\"")
return " \"\(escaped)\""
}()
let branchLabel: String = {
guard let branch = debugString(item["git_branch"]), !branch.isEmpty else { return "nil" }
return debugBool(item["git_dirty"]) == true ? "\(branch)*" : branch
}()
let teardownLabel: String = {
guard debugBool(item["teardown_requested"]) == true else { return "nil" }
let reason = debugString(item["teardown_requested_reason"]) ?? "requested"
let age = formatDebugAge(item["teardown_requested_age_seconds"]) ?? "unknown"
return "\(reason)@\(age)"
}()
let portalHostLabel: String = {
let hostId = debugString(item["portal_host_id"]) ?? "nil"
let area = doubleFromAny(item["portal_host_area"]).map { String(format: "%.1f", $0) } ?? "nil"
let inWindow = debugFlag(item["portal_host_in_window"])
return "\(hostId)/win=\(inWindow)/area=\(area)"
}()
let windowMetaLabel: String = {
let title = debugString(item["window_title"]) ?? "nil"
let windowClass = debugString(item["window_class"]) ?? "nil"
let controllerClass = debugString(item["window_controller_class"]) ?? "nil"
let delegateClass = debugString(item["window_delegate_class"]) ?? "nil"
return "title=\(title) class=\(windowClass) controller=\(controllerClass) delegate=\(delegateClass)"
}()
let line1 =
"[\(index)] \(surface)\(titleSuffix) " +
"mapped=\(debugFlag(item["mapped"])) tree=\(debugFlag(item["tree_visible"])) " +
"window=\(window) workspace=\(workspace) pane=\(pane) bonsplitTab=\(bonsplitTab) " +
"ctx=\(debugString(item["surface_context"]) ?? "nil")"
let line2 =
" runtime=\(debugFlag(item["runtime_surface_ready"])) " +
"focused=\(debugFlag(item["surface_focused"])) " +
"selected=\(debugFlag(item["surface_selected_in_pane"])) " +
"pinned=\(debugFlag(item["surface_pinned"])) " +
"terminal=\(debugString(item["terminal_object_ptr"]) ?? "nil") " +
"hosted=\(debugString(item["hosted_view_ptr"]) ?? "nil") " +
"ghostty=\(debugString(item["ghostty_surface_ptr"]) ?? "nil") " +
"portal=\(debugString(item["portal_binding_state"]) ?? "nil")#\(debugString(item["portal_binding_generation"]) ?? "nil") " +
"teardown=\(teardownLabel)"
let line3 =
" tty=\(debugString(item["tty"]) ?? "nil") " +
"cwd=\(debugString(item["current_directory"]) ?? debugString(item["requested_working_directory"]) ?? "nil") " +
"branch=\(branchLabel) " +
"ports=\(formatDebugPorts(item["listening_ports"])) " +
"visible=\(debugFlag(item["hosted_view_visible_in_ui"])) " +
"inWindow=\(debugFlag(item["hosted_view_in_window"])) " +
"superview=\(debugFlag(item["hosted_view_has_superview"])) " +
"hidden=\(debugFlag(item["hosted_view_hidden"])) " +
"ancestorHidden=\(debugFlag(item["hosted_view_hidden_or_ancestor_hidden"])) " +
"firstResponder=\(debugFlag(item["surface_view_first_responder"])) " +
"windowNum=\(debugString(item["window_number"]) ?? "nil") " +
"windowKey=\(debugFlag(item["window_key"])) " +
"frame=\(formatDebugRect(item["hosted_view_frame_in_window"]) ?? "nil")"
let line4 =
" created=\(formatDebugAge(item["surface_age_seconds"]) ?? "nil") " +
"runtimeCreated=\(formatDebugAge(item["runtime_surface_age_seconds"]) ?? "nil") " +
"lastWorkspace=\(lastKnownWorkspace) " +
"initialCommand=\(debugString(item["initial_command"]) ?? "nil") " +
"portalHost=\(portalHostLabel)"
let line5 =
" window=\(windowMetaLabel) " +
"chain=\(formatDebugList(item["hosted_view_superview_chain"]) ?? "nil")"
return [line1, line2, line3, line4, line5].joined(separator: "\n")
}
.joined(separator: "\n")
}
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")
let (colorOpt, rem3) = parseOption(rem2, name: "--color")
var positional = rem3
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 inferredPositional = positional.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
let title = (titleOpt ?? (action == "rename" && !inferredPositional.isEmpty ? inferredPositional : nil))?.trimmingCharacters(in: .whitespacesAndNewlines)
if action == "rename", (title?.isEmpty ?? true) {
throw CLIError(message: "workspace-action rename requires --title <text> (or a trailing title)")
}
let color = (
colorOpt ?? (action == "set_color" ? (inferredPositional.isEmpty ? nil : inferredPositional) : nil)
)?.trimmingCharacters(in: .whitespacesAndNewlines)
if action == "set_color", (color?.isEmpty ?? true) {
throw CLIError(message: "workspace-action set-color requires --color <name|#hex> (or a trailing color)")
}
var params: [String: Any] = ["action": action]
if let workspaceId {
params["workspace_id"] = workspaceId
}
if let title, !title.isEmpty {
params["title"] = title
}
if let color, !color.isEmpty {
params["color"] = color
}
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)")
}
if let color = payload["color"] as? String {
summaryParts.append("color=\(color)")
}
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
)
}
struct SSHCommandOptions {
let destination: String
let port: Int?
let identityFile: String?
let workspaceName: String?
let noFocus: Bool
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 {
let sshStartedAt = Date()
// 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)
func logSSHTiming(_ stage: String, extra: String = "") {
let elapsedMs = Int(Date().timeIntervalSince(sshStartedAt) * 1000)
let suffix = extra.isEmpty ? "" : " \(extra)"
cliDebugLog(
"cli.ssh.timing target=\(sshOptions.destination) relayPort=\(sshOptions.remoteRelayPort) " +
"stage=\(stage) elapsedMs=\(elapsedMs)\(suffix)"
)
}
logSSHTiming("parsed")
let terminfoSource = localXtermGhosttyTerminfoSource()
cliDebugLog(
"cli.ssh.timing target=\(sshOptions.destination) relayPort=\(sshOptions.remoteRelayPort) " +
"stage=terminfo elapsedMs=0 mode=deferred term=xterm-256color " +
"source=\(terminfoSource == nil ? 0 : 1)"
)
let shellFeaturesValue = scopedGhosttyShellFeaturesValue()
let initialSSHCommand = buildSSHCommandText(sshOptions)
let remoteTerminalBootstrapScript = sshOptions.extraArguments.isEmpty
? buildInteractiveRemoteShellScript(
remoteRelayPort: sshOptions.remoteRelayPort,
shellFeatures: shellFeaturesValue,
terminfoSource: terminfoSource
)
: nil
let remoteTerminalSSHCommand = buildSSHCommandText(
sshOptions,
remoteBootstrapScript: remoteTerminalBootstrapScript
)
let initialSSHStartupCommand: String
let remoteTerminalSSHStartupCommand: String
if let remoteTerminalBootstrapScript, !remoteTerminalBootstrapScript.isEmpty {
let bootstrapSSHStartupCommand = try buildBootstrapSSHStartupCommand(
options: sshOptions,
remoteBootstrapScript: remoteTerminalBootstrapScript,
shellFeatures: shellFeaturesValue,
remoteRelayPort: sshOptions.remoteRelayPort
)
initialSSHStartupCommand = bootstrapSSHStartupCommand
remoteTerminalSSHStartupCommand = bootstrapSSHStartupCommand
} else {
initialSSHStartupCommand = try buildSSHStartupCommand(
sshCommand: initialSSHCommand,
shellFeatures: "",
remoteRelayPort: sshOptions.remoteRelayPort
)
remoteTerminalSSHStartupCommand = try buildSSHStartupCommand(
sshCommand: remoteTerminalSSHCommand,
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": initialSSHStartupCommand,
]
let workspaceCreateStartedAt = Date()
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")"
)
cliDebugLog(
"cli.ssh.timing target=\(sshOptions.destination) relayPort=\(sshOptions.remoteRelayPort) " +
"workspace=\(String(workspaceId.prefix(8))) stage=workspace.create elapsedMs=\(Int(Date().timeIntervalSince(workspaceCreateStartedAt) * 1000))"
)
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"] = remoteTerminalSSHStartupCommand
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: "|"))"
)
let configureStartedAt = Date()
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
}
// `cmux ssh` is an explicit "open this remote workspace now" action,
// so we intentionally select the newly created workspace after wiring
// up the remote connection unless --no-focus is passed.
if !sshOptions.noFocus {
_ = 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)"
)
cliDebugLog(
"cli.ssh.timing target=\(sshOptions.destination) relayPort=\(sshOptions.remoteRelayPort) " +
"workspace=\(String(workspaceId.prefix(8))) stage=workspace.remote.configure elapsedMs=\(Int(Date().timeIntervalSince(configureStartedAt) * 1000))"
)
} 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"] = initialSSHCommand
payload["ssh_startup_command"] = initialSSHStartupCommand
payload["ssh_terminal_command"] = remoteTerminalSSHCommand
payload["ssh_terminal_startup_command"] = remoteTerminalSSHStartupCommand
payload["ssh_env_overrides"] = [
"GHOSTTY_SHELL_FEATURES": shellFeaturesValue,
]
payload["remote_relay_port"] = remoteRelayPort
logSSHTiming("complete", extra: "workspace=\(String(workspaceId.prefix(8)))")
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 noFocus = false
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 "--no-focus":
noFocus = true
index += 1
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,
noFocus: noFocus,
sshOptions: sshOptions,
extraArguments: extraArguments,
localSocketPath: localSocketPath,
remoteRelayPort: remoteRelayPort
)
}
func buildSSHCommandText(
_ options: SSHCommandOptions,
remoteBootstrapScript: String? = nil
) -> String {
var parts = baseSSHArguments(options)
let trimmedRemoteBootstrap = remoteBootstrapScript?
.trimmingCharacters(in: .whitespacesAndNewlines)
if options.extraArguments.isEmpty {
if let trimmedRemoteBootstrap, !trimmedRemoteBootstrap.isEmpty {
let remoteCommand = sshPercentEscapedRemoteCommand(
encodedRemoteBootstrapCommand(trimmedRemoteBootstrap)
)
parts += ["-o", "RemoteCommand=\(remoteCommand)"]
}
if !hasSSHOptionKey(options.sshOptions, key: "RequestTTY") {
parts.append("-tt")
}
parts.append(options.destination)
} else {
parts.append(options.destination)
parts.append(contentsOf: options.extraArguments)
}
return parts.map(shellQuote).joined(separator: " ")
}
func buildBootstrapSSHStartupCommand(
options: SSHCommandOptions,
remoteBootstrapScript: String,
shellFeatures: String,
remoteRelayPort: Int
) throws -> String {
let commandSnippet = buildSSHBootstrapCommandSnippet(
options: options,
remoteBootstrapScript: remoteBootstrapScript
)
return try buildSSHStartupCommand(
sshCommand: commandSnippet,
shellFeatures: shellFeatures,
remoteRelayPort: remoteRelayPort,
isShellSnippet: true
)
}
private func buildSSHBootstrapCommandSnippet(
options: SSHCommandOptions,
remoteBootstrapScript: String
) -> String {
let encodedBootstrapScript = Data(remoteBootstrapScript.utf8).base64EncodedString()
let sshPrefix = baseSSHArguments(options).map(shellQuote).joined(separator: " ")
let remoteCommandBase64Placeholder = "__CMUX_REMOTE_BOOTSTRAP_B64_RUNTIME__"
let remoteCommandTemplate = sshPercentEscapedRemoteCommand(
runtimeEncodedRemoteBootstrapCommandShell(
base64Placeholder: remoteCommandBase64Placeholder
)
)
var lines: [String] = [
"cmux_workspace_id=\"${CMUX_WORKSPACE_ID:-}\"",
"cmux_surface_id=\"${CMUX_SURFACE_ID:-}\"",
"cmux_remote_bootstrap_b64=\(shellQuote(encodedBootstrapScript))",
"cmux_remote_bootstrap=\"$(printf %s \"$cmux_remote_bootstrap_b64\" | base64 -d 2>/dev/null || printf %s \"$cmux_remote_bootstrap_b64\" | base64 -D 2>/dev/null)\"",
"cmux_remote_bootstrap=\"$(printf '%s' \"$cmux_remote_bootstrap\" | sed \"s/__CMUX_WORKSPACE_ID__/$cmux_workspace_id/g; s/__CMUX_SURFACE_ID__/$cmux_surface_id/g\")\"",
"cmux_remote_bootstrap_b64_runtime=\"$(printf '%s' \"$cmux_remote_bootstrap\" | base64 | tr -d '\\n')\"",
"cmux_remote_command_template=\(shellQuote(remoteCommandTemplate))",
"cmux_remote_command=\"$(printf '%s' \"$cmux_remote_command_template\" | sed \"s|\(remoteCommandBase64Placeholder)|$cmux_remote_bootstrap_b64_runtime|g\")\"",
]
var sshInvocation = "command \(sshPrefix) -o \"RemoteCommand=$cmux_remote_command\""
if !hasSSHOptionKey(options.sshOptions, key: "RequestTTY") {
sshInvocation += " -tt"
}
sshInvocation += " " + shellQuote(options.destination)
lines.append(sshInvocation)
return lines.joined(separator: "\n")
}
private func runtimeEncodedRemoteBootstrapCommandShell(base64Placeholder: String) -> String {
return [
"cmux_tmp=$(mktemp \"${TMPDIR:-/tmp}/cmux-ssh-bootstrap.XXXXXX\") || exit 1",
"(printf %s '\(base64Placeholder)' | base64 -d 2>/dev/null || printf %s '\(base64Placeholder)' | base64 -D 2>/dev/null) > \"$cmux_tmp\" || { rm -f \"$cmux_tmp\"; exit 1; }",
"chmod 700 \"$cmux_tmp\" >/dev/null 2>&1 || true",
"/bin/sh \"$cmux_tmp\"",
"cmux_status=$?",
"rm -f \"$cmux_tmp\"",
"exit $cmux_status",
].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
}
func buildInteractiveRemoteShellScript(
remoteRelayPort: Int,
shellFeatures: String,
terminfoSource: String? = nil
) -> String {
let remoteTerminalLines = interactiveRemoteTerminalSetupLines(terminfoSource: terminfoSource)
let remoteEnvExportLines = interactiveRemoteShellExportLines(shellFeatures: shellFeatures)
let remoteCallerExportLines = [
"if [ -n '__CMUX_WORKSPACE_ID__' ]; then export CMUX_WORKSPACE_ID='__CMUX_WORKSPACE_ID__'; fi",
"if [ -n '__CMUX_SURFACE_ID__' ]; then export CMUX_SURFACE_ID='__CMUX_SURFACE_ID__'; fi",
]
let relaySocket = remoteRelayPort > 0 ? "127.0.0.1:\(remoteRelayPort)" : nil
let shellStateDir = "$HOME/.cmux/relay/\(max(remoteRelayPort, 0)).shell"
var commonShellLines = remoteTerminalLines
commonShellLines.append(contentsOf: remoteEnvExportLines)
commonShellLines.append("export PATH=\"$HOME/.cmux/bin:$PATH\"")
if let relaySocket {
commonShellLines.append("export CMUX_SOCKET_PATH=\(relaySocket)")
}
commonShellLines.append(contentsOf: remoteCallerExportLines)
commonShellLines.append(contentsOf: [
"hash -r >/dev/null 2>&1 || true",
"rehash >/dev/null 2>&1 || true",
])
let zshBootstrap = RemoteRelayZshBootstrap(shellStateDir: shellStateDir)
let zshEnvLines = zshBootstrap.zshEnvLines
let zshProfileLines = zshBootstrap.zshProfileLines
let zshRCLines = zshBootstrap.zshRCLines(commonShellLines: commonShellLines)
let zshLoginLines = zshBootstrap.zshLoginLines
let bashRCLines = [
"if [ -f \"$HOME/.bash_profile\" ]; then . \"$HOME/.bash_profile\"; elif [ -f \"$HOME/.bash_login\" ]; then . \"$HOME/.bash_login\"; elif [ -f \"$HOME/.profile\" ]; then . \"$HOME/.profile\"; fi",
"[ -f \"$HOME/.bashrc\" ] && . \"$HOME/.bashrc\"",
] + commonShellLines
let relayWarmupLines = interactiveRemoteRelayWarmupLines(remoteRelayPort: remoteRelayPort)
var outerLines: [String] = [
"CMUX_LOGIN_SHELL=\"${SHELL:-/bin/zsh}\"",
"case \"${CMUX_LOGIN_SHELL##*/}\" in",
" zsh)",
" mkdir -p \"$HOME/.cmux/relay\"",
" cmux_shell_dir=\"\(shellStateDir)\"",
" mkdir -p \"$cmux_shell_dir\"",
" cat > \"$cmux_shell_dir/.zshenv\" <<'CMUXZSHENV'",
]
outerLines.append(contentsOf: zshEnvLines)
outerLines += [
"CMUXZSHENV",
" cat > \"$cmux_shell_dir/.zprofile\" <<'CMUXZSHPROFILE'",
]
outerLines.append(contentsOf: zshProfileLines)
outerLines += [
"CMUXZSHPROFILE",
" cat > \"$cmux_shell_dir/.zshrc\" <<'CMUXZSHRC'",
]
outerLines.append(contentsOf: zshRCLines)
outerLines += [
"CMUXZSHRC",
" cat > \"$cmux_shell_dir/.zlogin\" <<'CMUXZSHLOGIN'",
]
outerLines.append(contentsOf: zshLoginLines)
outerLines += [
"CMUXZSHLOGIN",
" chmod 600 \"$cmux_shell_dir/.zshenv\" \"$cmux_shell_dir/.zprofile\" \"$cmux_shell_dir/.zshrc\" \"$cmux_shell_dir/.zlogin\" >/dev/null 2>&1 || true",
]
outerLines.append(contentsOf: relayWarmupLines.map { " " + $0 })
outerLines += [
" export CMUX_REAL_ZDOTDIR=\"${ZDOTDIR:-$HOME}\"",
" export ZDOTDIR=\"$cmux_shell_dir\"",
" exec \"$CMUX_LOGIN_SHELL\" -il",
" ;;",
" bash)",
" mkdir -p \"$HOME/.cmux/relay\"",
" cmux_shell_dir=\"\(shellStateDir)\"",
" mkdir -p \"$cmux_shell_dir\"",
" cat > \"$cmux_shell_dir/.bashrc\" <<'CMUXBASHRC'",
]
outerLines.append(contentsOf: bashRCLines)
outerLines += [
"CMUXBASHRC",
" chmod 600 \"$cmux_shell_dir/.bashrc\" >/dev/null 2>&1 || true",
]
outerLines.append(contentsOf: relayWarmupLines.map { " " + $0 })
outerLines += [
" exec \"$CMUX_LOGIN_SHELL\" --rcfile \"$cmux_shell_dir/.bashrc\" -i",
" ;;",
" *)",
]
outerLines.append(contentsOf: commonShellLines)
outerLines.append(contentsOf: relayWarmupLines)
outerLines += [
"exec \"$CMUX_LOGIN_SHELL\" -i",
";;",
"esac",
]
return outerLines.joined(separator: "\n")
}
func buildInteractiveRemoteShellCommand(
remoteRelayPort: Int,
shellFeatures: String,
terminfoSource: String? = nil
) -> String {
let script = buildInteractiveRemoteShellScript(
remoteRelayPort: remoteRelayPort,
shellFeatures: shellFeatures,
terminfoSource: terminfoSource
)
return "/bin/sh -c \(shellQuote(script))"
}
private func interactiveRemoteTerminalSetupLines(terminfoSource: String?) -> [String] {
var lines: [String] = [
"cmux_term='xterm-256color'",
"if command -v infocmp >/dev/null 2>&1 && infocmp xterm-ghostty >/dev/null 2>&1; then",
" cmux_term='xterm-ghostty'",
"fi",
"export TERM=\"$cmux_term\"",
]
guard let terminfoSource else { return lines }
let trimmedTerminfoSource = terminfoSource.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedTerminfoSource.isEmpty else { return lines }
lines += [
"if [ \"$cmux_term\" != 'xterm-ghostty' ]; then",
" (",
" command -v tic >/dev/null 2>&1 || exit 0",
" mkdir -p \"$HOME/.terminfo\" 2>/dev/null || exit 0",
" cat <<'CMUXTERMINFO' | tic -x - >/dev/null 2>&1",
trimmedTerminfoSource,
"CMUXTERMINFO",
" ) >/dev/null 2>&1 &",
"fi",
]
return lines
}
private func interactiveRemoteShellExportLines(shellFeatures: String) -> [String] {
let environment = ProcessInfo.processInfo.environment
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 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
}
private func interactiveRemoteRelayWarmupLines(remoteRelayPort: Int) -> [String] {
guard remoteRelayPort > 0 else { return [] }
return []
}
private func baseSSHArguments(_ options: SSHCommandOptions) -> [String] {
let effectiveSSHOptions = effectiveSSHOptions(
options.sshOptions,
remoteRelayPort: options.remoteRelayPort
)
var parts: [String] = ["ssh"]
if !hasSSHOptionKey(effectiveSSHOptions, key: "ConnectTimeout") {
parts += ["-o", "ConnectTimeout=6"]
}
if !hasSSHOptionKey(effectiveSSHOptions, key: "ServerAliveInterval") {
parts += ["-o", "ServerAliveInterval=20"]
}
if !hasSSHOptionKey(effectiveSSHOptions, key: "ServerAliveCountMax") {
parts += ["-o", "ServerAliveCountMax=2"]
}
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 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: ",")
}
func encodedRemoteBootstrapCommand(_ remoteBootstrapScript: String) -> String {
let encodedScript = Data(remoteBootstrapScript.utf8).base64EncodedString()
let encodedLiteral = shellQuote(encodedScript)
return [
"cmux_tmp=$(mktemp \"${TMPDIR:-/tmp}/cmux-ssh-bootstrap.XXXXXX\") || exit 1",
"(printf %s \(encodedLiteral) | base64 -d 2>/dev/null || printf %s \(encodedLiteral) | base64 -D 2>/dev/null) > \"$cmux_tmp\" || { rm -f \"$cmux_tmp\"; exit 1; }",
"chmod 700 \"$cmux_tmp\" >/dev/null 2>&1 || true",
"/bin/sh \"$cmux_tmp\"",
"cmux_status=$?",
"rm -f \"$cmux_tmp\"",
"exit $cmux_status",
].joined(separator: "; ")
}
func sshPercentEscapedRemoteCommand(_ remoteCommand: String) -> String {
remoteCommand.replacingOccurrences(of: "%", with: "%%")
}
func buildSSHStartupCommand(
sshCommand: String,
shellFeatures: String,
remoteRelayPort: Int,
isShellSnippet: Bool = false
) throws -> String {
let trimmedFeatures = shellFeatures.trimmingCharacters(in: .whitespacesAndNewlines)
let shellFeaturesBootstrap: String = trimmedFeatures.isEmpty
? ""
: "export GHOSTTY_SHELL_FEATURES=\(shellQuote(trimmedFeatures))"
let lifecycleCleanup = buildSSHSessionEndShellCommand(remoteRelayPort: remoteRelayPort)
var scriptLines: [String] = []
if !shellFeaturesBootstrap.isEmpty {
scriptLines.append(shellFeaturesBootstrap)
}
scriptLines += [
"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",
]
if isShellSnippet {
scriptLines.append(sshCommand)
} else {
scriptLines.append("command \(sshCommand)")
}
scriptLines += [
"cmux_ssh_status=$?",
"trap - EXIT HUP INT TERM",
"cmux_ssh_session_end",
"exit $cmux_ssh_status",
]
let script = scriptLines.joined(separator: "\n")
return try writeSSHStartupScript(script, remoteRelayPort: remoteRelayPort)
}
private func writeSSHStartupScript(_ scriptBody: String, remoteRelayPort: Int) throws -> String {
let tempDir = FileManager.default.temporaryDirectory
let scriptURL = tempDir.appendingPathComponent(
"cmux-ssh-startup-\(remoteRelayPort)-\(UUID().uuidString.lowercased()).sh"
)
let script = "#!/bin/sh\n\(scriptBody)\n"
try script.write(to: scriptURL, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: scriptURL.path)
return shellQuote(scriptURL.path)
}
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,
timeout: TimeInterval? = nil
) -> (status: Int32, stdout: String, stderr: String) {
let result = CLIProcessRunner.runProcess(
executablePath: executablePath,
arguments: arguments,
stdinText: stdinText,
timeout: timeout
)
return (result.status, result.stdout, result.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])
let fallback: String
if let value = payload["value"] {
fallback = displayBrowserValue(value)
} else {
fallback = "OK"
}
output(payload, fallback: fallback)
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])
if effectiveJSONOutput || consoleVerb == "clear" {
output(payload, fallback: "OK")
} else {
print(displayBrowserLogItems(payload["entries"]) ?? "No console entries")
}
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)
if effectiveJSONOutput || errorsVerb == "clear" {
output(payload, fallback: "OK")
} else {
print(displayBrowserLogItems(payload["errors"]) ?? "No browser errors")
}
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 "themes":
return """
Usage: cmux themes
cmux themes list
cmux themes set <theme>
cmux themes set --light <theme> [--dark <theme>]
cmux themes set --dark <theme> [--light <theme>]
cmux themes clear
When run in a TTY, `cmux themes` opens an interactive theme picker with
live app preview. Use `cmux themes list` for a plain listing.
The picker previews the selected theme across the running cmux app and
lets you apply it to the light theme, dark theme, or both defaults.
Commands:
list List available themes and mark the current light/dark defaults
set <theme> Set the same theme for both light and dark appearance
set --light <theme> Set the light appearance theme
set --dark <theme> Set the dark appearance theme
clear Remove the cmux theme override and fall back to other config
Examples:
cmux themes
cmux themes list
cmux themes set "Catppuccin Mocha"
cmux themes set --light "Catppuccin Latte" --dark "Catppuccin Mocha"
cmux themes clear
"""
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 "omo":
return String(localized: "cli.omo.usage", defaultValue: """
Usage: cmux omo [opencode-args...]
Launch OpenCode with oh-my-openagent in a cmux-aware environment.
oh-my-openagent orchestrates multiple AI models as specialized agents in
parallel. This command sets up a tmux shim so agent panes become native
cmux splits with sidebar metadata and notifications.
This command:
- sets a tmux-like environment so oh-my-openagent uses cmux splits
- prepends a private tmux shim to PATH
- forwards all remaining arguments to opencode
The tmux shim translates tmux window/pane commands into cmux workspace
and split operations in the current cmux session.
Examples:
cmux omo
cmux omo --continue
cmux omo --model claude-sonnet-4-6
""")
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
set-color | clear-color
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
--color <name|#hex> Color for set-color (name or #RRGGBB hex)
Named colors:
Red, Crimson, Orange, Amber, Olive, Green, Teal, Aqua,
Blue, Navy, Indigo, Purple, Magenta, Rose, Brown, Charcoal
Example:
cmux workspace-action --workspace workspace:2 --action pin
cmux workspace-action --action rename --title "infra"
cmux workspace-action close-others
cmux workspace-action --action set-color --color blue
cmux workspace-action --action set-color --color "#C0392B"
cmux workspace-action set-color Amber
cmux workspace-action clear-color
"""
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 [--name <title>] [--cwd <path>] [--command <text>]
Create a new workspace in the current window.
Flags:
--name <title> Set a custom name for the new workspace
--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 --name "Build Server"
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)
--no-focus Create workspace without switching to it
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 "debug-terminals":
return """
Usage: cmux debug-terminals
Print live Ghostty terminal runtime metadata across all windows and workspaces.
Intended for debugging stray or detached terminal views.
"""
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 "codex":
return """
Usage: cmux codex <install-hooks|uninstall-hooks>
Manage Codex CLI hooks integration.
Subcommands:
install-hooks Install cmux hooks into ~/.codex/hooks.json
uninstall-hooks Remove cmux hooks from ~/.codex/hooks.json
"""
case "codex-hook":
return """
Usage: cmux codex-hook <session-start|prompt-submit|stop> [flags]
Hook for Codex CLI integration. Reads JSON from stdin.
Gracefully no-ops when not running inside cmux.
Subcommands:
session-start Register a Codex session
prompt-submit Set Running status on user prompt
stop Send completion notification, set Idle
Flags:
--workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID)
--surface <id|ref> Target surface (default: $CMUX_SURFACE_ID)
"""
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
--direction <left|right|up|down> Split direction (default: right)
Examples:
cmux markdown open plan.md
cmux markdown ~/project/CHANGELOG.md
cmux markdown open ./docs/design.md --workspace 0
cmux markdown open plan.md --direction down
"""
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 static let cmuxThemeOverrideBundleIdentifier = "com.cmuxterm.app"
private static let cmuxThemesBlockStart = "# cmux themes start"
private static let cmuxThemesBlockEnd = "# cmux themes end"
private static let cmuxThemesReloadNotificationName = "com.cmuxterm.themes.reload-config"
private struct ThemeSelection {
let rawValue: String?
let light: String?
let dark: String?
let sourcePath: String?
}
private struct ThemeReloadStatus {
let requested: Bool
let targetBundleIdentifier: String
}
private enum ThemePickerTargetMode: String {
case both
case light
case dark
}
private func shouldUseInteractiveThemePicker(jsonOutput: Bool) -> Bool {
guard !jsonOutput else { return false }
return isatty(STDIN_FILENO) == 1 && isatty(STDOUT_FILENO) == 1
}
private func runInteractiveThemes() throws {
guard let helperURL = bundledHelperURL(named: "ghostty") else {
throw CLIError(message: "Bundled Ghostty theme picker helper not found")
}
let selection = currentThemeSelection()
var environment = ProcessInfo.processInfo.environment
environment["CMUX_THEME_PICKER_CONFIG"] = try cmuxThemeOverrideConfigURL().path
environment["CMUX_THEME_PICKER_BUNDLE_ID"] = currentCmuxAppBundleIdentifier() ?? Self.cmuxThemeOverrideBundleIdentifier
environment["CMUX_THEME_PICKER_TARGET"] = defaultThemePickerTargetMode(current: selection).rawValue
environment["CMUX_THEME_PICKER_COLOR_SCHEME"] = defaultAppearancePrefersDarkThemes() ? "dark" : "light"
if let light = selection.light {
environment["CMUX_THEME_PICKER_INITIAL_LIGHT"] = light
}
if let dark = selection.dark {
environment["CMUX_THEME_PICKER_INITIAL_DARK"] = dark
}
if let resourcesURL = bundledGhosttyResourcesURL() {
environment["GHOSTTY_RESOURCES_DIR"] = resourcesURL.path
}
try execInteractiveHelper(
executablePath: helperURL.path,
arguments: ["+list-themes"],
environment: environment
)
}
private func defaultThemePickerTargetMode(current: ThemeSelection) -> ThemePickerTargetMode {
if let light = current.light,
let dark = current.dark,
light.caseInsensitiveCompare(dark) == .orderedSame {
return .both
}
return defaultAppearancePrefersDarkThemes() ? .dark : .light
}
private func defaultAppearancePrefersDarkThemes() -> Bool {
let globalDefaults = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain)
let interfaceStyle = (globalDefaults?["AppleInterfaceStyle"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
return interfaceStyle?.caseInsensitiveCompare("Dark") == .orderedSame
}
private func bundledHelperURL(named helperName: String) -> URL? {
let fileManager = FileManager.default
guard let executableURL = resolvedExecutableURL() else { return nil }
var candidates: [URL] = [
executableURL.deletingLastPathComponent().appendingPathComponent(helperName, isDirectory: false)
]
var current = executableURL.deletingLastPathComponent().standardizedFileURL
while true {
if current.lastPathComponent == "Contents" {
candidates.append(
current
.appendingPathComponent("Resources", isDirectory: true)
.appendingPathComponent("bin", isDirectory: true)
.appendingPathComponent(helperName, isDirectory: false)
)
}
let projectMarker = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj", isDirectory: false)
let repoHelper = current
.appendingPathComponent("ghostty", isDirectory: true)
.appendingPathComponent("zig-out", isDirectory: true)
.appendingPathComponent("bin", isDirectory: true)
.appendingPathComponent(helperName, isDirectory: false)
if fileManager.fileExists(atPath: projectMarker.path),
fileManager.isExecutableFile(atPath: repoHelper.path) {
candidates.append(repoHelper)
break
}
guard let parent = parentSearchURL(for: current) else { break }
current = parent
}
return candidates.first(where: { fileManager.isExecutableFile(atPath: $0.path) })
}
private func execInteractiveHelper(
executablePath: String,
arguments: [String],
environment: [String: String]
) throws -> Never {
var argv = ([executablePath] + arguments).map { strdup($0) }
defer {
for item in argv {
free(item)
}
}
argv.append(nil)
var envp = environment
.map { key, value in strdup("\(key)=\(value)") }
defer {
for item in envp {
free(item)
}
}
envp.append(nil)
execve(executablePath, &argv, &envp)
let code = errno
throw CLIError(message: "Failed to launch interactive theme picker: \(String(cString: strerror(code)))")
}
private func bundledGhosttyResourcesURL() -> URL? {
let fileManager = FileManager.default
guard let executableURL = resolvedExecutableURL() else { return nil }
var current = executableURL.deletingLastPathComponent().standardizedFileURL
while true {
if current.lastPathComponent == "Contents" {
let candidate = current
.appendingPathComponent("Resources", isDirectory: true)
.appendingPathComponent("ghostty", isDirectory: true)
if fileManager.fileExists(atPath: candidate.path) {
return candidate
}
}
let projectMarker = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj", isDirectory: false)
let repoResources = current
.appendingPathComponent("Resources", isDirectory: true)
.appendingPathComponent("ghostty", isDirectory: true)
if fileManager.fileExists(atPath: projectMarker.path),
fileManager.fileExists(atPath: repoResources.path) {
return repoResources
}
guard let parent = parentSearchURL(for: current) else { break }
current = parent
}
return Bundle.main.resourceURL?.appendingPathComponent("ghostty", isDirectory: true)
}
private func runThemes(commandArgs: [String], jsonOutput: Bool) throws {
if commandArgs.isEmpty {
if shouldUseInteractiveThemePicker(jsonOutput: jsonOutput) {
try runInteractiveThemes()
return
}
try printThemesList(jsonOutput: jsonOutput)
return
}
guard let subcommand = commandArgs.first else {
try printThemesList(jsonOutput: jsonOutput)
return
}
switch subcommand {
case "list":
if commandArgs.count > 1 {
throw CLIError(message: "themes list does not take any positional arguments")
}
try printThemesList(jsonOutput: jsonOutput)
case "set":
try runThemesSet(
args: Array(commandArgs.dropFirst()),
jsonOutput: jsonOutput
)
case "clear":
if commandArgs.count > 1 {
throw CLIError(message: "themes clear does not take any positional arguments")
}
try runThemesClear(jsonOutput: jsonOutput)
default:
if subcommand.hasPrefix("-") {
throw CLIError(message: "Unknown themes subcommand '\(subcommand)'. Run 'cmux themes --help'.")
}
try runThemesSet(
args: commandArgs,
jsonOutput: jsonOutput
)
}
}
private func printThemesList(jsonOutput: Bool) throws {
let themes = availableThemeNames()
let current = currentThemeSelection()
let configPath = try cmuxThemeOverrideConfigURL().path
if jsonOutput {
let currentPayload: [String: Any] = [
"raw_value": current.rawValue ?? NSNull(),
"light": current.light ?? NSNull(),
"dark": current.dark ?? NSNull(),
"source_path": current.sourcePath ?? NSNull()
]
let payload: [String: Any] = [
"themes": themes.map { theme in
[
"name": theme,
"current_light": current.light?.caseInsensitiveCompare(theme) == .orderedSame,
"current_dark": current.dark?.caseInsensitiveCompare(theme) == .orderedSame
]
},
"current": currentPayload,
"config_path": configPath
]
print(jsonString(payload))
return
}
print("Current light: \(current.light ?? "inherit")")
print("Current dark: \(current.dark ?? "inherit")")
print("Config: \(configPath)")
if let sourcePath = current.sourcePath {
print("Source: \(sourcePath)")
}
print("")
guard !themes.isEmpty else {
print("No themes found.")
return
}
for theme in themes {
var badges: [String] = []
if current.light?.caseInsensitiveCompare(theme) == .orderedSame {
badges.append("light")
}
if current.dark?.caseInsensitiveCompare(theme) == .orderedSame {
badges.append("dark")
}
let badgeText = badges.isEmpty ? "" : " [\(badges.joined(separator: ", "))]"
print("\(theme)\(badgeText)")
}
}
private func runThemesSet(args: [String], jsonOutput: Bool) throws {
let (lightOpt, rem0) = parseOption(args, name: "--light")
let (darkOpt, rem1) = parseOption(rem0, name: "--dark")
if let unknown = rem1.first(where: { $0.hasPrefix("--") }) {
throw CLIError(message: "themes set: unknown flag '\(unknown)'. Known flags: --light <theme>, --dark <theme>")
}
let availableThemes = availableThemeNames()
let current = currentThemeSelection()
let lightTheme: String?
let darkTheme: String?
if lightOpt == nil && darkOpt == nil {
let joinedTheme = rem1.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
guard !joinedTheme.isEmpty else {
throw CLIError(message: "themes set requires a theme name or --light/--dark flags")
}
let resolved = try validatedThemeName(joinedTheme, availableThemes: availableThemes)
lightTheme = resolved
darkTheme = resolved
} else {
if !rem1.isEmpty {
throw CLIError(message: "themes set: unexpected argument '\(rem1.joined(separator: " "))'")
}
lightTheme = try lightOpt.map { try validatedThemeName($0, availableThemes: availableThemes) } ?? current.light
darkTheme = try darkOpt.map { try validatedThemeName($0, availableThemes: availableThemes) } ?? current.dark
}
guard let rawThemeValue = encodedThemeValue(light: lightTheme, dark: darkTheme) else {
throw CLIError(message: "themes set requires at least one theme")
}
let configURL = try writeManagedThemeOverride(rawThemeValue: rawThemeValue)
let reloadStatus = reloadThemesIfPossible()
if jsonOutput {
let payload: [String: Any] = [
"ok": true,
"light": lightTheme ?? NSNull(),
"dark": darkTheme ?? NSNull(),
"raw_value": rawThemeValue,
"config_path": configURL.path,
"reload_requested": reloadStatus.requested,
"reload_target_bundle_id": reloadStatus.targetBundleIdentifier
]
print(jsonString(payload))
return
}
print(
"OK light=\(lightTheme ?? "-") dark=\(darkTheme ?? "-") config=\(configURL.path) reload=requested"
)
}
private func runThemesClear(jsonOutput: Bool) throws {
let configURL = try clearManagedThemeOverride()
let reloadStatus = reloadThemesIfPossible()
if jsonOutput {
let payload: [String: Any] = [
"ok": true,
"cleared": true,
"config_path": configURL.path,
"reload_requested": reloadStatus.requested,
"reload_target_bundle_id": reloadStatus.targetBundleIdentifier
]
print(jsonString(payload))
return
}
print("OK cleared config=\(configURL.path) reload=requested")
}
private func currentThemeSelection() -> ThemeSelection {
var rawValue: String?
var sourcePath: String?
for url in themeConfigSearchURLs() {
guard let contents = try? String(contentsOf: url, encoding: .utf8),
let nextValue = lastThemeDirective(in: contents) else {
continue
}
rawValue = nextValue
sourcePath = url.path
}
return parseThemeSelection(rawValue: rawValue, sourcePath: sourcePath)
}
private func parseThemeSelection(rawValue: String?, sourcePath: String?) -> ThemeSelection {
guard let rawValue = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines), !rawValue.isEmpty else {
return ThemeSelection(rawValue: nil, light: nil, dark: nil, sourcePath: sourcePath)
}
var fallbackTheme: String?
var lightTheme: String?
var darkTheme: String?
for token in rawValue.split(separator: ",").map(String.init) {
let entry = token.trimmingCharacters(in: .whitespacesAndNewlines)
guard !entry.isEmpty else { continue }
let parts = entry.split(separator: ":", maxSplits: 1).map(String.init)
if parts.count != 2 {
if fallbackTheme == nil {
fallbackTheme = entry
}
continue
}
let key = parts[0].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let value = parts[1].trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { continue }
switch key {
case "light":
if lightTheme == nil {
lightTheme = value
}
case "dark":
if darkTheme == nil {
darkTheme = value
}
default:
if fallbackTheme == nil {
fallbackTheme = value
}
}
}
let resolvedLight = lightTheme ?? fallbackTheme ?? darkTheme
let resolvedDark = darkTheme ?? fallbackTheme ?? lightTheme
return ThemeSelection(rawValue: rawValue, light: resolvedLight, dark: resolvedDark, sourcePath: sourcePath)
}
private func encodedThemeValue(light: String?, dark: String?) -> String? {
let normalizedLight = light?.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedDark = dark?.trimmingCharacters(in: .whitespacesAndNewlines)
switch (normalizedLight?.isEmpty == false ? normalizedLight : nil, normalizedDark?.isEmpty == false ? normalizedDark : nil) {
case let (lightTheme?, darkTheme?):
return "light:\(lightTheme),dark:\(darkTheme)"
case let (lightTheme?, nil):
return "light:\(lightTheme)"
case let (nil, darkTheme?):
return "dark:\(darkTheme)"
case (nil, nil):
return nil
}
}
private func availableThemeNames() -> [String] {
let fileManager = FileManager.default
var seen: Set<String> = []
var themes: [String] = []
for directoryURL in themeDirectoryURLs() {
guard let entries = try? fileManager.contentsOfDirectory(
at: directoryURL,
includingPropertiesForKeys: [.isDirectoryKey, .isRegularFileKey],
options: [.skipsHiddenFiles]
) else {
continue
}
for entry in entries {
let values = try? entry.resourceValues(forKeys: [.isDirectoryKey, .isRegularFileKey])
guard values?.isDirectory != true else { continue }
guard values?.isRegularFile == true || values?.isRegularFile == nil else { continue }
let name = entry.lastPathComponent
let folded = name.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current)
if seen.insert(folded).inserted {
themes.append(name)
}
}
}
return themes.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
}
private func themeDirectoryURLs() -> [URL] {
let fileManager = FileManager.default
let processEnv = ProcessInfo.processInfo.environment
var urls: [URL] = []
var seen: Set<String> = []
func appendIfExisting(_ url: URL?) {
guard let url else { return }
let standardized = url.standardizedFileURL
guard fileManager.fileExists(atPath: standardized.path) else { return }
if seen.insert(standardized.path).inserted {
urls.append(standardized)
}
}
if let resourcesDir = processEnv["GHOSTTY_RESOURCES_DIR"]?.trimmingCharacters(in: .whitespacesAndNewlines),
!resourcesDir.isEmpty {
appendIfExisting(URL(fileURLWithPath: resourcesDir, isDirectory: true).appendingPathComponent("themes", isDirectory: true))
}
appendIfExisting(
Bundle.main.resourceURL?
.appendingPathComponent("ghostty", isDirectory: true)
.appendingPathComponent("themes", isDirectory: true)
)
if let executableURL = resolvedExecutableURL() {
var current = executableURL.deletingLastPathComponent().standardizedFileURL
while true {
if current.lastPathComponent == "Resources" {
appendIfExisting(
current
.appendingPathComponent("ghostty", isDirectory: true)
.appendingPathComponent("themes", isDirectory: true)
)
}
if current.lastPathComponent == "Contents" {
appendIfExisting(
current
.appendingPathComponent("Resources", isDirectory: true)
.appendingPathComponent("ghostty", isDirectory: true)
.appendingPathComponent("themes", isDirectory: true)
)
}
let projectMarker = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj", isDirectory: false)
let repoThemes = current.appendingPathComponent("Resources/ghostty/themes", isDirectory: true)
if fileManager.fileExists(atPath: projectMarker.path),
fileManager.fileExists(atPath: repoThemes.path) {
appendIfExisting(repoThemes)
break
}
guard let parent = parentSearchURL(for: current) else { break }
current = parent
}
}
if let xdgDataDirs = processEnv["XDG_DATA_DIRS"] {
for dataDir in xdgDataDirs.split(separator: ":").map(String.init).filter({ !$0.isEmpty }) {
appendIfExisting(
URL(fileURLWithPath: NSString(string: dataDir).expandingTildeInPath, isDirectory: true)
.appendingPathComponent("ghostty/themes", isDirectory: true)
)
}
}
appendIfExisting(URL(fileURLWithPath: "/Applications/Ghostty.app/Contents/Resources/ghostty/themes", isDirectory: true))
appendIfExisting(URL(fileURLWithPath: NSString(string: "~/.config/ghostty/themes").expandingTildeInPath, isDirectory: true))
appendIfExisting(
URL(
fileURLWithPath: NSString(
string: "~/Library/Application Support/com.mitchellh.ghostty/themes"
).expandingTildeInPath,
isDirectory: true
)
)
return urls
}
private func validatedThemeName(_ rawValue: String, availableThemes: [String]) throws -> String {
let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
throw CLIError(message: "Theme name cannot be empty")
}
if let matched = availableThemes.first(where: { $0.caseInsensitiveCompare(trimmed) == .orderedSame }) {
return matched
}
if availableThemes.isEmpty {
return trimmed
}
throw CLIError(message: "Unknown theme '\(trimmed)'. Run 'cmux themes' to list available themes.")
}
private func themeConfigSearchURLs() -> [URL] {
let rawPaths = [
"~/.config/ghostty/config",
"~/.config/ghostty/config.ghostty",
"~/Library/Application Support/com.mitchellh.ghostty/config",
"~/Library/Application Support/com.mitchellh.ghostty/config.ghostty",
"~/Library/Application Support/\(Self.cmuxThemeOverrideBundleIdentifier)/config",
"~/Library/Application Support/\(Self.cmuxThemeOverrideBundleIdentifier)/config.ghostty",
]
return rawPaths.map {
URL(fileURLWithPath: NSString(string: $0).expandingTildeInPath, isDirectory: false)
}
}
private func lastThemeDirective(in contents: String) -> String? {
var lastValue: String?
for line in contents.components(separatedBy: .newlines) {
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty || trimmed.hasPrefix("#") {
continue
}
let parts = trimmed.split(separator: "=", maxSplits: 1).map(String.init)
guard parts.count == 2 else { continue }
guard parts[0].trimmingCharacters(in: .whitespacesAndNewlines) == "theme" else { continue }
let value = parts[1]
.trimmingCharacters(in: .whitespacesAndNewlines)
.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
if !value.isEmpty {
lastValue = value
}
}
return lastValue
}
private func cmuxThemeOverrideConfigURL() throws -> URL {
guard let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
throw CLIError(message: "Unable to resolve Application Support directory")
}
return appSupport
.appendingPathComponent(Self.cmuxThemeOverrideBundleIdentifier, isDirectory: true)
.appendingPathComponent("config.ghostty", isDirectory: false)
}
private func writeManagedThemeOverride(rawThemeValue: String) throws -> URL {
let fileManager = FileManager.default
let configURL = try cmuxThemeOverrideConfigURL()
let directoryURL = configURL.deletingLastPathComponent()
try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil)
let existingContents = try readOptionalThemeOverrideContents(at: configURL) ?? ""
let strippedContents = removingManagedThemeOverride(from: existingContents)
.trimmingCharacters(in: .whitespacesAndNewlines)
let block = """
\(Self.cmuxThemesBlockStart)
theme = \(rawThemeValue)
\(Self.cmuxThemesBlockEnd)
"""
let nextContents = strippedContents.isEmpty ? "\(block)\n" : "\(strippedContents)\n\n\(block)\n"
try nextContents.write(to: configURL, atomically: true, encoding: .utf8)
return configURL
}
private func clearManagedThemeOverride() throws -> URL {
let fileManager = FileManager.default
let configURL = try cmuxThemeOverrideConfigURL()
guard let existingContents = try readOptionalThemeOverrideContents(at: configURL) else {
return configURL
}
let strippedContents = removingManagedThemeOverride(from: existingContents)
.trimmingCharacters(in: .whitespacesAndNewlines)
if strippedContents.isEmpty {
do {
try fileManager.removeItem(at: configURL)
} catch {
guard !isThemeOverrideFileNotFoundError(error) else {
return configURL
}
throw error
}
} else {
try strippedContents.appending("\n").write(to: configURL, atomically: true, encoding: .utf8)
}
return configURL
}
private func readOptionalThemeOverrideContents(at url: URL) throws -> String? {
do {
return try String(contentsOf: url, encoding: .utf8)
} catch {
guard isThemeOverrideFileNotFoundError(error) else {
throw error
}
return nil
}
}
private func isThemeOverrideFileNotFoundError(_ error: Error) -> Bool {
let nsError = error as NSError
if nsError.domain == NSCocoaErrorDomain {
return nsError.code == NSFileNoSuchFileError || nsError.code == NSFileReadNoSuchFileError
}
if nsError.domain == NSPOSIXErrorDomain {
return nsError.code == ENOENT
}
return false
}
private func removingManagedThemeOverride(from contents: String) -> String {
let pattern = #"(?ms)\n?# cmux themes start\n.*?\n# cmux themes end\n?"#
guard let regex = try? NSRegularExpression(pattern: pattern) else {
return contents
}
let fullRange = NSRange(contents.startIndex..<contents.endIndex, in: contents)
return regex.stringByReplacingMatches(in: contents, options: [], range: fullRange, withTemplate: "")
}
private func reloadThemesIfPossible() -> ThemeReloadStatus {
let bundleIdentifier = currentCmuxAppBundleIdentifier() ?? Self.cmuxThemeOverrideBundleIdentifier
DistributedNotificationCenter.default().post(
name: Notification.Name(Self.cmuxThemesReloadNotificationName),
object: nil,
userInfo: ["bundleIdentifier": bundleIdentifier]
)
return ThemeReloadStatus(requested: true, targetBundleIdentifier: bundleIdentifier)
}
private func currentCmuxAppBundleIdentifier() -> String? {
if let bundleIdentifier = ProcessInfo.processInfo.environment["CMUX_BUNDLE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines),
!bundleIdentifier.isEmpty {
return bundleIdentifier
}
if let bundleIdentifier = Bundle.main.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
!bundleIdentifier.isEmpty {
return bundleIdentifier
}
guard let executableURL = resolvedExecutableURL() else {
return nil
}
var current = executableURL.deletingLastPathComponent().standardizedFileURL
while true {
if current.pathExtension == "app",
let bundleIdentifier = Bundle(url: current)?.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
!bundleIdentifier.isEmpty {
return bundleIdentifier
}
if current.lastPathComponent == "Contents" {
let appURL = current.deletingLastPathComponent().standardizedFileURL
if appURL.pathExtension == "app",
let bundleIdentifier = Bundle(url: appURL)?.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
!bundleIdentifier.isEmpty {
return bundleIdentifier
}
}
guard let parent = parentSearchURL(for: current) else {
break
}
current = parent
}
return nil
}
/// Escape and quote a string for safe embedding in a v1 socket command.
/// The socket tokenizer treats `\` and `"` as special inside quoted strings,
/// so both must be escaped before wrapping in double quotes. Newlines and
/// carriage returns must also be escaped since the socket protocol uses
/// newline as the message terminator.
private func socketQuote(_ s: String) -> String {
let escaped = s
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
.replacingOccurrences(of: "\n", with: "\\n")
.replacingOccurrences(of: "\r", with: "\\r")
return "\"\(escaped)\""
}
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"]
}
private func forwardSidebarMetadataCommand(
_ socketCommand: String,
commandArgs: [String],
client: SocketClient,
windowOverride: String?
) throws -> String {
func insertArgumentBeforeSeparator(_ value: String, into args: inout [String]) {
if let separatorIndex = args.firstIndex(of: "--") {
args.insert(value, at: separatorIndex)
} else {
args.append(value)
}
}
var forwardedArgs: [String] = []
var resolvedExplicitWorkspace = false
var index = 0
while index < commandArgs.count {
let arg = commandArgs[index]
if arg == "--workspace", index + 1 < commandArgs.count {
let workspaceId = try resolveWorkspaceId(commandArgs[index + 1], client: client)
forwardedArgs.append("--tab=\(workspaceId)")
resolvedExplicitWorkspace = true
index += 2
continue
}
if arg.hasPrefix("--workspace=") {
let rawWorkspace = String(arg.dropFirst("--workspace=".count))
let workspaceId = try resolveWorkspaceId(rawWorkspace, client: client)
forwardedArgs.append("--tab=\(workspaceId)")
resolvedExplicitWorkspace = true
index += 1
continue
}
forwardedArgs.append(arg)
index += 1
}
if !resolvedExplicitWorkspace,
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride) {
let workspaceId = try resolveWorkspaceId(workspaceArg, client: client)
insertArgumentBeforeSeparator("--tab=\(workspaceId)", into: &forwardedArgs)
}
let command = ([socketCommand] + forwardedArgs)
.map(shellQuote)
.joined(separator: " ")
return try sendV1Command(command, client: client)
}
/// 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 let tty = surface["tty"] as? String, !tty.isEmpty {
parts.append("tty=\(tty)")
}
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"]
let globalBoolFlags: Set<String> = ["-V", "-v"]
while index < args.count {
let arg = args[index]
if !arg.hasPrefix("-") || arg == "-" {
return (arg.lowercased(), Array(args.dropFirst(index + 1)))
}
if arg == "--" {
break
}
// Handle -V (version) as a pseudo-command
if globalBoolFlags.contains(arg) {
return (arg, [])
}
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 tmuxResolvedCallerWorkspaceId(client: SocketClient) -> String? {
guard let callerWorkspace = tmuxCallerWorkspaceHandle() else {
return nil
}
return try? resolveWorkspaceId(callerWorkspace, client: client)
}
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 {
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 tmuxResolvedCallerWorkspaceId(client: client) == 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)
// When the target pane matches the caller's pane, prefer the caller's
// exact surface (CMUX_SURFACE_ID) over the pane's currently selected
// surface. The selected surface can change (e.g. tab switches) after
// claude-teams started, but the caller surface stays fixed.
let callerPane = tmuxCallerPaneHandle()
let callerSurface = tmuxCallerSurfaceHandle()
let canonicalCallerPane = callerPane.flatMap { try? tmuxCanonicalPaneId($0, workspaceId: resolved.workspaceId, client: client) }
let paneMatch = callerPane != nil && (resolved.paneId == callerPane! || resolved.paneId == canonicalCallerPane)
if paneMatch,
let callerSurface,
let surfaceId = try? tmuxCanonicalSurfaceId(
callerSurface,
workspaceId: resolved.workspaceId,
client: client
) {
return (resolved.workspaceId, resolved.paneId, surfaceId)
}
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,
tmuxResolvedCallerWorkspaceId(client: client) == 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 tmuxAnchoredSplitTarget(
workspaceId: String,
client: SocketClient
) -> (targetSurfaceId: String, callerSurfaceId: String?, direction: String)? {
var store = loadTmuxCompatStore()
if let lastColumn = store.mainVerticalLayouts[workspaceId]?.lastColumnSurfaceId {
if let lastColumnId = try? tmuxCanonicalSurfaceId(
lastColumn,
workspaceId: workspaceId,
client: client
) {
// Once the agent column exists, keep stacking into it even if the
// caller surface handle has churned from a stale surface:<n> ref.
return (lastColumnId, nil, "down")
}
// Right-column anchors can outlive the pane they pointed at.
// Drop stale state and rebuild from the caller surface instead.
store.mainVerticalLayouts[workspaceId]?.lastColumnSurfaceId = nil
store.lastSplitSurface.removeValue(forKey: workspaceId)
try? saveTmuxCompatStore(store)
}
let candidateAnchors = [
tmuxCallerSurfaceHandle(),
store.mainVerticalLayouts[workspaceId]?.mainSurfaceId
].compactMap { $0 }
for candidate in candidateAnchors {
if let anchorSurfaceId = try? tmuxCanonicalSurfaceId(
candidate,
workspaceId: workspaceId,
client: client
) {
return (anchorSurfaceId, anchorSurfaceId, "right")
}
}
let removedLayout = store.mainVerticalLayouts.removeValue(forKey: workspaceId) != nil
let removedSplit = store.lastSplitSurface.removeValue(forKey: workspaceId) != nil
if removedLayout || removedSplit {
try? saveTmuxCompatStore(store)
}
return nil
}
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
}
/// Enrich a tmux format context dictionary with pane geometry data from the
/// enriched pane.list response. Computes character-cell positions from pixel
/// frames and cell dimensions so tmux format variables like #{pane_width},
/// #{pane_height}, #{pane_left}, #{pane_top}, #{window_width}, #{window_height}
/// render correctly.
private func tmuxEnrichContextWithGeometry(
_ context: inout [String: String],
pane: [String: Any],
containerFrame: [String: Any]?
) {
let isFocused = (pane["focused"] as? Bool) == true
context["pane_active"] = isFocused ? "1" : "0"
guard let columns = pane["columns"] as? Int,
let rows = pane["rows"] as? Int else { return }
context["pane_width"] = String(columns)
context["pane_height"] = String(rows)
let cellW = pane["cell_width_px"] as? Int ?? 0
let cellH = pane["cell_height_px"] as? Int ?? 0
guard cellW > 0, cellH > 0 else { return }
if let frame = pane["pixel_frame"] as? [String: Any] {
let px = frame["x"] as? Double ?? 0
let py = frame["y"] as? Double ?? 0
context["pane_left"] = String(Int(px) / cellW)
context["pane_top"] = String(Int(py) / cellH)
}
if let cf = containerFrame {
let cw = cf["width"] as? Double ?? 0
let ch = cf["height"] as? Double ?? 0
context["window_width"] = String(max(Int(cw) / cellW, 1))
context["window_height"] = String(max(Int(ch) / cellH, 1))
}
}
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 TmuxCompatFocusedContext {
let socketPath: String
let workspaceId: String
let windowId: String?
let paneHandle: String
let paneId: String?
let surfaceId: String?
}
private func tmuxCompatResolvedSocketPath(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 = CLISocketPathResolver.isImplicitDefaultPath(envSocketPath) ? .implicitDefault : .environment
} else {
source = .implicitDefault
}
return CLISocketPathResolver.resolve(
requestedPath: requestedSocketPath,
source: source,
environment: processEnvironment
)
}
private func tmuxCompatFocusedContext(
processEnvironment: [String: String],
explicitPassword: String?
) -> TmuxCompatFocusedContext? {
let socketPath = tmuxCompatResolvedSocketPath(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 TmuxCompatFocusedContext(
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 resolveExecutableInSearchPath(
_ name: String,
searchPath: String?,
skip: ((String) -> Bool)? = nil
) -> String? {
let entries = searchPath?.split(separator: ":").map(String.init) ?? []
for entry in entries where !entry.isEmpty {
let candidate = URL(fileURLWithPath: entry, isDirectory: true)
.appendingPathComponent(name, isDirectory: false)
.path
guard FileManager.default.isExecutableFile(atPath: candidate) else { continue }
if let skip, skip(candidate) { continue }
return candidate
}
return nil
}
private func resolveClaudeExecutable(searchPath: String?) -> String? {
resolveExecutableInSearchPath(
"claude",
searchPath: searchPath,
skip: { self.isCmuxClaudeWrapper(at: $0) }
)
}
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 configureTmuxCompatEnvironment(
processEnvironment: [String: String],
shimDirectory: URL,
executablePath: String,
socketPath: String,
explicitPassword: String?,
focusedContext: TmuxCompatFocusedContext?,
tmuxPathPrefix: String,
cmuxBinEnvVar: String,
termOverrideEnvVar: String,
extraEnvVars: [(key: String, value: String)] = []
) {
let updatedPath = prependPathEntries(
[shimDirectory.path],
to: processEnvironment["PATH"]
)
let fakeTmuxValue: String = {
if let focusedContext {
let windowToken = focusedContext.windowId ?? focusedContext.workspaceId
return "/tmp/\(tmuxPathPrefix)/\(focusedContext.workspaceId),\(windowToken),\(focusedContext.paneHandle)"
}
return processEnvironment["TMUX"] ?? "/tmp/\(tmuxPathPrefix)/default,0,0"
}()
let fakeTmuxPane = focusedContext.map { "%\($0.paneHandle)" }
?? processEnvironment["TMUX_PANE"]
?? "%1"
let fakeTerm = processEnvironment[termOverrideEnvVar] ?? "screen-256color"
setenv(cmuxBinEnvVar, 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")
for envVar in extraEnvVars {
setenv(envVar.key, envVar.value, 1)
}
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 configureClaudeTeamsEnvironment(
processEnvironment: [String: String],
shimDirectory: URL,
executablePath: String,
socketPath: String,
explicitPassword: String?,
focusedContext: TmuxCompatFocusedContext?
) {
configureTmuxCompatEnvironment(
processEnvironment: processEnvironment,
shimDirectory: shimDirectory,
executablePath: executablePath,
socketPath: socketPath,
explicitPassword: explicitPassword,
focusedContext: focusedContext,
tmuxPathPrefix: "cmux-claude-teams",
cmuxBinEnvVar: "CMUX_CLAUDE_TEAMS_CMUX_BIN",
termOverrideEnvVar: "CMUX_CLAUDE_TEAMS_TERM",
extraEnvVars: [
(key: "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS", value: "1"),
]
)
}
private func createTmuxCompatShimDirectory(
directoryName: String,
tmuxShimScript: String
) throws -> URL {
let homePath = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory()
let root = URL(fileURLWithPath: homePath, isDirectory: true)
.appendingPathComponent(".cmuxterm", isDirectory: true)
.appendingPathComponent(directoryName, isDirectory: true)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true, attributes: nil)
let tmuxURL = root.appendingPathComponent("tmux", isDirectory: false)
try writeShimIfChanged(tmuxShimScript, to: tmuxURL)
return root
}
private func createClaudeTeamsShimDirectory() throws -> URL {
let script = """
#!/usr/bin/env bash
set -euo pipefail
exec "${CMUX_CLAUDE_TEAMS_CMUX_BIN:-cmux}" __tmux-compat "$@"
"""
return try createTmuxCompatShimDirectory(
directoryName: "claude-teams-bin",
tmuxShimScript: script
)
}
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 = tmuxCompatFocusedContext(
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)))")
}
// MARK: - cmux omo (OpenCode + oh-my-openagent)
private func resolveOpenCodeExecutable(searchPath: String?) -> String? {
resolveExecutableInSearchPath("opencode", searchPath: searchPath)
}
private func createOMOShimDirectory() throws -> URL {
// tmux shim: redirects tmux commands to cmux __tmux-compat
// Handle -V locally (no socket needed) since __tmux-compat requires a connection.
let tmuxScript = """
#!/usr/bin/env bash
set -euo pipefail
# Only match -V/-v as the first arg (top-level tmux flag).
# -v inside subcommands (e.g. split-window -v) is a vertical split flag.
case "${1:-}" in
-V|-v) echo "tmux 3.4"; exit 0 ;;
esac
exec "${CMUX_OMO_CMUX_BIN:-cmux}" __tmux-compat "$@"
"""
let root = try createTmuxCompatShimDirectory(
directoryName: "omo-bin",
tmuxShimScript: tmuxScript
)
// terminal-notifier shim: intercepts macOS notifications and routes to cmux notify
let notifierURL = root.appendingPathComponent("terminal-notifier", isDirectory: false)
let notifierScript = """
#!/usr/bin/env bash
# Intercept terminal-notifier calls and route through cmux notify.
# oh-my-openagent calls: terminal-notifier -title <t> -message <m> [-activate <id>]
TITLE="" BODY=""
while [[ $# -gt 0 ]]; do
case "$1" in
-title) TITLE="$2"; shift 2 ;;
-message) BODY="$2"; shift 2 ;;
*) shift ;;
esac
done
exec "${CMUX_OMO_CMUX_BIN:-cmux}" notify --title "${TITLE:-OpenCode}" --body "${BODY:-}"
"""
try writeShimIfChanged(notifierScript, to: notifierURL)
return root
}
private func writeShimIfChanged(_ script: String, to url: URL) throws {
let normalized = script.trimmingCharacters(in: .whitespacesAndNewlines)
let existing = try? String(contentsOf: url, encoding: .utf8)
if existing?.trimmingCharacters(in: .whitespacesAndNewlines) != normalized {
try script.write(to: url, atomically: false, encoding: .utf8)
}
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path)
}
private static let omoPluginName = "oh-my-opencode"
private func resolveExecutableInPath(_ name: String) -> String? {
let entries = ProcessInfo.processInfo.environment["PATH"]?.split(separator: ":").map(String.init) ?? []
for entry in entries where !entry.isEmpty {
let candidate = URL(fileURLWithPath: entry, isDirectory: true)
.appendingPathComponent(name, isDirectory: false)
.path
if FileManager.default.isExecutableFile(atPath: candidate) {
return candidate
}
}
return nil
}
private func omoUserConfigDir() -> URL {
let homePath = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory()
return URL(fileURLWithPath: homePath, isDirectory: true)
.appendingPathComponent(".config", isDirectory: true)
.appendingPathComponent("opencode", isDirectory: true)
}
private func omoShadowConfigDir() -> URL {
let homePath = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory()
return URL(fileURLWithPath: homePath, isDirectory: true)
.appendingPathComponent(".cmuxterm", isDirectory: true)
.appendingPathComponent("omo-config", isDirectory: true)
}
private func omoFileType(at url: URL) -> FileAttributeType? {
let attrs = try? FileManager.default.attributesOfItem(atPath: url.path)
return attrs?[.type] as? FileAttributeType
}
private func omoEnsureShadowPackageManifest(at shadowPackageURL: URL) throws {
let fm = FileManager.default
if omoFileType(at: shadowPackageURL) == .typeSymbolicLink {
try? fm.removeItem(at: shadowPackageURL)
}
// Keep the shadow package isolated from stale/yanked pins in the user's
// opencode package.json. bun will update this manifest with the resolved
// oh-my-opencode version when installation succeeds.
let packageManifest: [String: Any] = [
"dependencies": [
Self.omoPluginName: "latest"
],
"name": "cmux-omo-shadow",
"private": true
]
let output = try JSONSerialization.data(withJSONObject: packageManifest, options: [.prettyPrinted, .sortedKeys])
let existing = try? Data(contentsOf: shadowPackageURL)
if existing != output {
try output.write(to: shadowPackageURL, options: .atomic)
}
}
private func omoEnsureShadowNodeModulesSymlink(
shadowNodeModules: URL,
userNodeModules: URL
) throws {
let fm = FileManager.default
guard fm.fileExists(atPath: userNodeModules.path) else { return }
if let type = omoFileType(at: shadowNodeModules) {
if type == .typeSymbolicLink {
let target = try? fm.destinationOfSymbolicLink(atPath: shadowNodeModules.path)
if target != userNodeModules.path {
try? fm.removeItem(at: shadowNodeModules)
} else {
return
}
} else {
return
}
}
if !fm.fileExists(atPath: shadowNodeModules.path) {
try fm.createSymbolicLink(at: shadowNodeModules, withDestinationURL: userNodeModules)
}
}
private func omoRunPackageInstall(
executablePath: String,
arguments: [String],
currentDirectoryURL: URL
) throws -> Int32 {
let process = Process()
process.currentDirectoryURL = currentDirectoryURL
process.executableURL = URL(fileURLWithPath: executablePath)
process.arguments = arguments
process.standardOutput = FileHandle.standardError
process.standardError = FileHandle.standardError
try process.run()
process.waitUntilExit()
return process.terminationStatus
}
private func omoRequestedPort(from commandArgs: [String]) -> String? {
for (index, arg) in commandArgs.enumerated() {
if arg == "--port" {
let nextIndex = commandArgs.index(after: index)
guard nextIndex < commandArgs.endIndex else { return nil }
let value = commandArgs[nextIndex].trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
if arg.hasPrefix("--port=") {
let value = String(arg.dropFirst("--port=".count)).trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
}
return nil
}
private func omoBindableLoopbackPort(_ port: UInt16) -> UInt16? {
let socketDescriptor = socket(AF_INET, SOCK_STREAM, 0)
guard socketDescriptor >= 0 else { return nil }
defer { close(socketDescriptor) }
var reuseAddress: Int32 = 1
_ = setsockopt(
socketDescriptor,
SOL_SOCKET,
SO_REUSEADDR,
&reuseAddress,
socklen_t(MemoryLayout<Int32>.size)
)
var address = sockaddr_in()
address.sin_len = UInt8(MemoryLayout<sockaddr_in>.stride)
address.sin_family = sa_family_t(AF_INET)
address.sin_port = port.bigEndian
address.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1"))
let bindResult = withUnsafePointer(to: &address) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
Darwin.bind(socketDescriptor, $0, socklen_t(MemoryLayout<sockaddr_in>.stride))
}
}
guard bindResult == 0 else { return nil }
if port != 0 {
return port
}
var boundAddress = address
var boundAddressLength = socklen_t(MemoryLayout<sockaddr_in>.stride)
let nameResult = withUnsafeMutablePointer(to: &boundAddress) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
getsockname(socketDescriptor, $0, &boundAddressLength)
}
}
guard nameResult == 0 else { return nil }
return UInt16(bigEndian: boundAddress.sin_port)
}
private func omoResolvedPort(
commandArgs: [String],
processEnvironment: [String: String]
) -> String {
if let requestedPort = omoRequestedPort(from: commandArgs) {
return requestedPort
}
if let environmentPort = processEnvironment["OPENCODE_PORT"]?
.trimmingCharacters(in: .whitespacesAndNewlines),
let parsedEnvironmentPort = UInt16(environmentPort),
parsedEnvironmentPort != 0,
omoBindableLoopbackPort(parsedEnvironmentPort) != nil {
return environmentPort
}
if let preferredPort = omoBindableLoopbackPort(4096) {
return String(preferredPort)
}
if let fallbackPort = omoBindableLoopbackPort(0) {
return String(fallbackPort)
}
return "4096"
}
/// Creates a shadow config directory that layers oh-my-opencode on top of the user's
/// existing opencode config without modifying the original. Sets OPENCODE_CONFIG_DIR
/// to point at the shadow directory.
private func omoEnsurePlugin() throws {
let userDir = omoUserConfigDir()
let shadowDir = omoShadowConfigDir()
let fm = FileManager.default
try fm.createDirectory(at: shadowDir, withIntermediateDirectories: true, attributes: nil)
// Read the user's opencode.json (if any), add the plugin, write to shadow dir
let userJsonURL = userDir.appendingPathComponent("opencode.json")
let shadowJsonURL = shadowDir.appendingPathComponent("opencode.json")
var config: [String: Any]
if let data = try? Data(contentsOf: userJsonURL) {
guard let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw CLIError(message: "Failed to parse \(userJsonURL.path). Fix the JSON syntax and retry.")
}
config = existing
} else {
config = [:]
}
var plugins = (config["plugin"] as? [String]) ?? []
let alreadyPresent = plugins.contains {
$0 == Self.omoPluginName || $0.hasPrefix("\(Self.omoPluginName)@")
}
if !alreadyPresent {
plugins.append(Self.omoPluginName)
}
config["plugin"] = plugins
let output = try JSONSerialization.data(withJSONObject: config, options: [.prettyPrinted, .sortedKeys])
try output.write(to: shadowJsonURL, options: .atomic)
// Symlink node_modules from the user's config dir so installed packages resolve
let shadowNodeModules = shadowDir.appendingPathComponent("node_modules")
let userNodeModules = userDir.appendingPathComponent("node_modules")
try omoEnsureShadowNodeModulesSymlink(shadowNodeModules: shadowNodeModules, userNodeModules: userNodeModules)
// The shadow config owns its own package metadata so yanked/stale pins in the
// user's opencode package.json/bun.lock cannot poison plugin installation.
let shadowPackageURL = shadowDir.appendingPathComponent("package.json")
let shadowBunLockURL = shadowDir.appendingPathComponent("bun.lock")
try omoEnsureShadowPackageManifest(at: shadowPackageURL)
if omoFileType(at: shadowBunLockURL) == .typeSymbolicLink {
try? fm.removeItem(at: shadowBunLockURL)
}
// Copy oh-my-opencode plugin config (jsonc) if the user has one
for filename in ["oh-my-opencode.json", "oh-my-opencode.jsonc"] {
let userFile = userDir.appendingPathComponent(filename)
let shadowFile = shadowDir.appendingPathComponent(filename)
if fm.fileExists(atPath: userFile.path) && !fm.fileExists(atPath: shadowFile.path) {
try fm.createSymbolicLink(at: shadowFile, withDestinationURL: userFile)
}
}
// Install the package if not available via the symlinked node_modules
let pluginPackageDir = shadowNodeModules.appendingPathComponent(Self.omoPluginName)
if !fm.fileExists(atPath: pluginPackageDir.path) {
let installDir = shadowDir
if let bunPath = resolveExecutableInPath("bun") {
FileHandle.standardError.write("Installing oh-my-opencode plugin (this may take a minute on first run)...\n".data(using: .utf8)!)
let installArguments = ["add", Self.omoPluginName]
let firstAttemptStatus = try omoRunPackageInstall(
executablePath: bunPath,
arguments: installArguments,
currentDirectoryURL: installDir
)
if firstAttemptStatus != 0 {
FileHandle.standardError.write("Retrying oh-my-opencode install with a clean shadow package state...\n".data(using: .utf8)!)
try? fm.removeItem(at: shadowBunLockURL)
try? fm.removeItem(at: shadowNodeModules)
try omoEnsureShadowNodeModulesSymlink(shadowNodeModules: shadowNodeModules, userNodeModules: userNodeModules)
let retryStatus = try omoRunPackageInstall(
executablePath: bunPath,
arguments: installArguments,
currentDirectoryURL: installDir
)
if retryStatus != 0 {
throw CLIError(message: "Failed to install oh-my-opencode. Try manually: npm install -g oh-my-opencode")
}
}
} else if let npmPath = resolveExecutableInPath("npm") {
FileHandle.standardError.write("Installing oh-my-opencode plugin (this may take a minute on first run)...\n".data(using: .utf8)!)
let status = try omoRunPackageInstall(
executablePath: npmPath,
arguments: ["install", Self.omoPluginName],
currentDirectoryURL: installDir
)
if status != 0 {
throw CLIError(message: "Failed to install oh-my-opencode. Try manually: npm install -g oh-my-opencode")
}
} else {
throw CLIError(message: "Neither bun nor npm found in PATH. Install oh-my-opencode manually: bunx oh-my-opencode install")
}
FileHandle.standardError.write("oh-my-opencode plugin installed\n".data(using: .utf8)!)
}
// Ensure tmux mode is enabled in oh-my-opencode config.
// Without this, the TmuxSessionManager won't spawn visual panes even though
// $TMUX is set (tmux.enabled defaults to false).
let omoConfigURL = shadowDir.appendingPathComponent("oh-my-opencode.json")
var omoConfig: [String: Any]
if let data = try? Data(contentsOf: omoConfigURL),
let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
omoConfig = existing
} else {
// Check if user has a config we symlinked, read from source
let userOmoConfig = userDir.appendingPathComponent("oh-my-opencode.json")
if let data = try? Data(contentsOf: userOmoConfig),
let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
omoConfig = existing
// Remove the symlink so we can write our own copy
try? fm.removeItem(at: omoConfigURL)
} else {
omoConfig = [:]
}
}
var tmuxConfig = (omoConfig["tmux"] as? [String: Any]) ?? [:]
var needsWrite = false
if tmuxConfig["enabled"] as? Bool != true {
tmuxConfig["enabled"] = true
needsWrite = true
}
// Lower the default min widths so agent panes spawn in normal-sized windows.
// oh-my-openagent defaults: main_pane_min_width=120, agent_pane_min_width=40,
// requiring 161+ columns. Most terminal windows are narrower.
if tmuxConfig["main_pane_min_width"] == nil {
tmuxConfig["main_pane_min_width"] = 60
needsWrite = true
}
if tmuxConfig["agent_pane_min_width"] == nil {
tmuxConfig["agent_pane_min_width"] = 30
needsWrite = true
}
if tmuxConfig["main_pane_size"] == nil {
tmuxConfig["main_pane_size"] = 50
needsWrite = true
}
if needsWrite {
omoConfig["tmux"] = tmuxConfig
// Remove symlink if it exists (we need a real file)
if let attrs = try? fm.attributesOfItem(atPath: omoConfigURL.path),
attrs[.type] as? FileAttributeType == .typeSymbolicLink {
try? fm.removeItem(at: omoConfigURL)
}
let output = try JSONSerialization.data(withJSONObject: omoConfig, options: [.prettyPrinted, .sortedKeys])
try output.write(to: omoConfigURL, options: .atomic)
}
// Point OpenCode at the shadow config
setenv("OPENCODE_CONFIG_DIR", shadowDir.path, 1)
}
private func configureOMOEnvironment(
processEnvironment: [String: String],
shimDirectory: URL,
executablePath: String,
socketPath: String,
explicitPassword: String?,
focusedContext: TmuxCompatFocusedContext?,
openCodePort: String
) {
configureTmuxCompatEnvironment(
processEnvironment: processEnvironment,
shimDirectory: shimDirectory,
executablePath: executablePath,
socketPath: socketPath,
explicitPassword: explicitPassword,
focusedContext: focusedContext,
tmuxPathPrefix: "cmux-omo",
cmuxBinEnvVar: "CMUX_OMO_CMUX_BIN",
termOverrideEnvVar: "CMUX_OMO_TERM",
extraEnvVars: [(key: "OPENCODE_PORT", value: openCodePort)]
)
}
private func runOMO(
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
}
// Check for opencode before doing expensive plugin setup
let openCodeExecutablePath = resolveOpenCodeExecutable(searchPath: launcherEnvironment["PATH"])
if openCodeExecutablePath == nil {
let checkProcess = Process()
checkProcess.executableURL = URL(fileURLWithPath: "/usr/bin/which")
checkProcess.arguments = ["opencode"]
checkProcess.standardOutput = Pipe()
checkProcess.standardError = Pipe()
try? checkProcess.run()
checkProcess.waitUntilExit()
if checkProcess.terminationStatus != 0 {
throw CLIError(message: "opencode is not installed. Install it first:\n npm install -g opencode-ai\n # or\n bun install -g opencode-ai\n\nThen run: cmux omo")
}
}
// Ensure oh-my-opencode plugin is registered and installed
try omoEnsurePlugin()
let shimDirectory = try createOMOShimDirectory()
let executablePath = resolvedExecutableURL()?.path ?? (args.first ?? "cmux")
let focusedContext = tmuxCompatFocusedContext(
processEnvironment: launcherEnvironment,
explicitPassword: explicitPassword
)
let openCodePort = omoResolvedPort(
commandArgs: commandArgs,
processEnvironment: launcherEnvironment
)
launcherEnvironment["OPENCODE_PORT"] = openCodePort
configureOMOEnvironment(
processEnvironment: launcherEnvironment,
shimDirectory: shimDirectory,
executablePath: executablePath,
socketPath: socketPath,
explicitPassword: explicitPassword,
focusedContext: focusedContext,
openCodePort: openCodePort
)
let launchPath = openCodeExecutablePath ?? "opencode"
// oh-my-openagent needs the OpenCode API server running to attach
// subagent sessions to tmux panes. Prefer the historic default port
// when it is available, otherwise fall back to a free loopback port.
var effectiveArgs = commandArgs
if omoRequestedPort(from: commandArgs) == nil {
effectiveArgs.append("--port")
effectiveArgs.append(openCodePort)
}
var argv = ([launchPath] + effectiveArgs).map { strdup($0) }
defer {
for item in argv {
free(item)
}
}
argv.append(nil)
if openCodeExecutablePath != nil {
execv(launchPath, &argv)
} else {
execvp("opencode", &argv)
}
let code = errno
throw CLIError(message: "Failed to launch opencode: \(String(cString: strerror(code)))\n\nIs opencode installed? Install with:\n npm install -g opencode-ai")
}
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")) {
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")) {
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"]
)
var target = try tmuxResolveSurfaceTarget(parsed.value("-t"), client: client)
var direction: String
var anchoredCallerSurfaceId: String?
if parsed.hasFlag("-h") {
direction = parsed.hasFlag("-b") ? "left" : "right"
} else {
direction = parsed.hasFlag("-b") ? "up" : "down"
}
// Claude's agent teams targets arbitrary panes (from list-panes),
// not necessarily the leader pane from TMUX_PANE. Override the
// target to anchor all teammate splits to the leader surface.
// Only apply caller anchoring when the caller's workspace resolves
// successfully. Falling back to target.workspaceId would pair
// the caller's surface with a different workspace, creating an
// invalid cross-workspace split.
if let callerWorkspace = tmuxCallerWorkspaceHandle(),
let wsId = try? resolveWorkspaceId(callerWorkspace, client: client),
let anchoredTarget = tmuxAnchoredSplitTarget(workspaceId: wsId, client: client) {
target = (wsId, nil, anchoredTarget.targetSurfaceId)
direction = anchoredTarget.direction
anchoredCallerSurfaceId = anchoredTarget.callerSurfaceId
}
// Keep the leader pane focused while agents spawn beside it.
// -d explicitly means "don't focus the new pane".
let focusNewPane = !parsed.hasFlag("-d")
let created = try client.sendV2(method: "surface.split", params: [
"workspace_id": target.workspaceId,
"surface_id": target.surfaceId,
"direction": direction,
"focus": focusNewPane
])
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
// Track the newly created pane for main-vertical layout.
do {
var updatedStore = loadTmuxCompatStore()
updatedStore.lastSplitSurface[target.workspaceId] = surfaceId
if updatedStore.mainVerticalLayouts[target.workspaceId] != nil {
updatedStore.mainVerticalLayouts[target.workspaceId]?.lastColumnSurfaceId = surfaceId
} else if direction == "right", let anchoredCallerSurfaceId {
// First right split created the column; seed main-vertical
// state so subsequent splits stack downward.
updatedStore.mainVerticalLayouts[target.workspaceId] = MainVerticalState(
mainSurfaceId: anchoredCallerSurfaceId,
lastColumnSurfaceId: surfaceId
)
}
try saveTmuxCompatStore(updatedStore)
}
// Equalize vertical splits so teammate panes are evenly distributed.
// Use orientation: "vertical" to only equalize the agent column,
// preserving the leader/column horizontal divider position.
_ = try? client.sendV2(method: "workspace.equalize_splits", params: [
"workspace_id": target.workspaceId,
"orientation": "vertical"
])
if let text = tmuxShellCommandText(commandTokens: parsed.positional, cwd: parsed.value("-c")) {
_ = 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])
try? tmuxPruneCompatWorkspaceState(workspaceId: 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
])
try? tmuxPruneCompatSurfaceState(
workspaceId: target.workspaceId,
surfaceId: target.surfaceId,
client: client
)
// Re-equalize the agent column after removing a pane
_ = try? client.sendV2(method: "workspace.equalize_splits", params: [
"workspace_id": target.workspaceId,
"orientation": "vertical"
])
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)
var context = try tmuxFormatContext(
workspaceId: target.workspaceId,
paneId: target.paneId,
surfaceId: target.surfaceId,
client: client
)
// Enrich with geometry for format strings like #{pane_width},#{window_width}
let panePayload = try client.sendV2(method: "pane.list", params: ["workspace_id": target.workspaceId])
let panesList = panePayload["panes"] as? [[String: Any]] ?? []
let containerFrame = panePayload["container_frame"] as? [String: Any]
if let targetPaneId = target.paneId,
let matchingPane = panesList.first(where: { ($0["id"] as? String) == targetPaneId }) {
tmuxEnrichContextWithGeometry(&context, pane: matchingPane, containerFrame: containerFrame)
} else if let firstPane = panesList.first(where: { ($0["focused"] as? Bool) == true }) ?? panesList.first {
tmuxEnrichContextWithGeometry(&context, pane: firstPane, containerFrame: containerFrame)
}
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: [])
// Resolve target: can be a pane (%uuid) or workspace. In tmux,
// list-panes -t %<pane> lists all panes in the window containing that pane.
let workspaceId: String
if let target = parsed.value("-t"), tmuxPaneSelector(from: target) != nil {
let paneTarget = try tmuxResolvePaneTarget(target, client: client)
workspaceId = paneTarget.workspaceId
} else {
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]] ?? []
let containerFrame = payload["container_frame"] as? [String: Any]
for pane in panes {
guard let paneId = pane["id"] as? String else { continue }
var context = try tmuxFormatContext(workspaceId: workspaceId, paneId: paneId, client: client)
tmuxEnrichContextWithGeometry(&context, pane: pane, containerFrame: containerFrame)
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")
let target = try tmuxResolvePaneTarget(parsed.value("-t"), client: client)
if !hasDirectionalFlags, let absWidth = parsed.value("-x").flatMap({ Int($0.replacingOccurrences(of: "%", with: "")) }) {
// Absolute width: resize-pane -t <pane> -x <columns>
// Compute pixel delta from current width to desired width.
let panePayload = try client.sendV2(method: "pane.list", params: ["workspace_id": target.workspaceId])
let panes = panePayload["panes"] as? [[String: Any]] ?? []
if let matchingPane = panes.first(where: { ($0["id"] as? String) == target.paneId }),
let cellW = matchingPane["cell_width_px"] as? Int, cellW > 0,
let currentCols = matchingPane["columns"] as? Int {
let delta = absWidth - currentCols
if delta != 0 {
_ = try? client.sendV2(method: "pane.resize", params: [
"workspace_id": target.workspaceId,
"pane_id": target.paneId,
"direction": delta > 0 ? "right" : "left",
"amount": abs(delta) * cellW
])
}
}
} else if hasDirectionalFlags {
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":
let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: [])
let layoutName = parsed.positional.first ?? ""
// select-layout -t accepts pane targets (e.g. %1) in real tmux.
// Try pane target first, then workspace target. Only fall back to
// the caller's current workspace when no -t was provided; an
// explicit -t that fails to resolve should error, not silently
// apply to the wrong workspace.
let workspaceId: String = {
if let target = parsed.value("-t") {
if let resolved = try? tmuxResolvePaneTarget(target, client: client) {
return resolved.workspaceId
}
return (try? tmuxResolveWorkspaceTarget(target, client: client)) ?? ""
}
return (try? tmuxResolveWorkspaceTarget(nil, client: client)) ?? ""
}()
guard !workspaceId.isEmpty else {
throw CLIError(message: "Could not resolve workspace for select-layout")
}
if layoutName == "main-vertical" || layoutName == "main-horizontal" {
// For main-* layouts, only equalize the agent column (vertical splits),
// not the top-level horizontal split between main and agents.
let orientation = layoutName == "main-vertical" ? "vertical" : "horizontal"
_ = try? client.sendV2(method: "workspace.equalize_splits", params: [
"workspace_id": workspaceId,
"orientation": orientation
])
} else {
// For tiled/even-* layouts, equalize everything
_ = try? client.sendV2(method: "workspace.equalize_splits", params: ["workspace_id": workspaceId])
}
if layoutName == "main-vertical" {
if let callerSurface = tmuxCallerSurfaceHandle() {
var store = loadTmuxCompatStore()
let existingColumn = store.mainVerticalLayouts[workspaceId]?.lastColumnSurfaceId
let seedColumn = existingColumn ?? store.lastSplitSurface[workspaceId]
store.mainVerticalLayouts[workspaceId] = MainVerticalState(
mainSurfaceId: callerSurface,
lastColumnSurfaceId: seedColumn
)
try saveTmuxCompatStore(store)
}
} else if !layoutName.isEmpty {
// Non-main-vertical layout selected: clear stale state so
// future splits don't incorrectly redirect to the old column.
try tmuxPruneCompatWorkspaceState(workspaceId: workspaceId)
}
case "set-option", "set", "set-window-option", "setw", "source-file", "refresh-client", "attach-session", "detach-client":
return
case "-V", "-v":
print("tmux 3.4")
return
default:
throw CLIError(message: "Unsupported tmux compatibility command: \(command)")
}
}
private struct MainVerticalState: Codable {
/// The surface ID of the "main" (leader) pane on the left side.
var mainSurfaceId: String
/// The surface ID of the bottom-most pane in the right column.
/// Subsequent teammate splits target this pane with direction "down".
var lastColumnSurfaceId: String?
}
private struct TmuxCompatStore: Codable {
var buffers: [String: String] = [:]
var hooks: [String: String] = [:]
/// Tracks main-vertical layout state per workspace, keyed by workspace ID.
var mainVerticalLayouts: [String: MainVerticalState] = [:]
/// Tracks the last surface created by split-window per workspace.
/// Used to seed lastColumnSurfaceId when select-layout main-vertical
/// is called after the first split.
var lastSplitSurface: [String: String] = [:]
/// Custom decoder so older store files missing newer keys
/// (mainVerticalLayouts, lastSplitSurface) decode gracefully
/// instead of throwing and resetting the entire store.
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
buffers = try container.decodeIfPresent([String: String].self, forKey: .buffers) ?? [:]
hooks = try container.decodeIfPresent([String: String].self, forKey: .hooks) ?? [:]
mainVerticalLayouts = try container.decodeIfPresent([String: MainVerticalState].self, forKey: .mainVerticalLayouts) ?? [:]
lastSplitSurface = try container.decodeIfPresent([String: String].self, forKey: .lastSplitSurface) ?? [:]
}
init() {}
}
private func tmuxCompatStoreURL() -> URL {
let homePath = ProcessInfo.processInfo.environment["HOME"]
?? NSString(string: "~").expandingTildeInPath
return URL(fileURLWithPath: homePath)
.appendingPathComponent(".cmuxterm")
.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 tmuxPruneCompatWorkspaceState(workspaceId: String) throws {
var store = loadTmuxCompatStore()
let removedLayout = store.mainVerticalLayouts.removeValue(forKey: workspaceId) != nil
let removedSplit = store.lastSplitSurface.removeValue(forKey: workspaceId) != nil
if removedLayout || removedSplit {
try saveTmuxCompatStore(store)
}
}
private func tmuxCompatPaneAnchorSurfaceId(_ pane: [String: Any]) -> String? {
if let selected = pane["selected_surface_id"] as? String, !selected.isEmpty {
return selected
}
let surfaceIds = pane["surface_ids"] as? [String] ?? []
return surfaceIds.first
}
private func tmuxCompatPanePixelFrame(_ pane: [String: Any]) -> (x: Double, y: Double)? {
guard let frame = pane["pixel_frame"] as? [String: Any],
let x = doubleFromAny(frame["x"]),
let y = doubleFromAny(frame["y"]) else {
return nil
}
return (x, y)
}
private func tmuxReplacementColumnSurfaceId(
workspaceId: String,
layout: MainVerticalState,
client: SocketClient
) -> String? {
guard let payload = try? client.sendV2(method: "pane.list", params: ["workspace_id": workspaceId]) else {
return nil
}
let panes = payload["panes"] as? [[String: Any]] ?? []
guard !panes.isEmpty else { return nil }
guard let mainPane = panes.first(where: { pane in
let surfaceIds = pane["surface_ids"] as? [String] ?? []
if surfaceIds.contains(layout.mainSurfaceId) {
return true
}
return (pane["selected_surface_id"] as? String) == layout.mainSurfaceId
}) else {
return nil
}
let mainPaneId = mainPane["id"] as? String
let nonMainPanes = panes.filter { ($0["id"] as? String) != mainPaneId }
guard !nonMainPanes.isEmpty else { return nil }
let candidatePanes: [[String: Any]]
if let mainFrame = tmuxCompatPanePixelFrame(mainPane) {
let rightColumn = nonMainPanes.filter { pane in
guard let frame = tmuxCompatPanePixelFrame(pane) else { return false }
return frame.x > mainFrame.x + 0.5
}
candidatePanes = rightColumn.isEmpty ? nonMainPanes : rightColumn
} else {
candidatePanes = nonMainPanes
}
let bottomMostPane = candidatePanes.max { lhs, rhs in
let lhsFrame = tmuxCompatPanePixelFrame(lhs)
let rhsFrame = tmuxCompatPanePixelFrame(rhs)
switch (lhsFrame, rhsFrame) {
case let (.some(lhsFrame), .some(rhsFrame)):
if lhsFrame.y == rhsFrame.y {
return lhsFrame.x < rhsFrame.x
}
return lhsFrame.y < rhsFrame.y
case (.none, .some):
return true
case (.some, .none):
return false
case (.none, .none):
return false
}
}
return bottomMostPane.flatMap { tmuxCompatPaneAnchorSurfaceId($0) }
}
private func tmuxPruneCompatSurfaceState(
workspaceId: String,
surfaceId: String,
client: SocketClient
) throws {
var store = loadTmuxCompatStore()
var changed = false
if store.lastSplitSurface[workspaceId] == surfaceId {
store.lastSplitSurface.removeValue(forKey: workspaceId)
changed = true
}
if let layout = store.mainVerticalLayouts[workspaceId] {
if layout.mainSurfaceId == surfaceId {
store.mainVerticalLayouts.removeValue(forKey: workspaceId)
store.lastSplitSurface.removeValue(forKey: workspaceId)
changed = true
} else if layout.lastColumnSurfaceId == surfaceId {
var updatedLayout = layout
let replacementSurfaceId = tmuxReplacementColumnSurfaceId(
workspaceId: workspaceId,
layout: layout,
client: client
)
updatedLayout.lastColumnSurfaceId = replacementSurfaceId
store.mainVerticalLayouts[workspaceId] = updatedLayout
if let replacementSurfaceId {
store.lastSplitSurface[workspaceId] = replacementSurfaceId
} else {
store.lastSplitSurface.removeValue(forKey: workspaceId)
}
changed = true
}
}
if changed {
try saveTmuxCompatStore(store)
}
}
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)
do {
try SocketClient.waitForFilesystemPath(signalURL.path, timeout: max(0, deadline.timeIntervalSinceNow))
try? FileManager.default.removeItem(at: signalURL)
print("OK")
return
} catch {
if FileManager.default.fileExists(atPath: signalURL.path) {
try? FileManager.default.removeItem(at: signalURL)
print("OK")
return
}
}
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
]
)
switch subcommand {
case "session-start", "active":
telemetry.breadcrumb("claude-hook.session-start")
let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook(
preferred: nil,
fallback: workspaceArg,
client: client
)
let surfaceId = try resolvePreferredSurfaceIdForClaudeHook(
preferred: nil,
fallback: surfaceArg,
workspaceId: workspaceId,
client: client
)
let claudePid: Int? = {
guard let raw = ProcessInfo.processInfo.environment["CMUX_CLAUDE_PID"]?
.trimmingCharacters(in: .whitespacesAndNewlines),
let pid = Int(raw),
pid > 0 else {
return nil
}
return pid
}()
if let sessionId = parsedInput.sessionId {
try? sessionStore.upsert(
sessionId: sessionId,
workspaceId: workspaceId,
surfaceId: surfaceId,
cwd: parsedInput.cwd,
pid: claudePid
)
}
// Register PID for stale-session detection and OSC suppression,
// but don't set a visible status. "Running" only appears when the
// user submits a prompt (UserPromptSubmit) or Claude starts working
// (PreToolUse).
if let claudePid {
_ = try? sendV1Command(
"set_agent_pid claude_code \(claudePid) --tab=\(workspaceId)",
client: client
)
}
print("OK")
case "stop", "idle":
telemetry.breadcrumb("claude-hook.stop")
do {
// Turn ended. Don't consume session or clear PID Claude is still alive.
// Notification hook handles user-facing notifications; SessionEnd handles cleanup.
let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) }
let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook(
preferred: mappedSession?.workspaceId,
fallback: workspaceArg,
client: client
)
let surfaceId = try resolvePreferredSurfaceIdForClaudeHook(
preferred: mappedSession?.surfaceId,
fallback: surfaceArg,
workspaceId: workspaceId,
client: client
)
// Update session with transcript summary and send completion notification.
let completion = summarizeClaudeHookStop(
parsedInput: parsedInput,
sessionRecord: mappedSession
)
if let sessionId = parsedInput.sessionId, let completion {
try? sessionStore.upsert(
sessionId: sessionId,
workspaceId: workspaceId,
surfaceId: surfaceId,
cwd: parsedInput.cwd,
lastSubtitle: completion.subtitle,
lastBody: completion.body
)
}
if let completion {
let title = "Claude Code"
let subtitle = sanitizeNotificationField(completion.subtitle)
let body = sanitizeNotificationField(completion.body)
let payload = "\(title)|\(subtitle)|\(body)"
_ = try? sendV1Command("notify_target \(workspaceId) \(surfaceId) \(payload)", client: client)
}
try? setClaudeStatus(
client: client,
workspaceId: workspaceId,
value: "Idle",
icon: "pause.circle.fill",
color: "#8E8E93"
)
print("OK")
} catch {
if shouldIgnoreClaudeHookTeardownError(error) {
telemetry.breadcrumb("claude-hook.stop.ignored", data: ["error": String(describing: error)])
print("OK")
return
}
throw error
}
case "prompt-submit":
telemetry.breadcrumb("claude-hook.prompt-submit")
let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) }
let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook(
preferred: mappedSession?.workspaceId,
fallback: workspaceArg,
client: client
)
_ = 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")
var summary = summarizeClaudeHookNotification(rawInput: rawInput)
let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) }
let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook(
preferred: mappedSession?.workspaceId,
fallback: workspaceArg,
client: client
)
if let mappedSession,
let savedBody = mappedSession.lastBody, !savedBody.isEmpty,
summary.body.contains("needs your attention") || summary.body.contains("needs your input") {
summary = (subtitle: mappedSession.lastSubtitle ?? summary.subtitle, body: savedBody)
}
let surfaceId = try resolvePreferredSurfaceIdForClaudeHook(
preferred: mappedSession?.surfaceId,
fallback: surfaceArg,
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 "session-end":
telemetry.breadcrumb("claude-hook.session-end")
// Final cleanup when Claude process exits.
// Only clear when we are the primary cleanup path (Stop didn't fire first).
// If Stop already consumed the session, consumedSession is nil and we skip
// to avoid wiping the completion notification that Stop just delivered.
let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) }
let fallbackWorkspaceId = try? resolvePreferredWorkspaceIdForClaudeHook(
preferred: mappedSession?.workspaceId,
fallback: workspaceArg,
client: client
)
let fallbackSurfaceId: String? = {
guard let fallbackWorkspaceId else { return nil }
return try? resolvePreferredSurfaceIdForClaudeHook(
preferred: mappedSession?.surfaceId,
fallback: surfaceArg,
workspaceId: fallbackWorkspaceId,
client: client
)
}()
let consumedSession = try? sessionStore.consume(
sessionId: parsedInput.sessionId,
workspaceId: fallbackWorkspaceId,
surfaceId: fallbackSurfaceId
)
if let consumedSession {
let workspaceId = consumedSession.workspaceId
_ = try? clearClaudeStatus(client: client, workspaceId: workspaceId)
_ = try? sendV1Command("clear_agent_pid claude_code --tab=\(workspaceId)", client: client)
_ = try? sendV1Command("clear_notifications --tab=\(workspaceId)", client: client)
}
print("OK")
case "pre-tool-use":
telemetry.breadcrumb("claude-hook.pre-tool-use")
// Clears "Needs input" status and notification when Claude resumes work
// (e.g. after permission grant). Runs async so it doesn't block tool execution.
let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) }
let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook(
preferred: mappedSession?.workspaceId,
fallback: workspaceArg,
client: client
)
let claudePid = mappedSession?.pid
// AskUserQuestion means Claude is about to ask the user something.
// Save question text in session so the Notification handler can use it
// instead of the generic "Claude Code needs your attention".
if let toolName = parsedInput.object?["tool_name"] as? String,
toolName == "AskUserQuestion",
let question = describeAskUserQuestion(parsedInput.object),
let sessionId = parsedInput.sessionId {
// Preserve the existing surfaceId from SessionStart; passing ""
// would overwrite it and cause notifications to target the wrong workspace.
let existingSurfaceId = (try? sessionStore.lookup(sessionId: sessionId))?.surfaceId ?? ""
try? sessionStore.upsert(
sessionId: sessionId,
workspaceId: workspaceId,
surfaceId: existingSurfaceId,
cwd: parsedInput.cwd,
lastSubtitle: "Waiting",
lastBody: question
)
// Don't clear notifications or set status here.
// The Notification hook fires right after and will use the saved question.
print("OK")
return
}
_ = try? sendV1Command("clear_notifications --tab=\(workspaceId)", client: client)
let statusValue: String
if UserDefaults.standard.bool(forKey: "claudeCodeVerboseStatus"),
let toolStatus = describeToolUse(parsedInput.object) {
statusValue = toolStatus
} else {
statusValue = "Running"
}
try setClaudeStatus(
client: client,
workspaceId: workspaceId,
value: statusValue,
icon: "bolt.fill",
color: "#4C8DFF",
pid: claudePid
)
print("OK")
case "help", "--help", "-h":
telemetry.breadcrumb("claude-hook.help")
print(
"""
cmux claude-hook <session-start|stop|session-end|notification|prompt-submit|pre-tool-use> [--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,
pid: Int? = nil
) throws {
var cmd = "set_status claude_code \(value) --icon=\(icon) --color=\(color) --tab=\(workspaceId)"
if let pid {
cmd += " --pid=\(pid)"
}
_ = try client.send(command: cmd)
}
private func clearClaudeStatus(client: SocketClient, workspaceId: String) throws {
_ = try client.send(command: "clear_status claude_code --tab=\(workspaceId)")
}
private func resolvePreferredWorkspaceIdForClaudeHook(
preferred: String?,
fallback: String?,
client: SocketClient
) throws -> String {
if let preferred = nonEmptyClaudeHookIdentifier(preferred) {
if isUUID(preferred) {
return preferred
}
return try resolveWorkspaceIdForClaudeHook(preferred, client: client)
}
if let fallback = nonEmptyClaudeHookIdentifier(fallback), isUUID(fallback) {
return fallback
}
return try resolveWorkspaceIdForClaudeHook(fallback, client: client)
}
private func resolvePreferredSurfaceIdForClaudeHook(
preferred: String?,
fallback: String?,
workspaceId: String,
client: SocketClient
) throws -> String {
if let preferred = nonEmptyClaudeHookIdentifier(preferred) {
if isUUID(preferred) {
return preferred
}
return try resolveSurfaceIdForClaudeHook(preferred, workspaceId: workspaceId, client: client)
}
if let fallback = nonEmptyClaudeHookIdentifier(fallback), isUUID(fallback) {
return fallback
}
return try resolveSurfaceIdForClaudeHook(fallback, workspaceId: workspaceId, client: client)
}
private func nonEmptyClaudeHookIdentifier(_ value: String?) -> String? {
guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
!trimmed.isEmpty else {
return nil
}
return trimmed
}
private func shouldIgnoreClaudeHookTeardownError(_ error: Error) -> Bool {
let message = String(describing: error).lowercased()
let benignFragments = [
"tabmanager not available",
"no workspace selected",
"workspace not found",
"workspace ref not found",
"workspace index not found",
"surface not found",
"surface ref not found",
"surface index not found",
"unable to resolve surface id",
"panel not found",
"tab not found",
"failed to write to socket",
"socket read error",
"not connected"
]
return benignFragments.contains { message.contains($0) }
}
private func describeAskUserQuestion(_ object: [String: Any]?) -> String? {
guard let object,
let input = object["tool_input"] as? [String: Any],
let questions = input["questions"] as? [[String: Any]],
let first = questions.first else { return nil }
var parts: [String] = []
if let question = first["question"] as? String, !question.isEmpty {
parts.append(question)
} else if let header = first["header"] as? String, !header.isEmpty {
parts.append(header)
}
if let options = first["options"] as? [[String: Any]] {
let labels = options.compactMap { $0["label"] as? String }
if !labels.isEmpty {
parts.append(labels.map { "[\($0)]" }.joined(separator: " "))
}
}
if parts.isEmpty { return "Asking a question" }
return parts.joined(separator: "\n")
}
private func describeToolUse(_ object: [String: Any]?) -> String? {
guard let object, let toolName = object["tool_name"] as? String else { return nil }
let input = object["tool_input"] as? [String: Any]
switch toolName {
case "Read":
if let path = input?["file_path"] as? String {
return "Reading \(shortenPath(path))"
}
return "Reading file"
case "Edit":
if let path = input?["file_path"] as? String {
return "Editing \(shortenPath(path))"
}
return "Editing file"
case "Write":
if let path = input?["file_path"] as? String {
return "Writing \(shortenPath(path))"
}
return "Writing file"
case "Bash":
if let cmd = input?["command"] as? String {
let first = cmd.components(separatedBy: .whitespacesAndNewlines).first ?? cmd
let short = String(first.prefix(30))
return "Running \(short)"
}
return "Running command"
case "Glob":
if let pattern = input?["pattern"] as? String {
return "Searching \(String(pattern.prefix(30)))"
}
return "Searching files"
case "Grep":
if let pattern = input?["pattern"] as? String {
return "Grep \(String(pattern.prefix(30)))"
}
return "Searching code"
case "Agent":
if let desc = input?["description"] as? String {
return String(desc.prefix(40))
}
return "Subagent"
case "WebFetch":
return "Fetching URL"
case "WebSearch":
if let query = input?["query"] as? String {
return "Search: \(String(query.prefix(30)))"
}
return "Web search"
default:
return toolName
}
}
private func shortenPath(_ path: String) -> String {
let url = URL(fileURLWithPath: path)
let name = url.lastPathComponent
return name.isEmpty ? String(path.suffix(30)) : name
}
private func resolveWorkspaceIdForClaudeHook(_ raw: String?, client: SocketClient) throws -> String {
try resolveWorkspaceIdAllowingFallback(raw, client: client)
}
private func resolveSurfaceIdForClaudeHook(
_ raw: String?,
workspaceId: String,
client: SocketClient
) throws -> String {
try resolveSurfaceIdAllowingFallback(raw, workspaceId: workspaceId, client: client)
}
private func resolveWorkspaceIdAllowingFallback(
_ raw: String?,
client: SocketClient
) throws -> String {
if let raw,
!raw.isEmpty,
let candidate = try? resolveWorkspaceId(raw, client: client),
(try? client.sendV2(method: "surface.list", params: ["workspace_id": candidate])) != nil {
return candidate
}
if let callerWorkspaceId = resolveCallerWorkspaceIdByTTY(client: client),
(try? client.sendV2(method: "surface.list", params: ["workspace_id": callerWorkspaceId])) != nil {
return callerWorkspaceId
}
return try resolveWorkspaceId(nil, client: client)
}
private func resolveSurfaceIdAllowingFallback(
_ raw: String?,
workspaceId: String,
client: SocketClient
) throws -> String {
if let raw,
!raw.isEmpty,
let candidate = try? resolveSurfaceId(raw, workspaceId: workspaceId, client: client),
let listed = try? client.sendV2(method: "surface.list", params: ["workspace_id": workspaceId]) {
let items = listed["surfaces"] as? [[String: Any]] ?? []
if items.contains(where: {
($0["id"] as? String) == candidate || ($0["ref"] as? String) == candidate
}) {
return candidate
}
}
if let callerSurfaceId = resolveCallerSurfaceIdByTTY(workspaceId: workspaceId, client: client),
let listed = try? client.sendV2(method: "surface.list", params: ["workspace_id": workspaceId]) {
let items = listed["surfaces"] as? [[String: Any]] ?? []
if items.contains(where: {
($0["id"] as? String) == callerSurfaceId || ($0["ref"] as? String) == callerSurfaceId
}) {
return callerSurfaceId
}
}
return try resolveSurfaceId(nil, workspaceId: workspaceId, client: client)
}
private struct CallerTerminalBinding {
let workspaceId: String
let surfaceId: String
}
private func resolveCallerWorkspaceIdByTTY(client: SocketClient) -> String? {
resolveCallerTerminalBindingByTTY(client: client)?.workspaceId
}
private func resolveCallerSurfaceIdByTTY(workspaceId: String, client: SocketClient) -> String? {
guard let binding = resolveCallerTerminalBindingByTTY(client: client),
binding.workspaceId == workspaceId else {
return nil
}
return binding.surfaceId
}
private func resolveCallerTerminalBindingByTTY(client: SocketClient) -> CallerTerminalBinding? {
guard let ttyName = resolveCallerTTYName() else {
return nil
}
guard let payload = try? client.sendV2(method: "debug.terminals") else {
return nil
}
let terminals = payload["terminals"] as? [[String: Any]] ?? []
for terminal in terminals {
guard normalizedTTYName(terminal["tty"] as? String) == ttyName,
let workspaceId = normalizedHandleValue(terminal["workspace_id"] as? String),
let surfaceId = normalizedHandleValue(terminal["surface_id"] as? String) else {
continue
}
return CallerTerminalBinding(workspaceId: workspaceId, surfaceId: surfaceId)
}
return nil
}
private func resolveCallerTTYName() -> String? {
let env = ProcessInfo.processInfo.environment
for key in ["CMUX_CLI_TTY_NAME", "CMUX_TTY_NAME", "TTY", "SSH_TTY"] {
if let ttyName = normalizedTTYName(env[key]) {
return ttyName
}
}
for fileDescriptor in [STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO] {
if let rawTTYName = ttyname(fileDescriptor),
let ttyName = normalizedTTYName(String(cString: rawTTYName)) {
return ttyName
}
}
return nil
}
private func normalizedTTYName(_ raw: String?) -> String? {
guard let trimmed = normalizedHandleValue(raw == "not a tty" ? nil : raw) else {
return nil
}
let components = trimmed.split(separator: "/")
if let last = components.last, !last.isEmpty {
return String(last)
}
return trimmed
}
private func normalizedHandleValue(_ raw: String?) -> String? {
guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines),
!raw.isEmpty else {
return nil
}
return raw
}
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)
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") || lower.contains("permission_prompt") {
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("complet") || lower.contains("finish") || lower.contains("done") || lower.contains("success") {
let body = message.isEmpty ? "Task completed" : message
return ("Completed", body)
}
if lower.contains("idle") || lower.contains("wait") || lower.contains("input") || lower.contains("idle_prompt") {
let body = message.isEmpty ? "Waiting for input" : message
return ("Waiting", body)
}
// Use the message directly if it's meaningful (not a generic placeholder).
if !message.isEmpty, message != "Claude needs your input" {
return ("Attention", message)
}
return ("Attention", "Claude needs your attention")
}
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 {
return normalizedSingleLine(value)
.replacingOccurrences(of: "|", with: "¦")
}
// MARK: - Codex hooks
/// The hooks.json content that cmux installs into ~/.codex/.
/// Each hook calls `cmux codex-hook <event>` which gracefully no-ops
/// when not running inside cmux. The command checks for cmux on PATH
/// first so it silently succeeds even when cmux is not installed
/// (e.g. user opened codex in a non-cmux terminal).
private static func codexHookCommand(_ event: String) -> String {
"[ -n \"$CMUX_SURFACE_ID\" ] && command -v cmux >/dev/null 2>&1 && cmux codex-hook \(event) || echo '{}'"
}
private static let codexHooksJSON: [String: Any] = [
"hooks": [
"SessionStart": [[
"hooks": [[
"type": "command",
"command": codexHookCommand("session-start"),
"timeout": 10
] as [String: Any]]
] as [String: Any]],
"UserPromptSubmit": [[
"hooks": [[
"type": "command",
"command": codexHookCommand("prompt-submit"),
"timeout": 10
] as [String: Any]]
] as [String: Any]],
"Stop": [[
"hooks": [[
"type": "command",
"command": codexHookCommand("stop"),
"timeout": 10
] as [String: Any]]
] as [String: Any]]
] as [String: Any]
]
/// Identifier used to detect cmux-owned hooks during uninstall.
private static let codexHookCommandMarker = "cmux codex-hook"
private func runCodexInstallHooks() throws {
let skipConfirm = ProcessInfo.processInfo.arguments.contains("--yes")
|| ProcessInfo.processInfo.arguments.contains("-y")
let codexHome = ProcessInfo.processInfo.environment["CODEX_HOME"]
?? NSString(string: "~/.codex").expandingTildeInPath
let hooksPath = (codexHome as NSString).appendingPathComponent("hooks.json")
let configPath = (codexHome as NSString).appendingPathComponent("config.toml")
let fm = FileManager.default
// Ensure ~/.codex/ exists
try fm.createDirectory(atPath: codexHome, withIntermediateDirectories: true, attributes: nil)
// Read existing state
let existingHooksContent: String? = fm.fileExists(atPath: hooksPath)
? (try? String(contentsOfFile: hooksPath, encoding: .utf8))
: nil
// Build merged hooks
var existing: [String: Any] = [:]
if let existingHooksContent,
let data = existingHooksContent.data(using: .utf8),
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
existing = parsed
}
var hooks = existing["hooks"] as? [String: Any] ?? [:]
let cmuxHooks = Self.codexHooksJSON["hooks"] as! [String: Any]
for (eventName, cmuxGroups) in cmuxHooks {
guard let cmuxGroupArray = cmuxGroups as? [[String: Any]] else { continue }
var eventGroups = hooks[eventName] as? [[String: Any]] ?? []
eventGroups.removeAll { group in
guard let groupHooks = group["hooks"] as? [[String: Any]] else { return false }
return groupHooks.allSatisfy { hook in
(hook["command"] as? String)?.contains(Self.codexHookCommandMarker) == true
}
}
eventGroups.append(contentsOf: cmuxGroupArray)
hooks[eventName] = eventGroups
}
existing["hooks"] = hooks
let newJsonData = try JSONSerialization.data(withJSONObject: existing, options: [.prettyPrinted, .sortedKeys])
let newHooksContent = String(data: newJsonData, encoding: .utf8) ?? ""
// Build new config.toml content
let existingConfigContent: String = fm.fileExists(atPath: configPath)
? ((try? String(contentsOfFile: configPath, encoding: .utf8)) ?? "")
: ""
let newConfigContent = buildConfigWithCodexHooks(existingConfigContent)
// Check if anything would change
let hooksChanged = existingHooksContent != newHooksContent
let configChanged = existingConfigContent != newConfigContent
if !hooksChanged && !configChanged {
print("cmux hooks are already installed. Nothing to change.")
return
}
// Show diff and ask for confirmation
if hooksChanged {
print(" \(hooksPath):")
if let existingHooksContent {
printSimpleDiff(old: existingHooksContent, new: newHooksContent)
} else {
print(" (new file)")
let lines = newHooksContent.components(separatedBy: "\n")
for (i, line) in lines.enumerated() {
let lineLabel = String(format: "%3d", i + 1)
print(" \u{001B}[32m\(lineLabel) +\(line)\u{001B}[0m")
}
}
print("")
}
if configChanged {
print(" \(configPath):")
if existingConfigContent.isEmpty {
print(" (new file)")
let lines = newConfigContent.components(separatedBy: "\n")
for (i, line) in lines.enumerated() where !line.isEmpty {
let lineLabel = String(format: "%3d", i + 1)
print(" \u{001B}[32m\(lineLabel) +\(line)\u{001B}[0m")
}
} else {
printSimpleDiff(old: existingConfigContent, new: newConfigContent)
}
print("")
}
if !skipConfirm {
print("Apply these changes? [Y/n] ", terminator: "")
if let response = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(),
!response.isEmpty && response != "y" && response != "yes" {
print("Aborted.")
return
}
}
// Apply changes
if hooksChanged {
try newJsonData.write(to: URL(fileURLWithPath: hooksPath), options: .atomic)
}
if configChanged {
try newConfigContent.write(toFile: configPath, atomically: true, encoding: .utf8)
}
print("")
print("Installed. Hooks activate inside cmux and silently no-op elsewhere.")
print("To remove: cmux codex uninstall-hooks")
}
private func runCodexUninstallHooks() throws {
let skipConfirm = ProcessInfo.processInfo.arguments.contains("--yes")
|| ProcessInfo.processInfo.arguments.contains("-y")
let codexHome = ProcessInfo.processInfo.environment["CODEX_HOME"]
?? NSString(string: "~/.codex").expandingTildeInPath
let hooksPath = (codexHome as NSString).appendingPathComponent("hooks.json")
let configPath = (codexHome as NSString).appendingPathComponent("config.toml")
let fm = FileManager.default
guard fm.fileExists(atPath: hooksPath),
let data = try? Data(contentsOf: URL(fileURLWithPath: hooksPath)),
var parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
print("No hooks.json found at \(hooksPath)")
return
}
guard var hooks = parsed["hooks"] as? [String: Any] else {
print("No hooks section found in \(hooksPath)")
return
}
// Build the new state without cmux hooks
var removedCount = 0
for eventName in hooks.keys {
guard var eventGroups = hooks[eventName] as? [[String: Any]] else { continue }
let before = eventGroups.count
eventGroups.removeAll { group in
guard let groupHooks = group["hooks"] as? [[String: Any]] else { return false }
return groupHooks.allSatisfy { hook in
(hook["command"] as? String)?.contains(Self.codexHookCommandMarker) == true
}
}
removedCount += before - eventGroups.count
if eventGroups.isEmpty {
hooks.removeValue(forKey: eventName)
} else {
hooks[eventName] = eventGroups
}
}
// Build config.toml without codex_hooks
let existingConfigContent: String = fm.fileExists(atPath: configPath)
? ((try? String(contentsOfFile: configPath, encoding: .utf8)) ?? "")
: ""
let newConfigContent = buildConfigWithoutCodexHooks(existingConfigContent)
let configChanged = existingConfigContent != newConfigContent
if removedCount == 0 && !configChanged {
print("No cmux hooks found.")
return
}
parsed["hooks"] = hooks
let newJsonData = try JSONSerialization.data(withJSONObject: parsed, options: [.prettyPrinted, .sortedKeys])
let newHooksContent = String(data: newJsonData, encoding: .utf8) ?? ""
let oldHooksContent = String(data: data, encoding: .utf8) ?? ""
// Show diff and ask for confirmation
if removedCount > 0 {
print(" \(hooksPath):")
printSimpleDiff(old: oldHooksContent, new: newHooksContent)
print("")
}
if configChanged {
print(" \(configPath):")
printSimpleDiff(old: existingConfigContent, new: newConfigContent)
print("")
}
if !skipConfirm {
print("Apply these changes? [Y/n] ", terminator: "")
if let response = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(),
!response.isEmpty && response != "y" && response != "yes" {
print("Aborted.")
return
}
}
if removedCount > 0 {
try newJsonData.write(to: URL(fileURLWithPath: hooksPath), options: .atomic)
}
if configChanged {
try newConfigContent.write(toFile: configPath, atomically: true, encoding: .utf8)
}
print("Removed cmux Codex hooks.")
}
/// Print a unified-diff-style view with context lines and line numbers.
private func printSimpleDiff(old: String, new: String, contextLines: Int = 2) {
let red = "\u{001B}[31m"
let green = "\u{001B}[32m"
let dim = "\u{001B}[2m"
let reset = "\u{001B}[0m"
let oldLines = old.components(separatedBy: "\n")
let newLines = new.components(separatedBy: "\n")
// Simple LCS-based diff: find matching lines
let lcs = longestCommonSubsequence(oldLines, newLines)
var oldIdx = 0, newIdx = 0, lcsIdx = 0
struct DiffLine {
enum Kind { case context, remove, add }
let kind: Kind
let lineNo: Int // 1-based, refers to old line for context/remove, new line for add
let text: String
}
var allDiffs: [DiffLine] = []
while oldIdx < oldLines.count || newIdx < newLines.count {
if lcsIdx < lcs.count && oldIdx < oldLines.count && newIdx < newLines.count
&& oldLines[oldIdx] == lcs[lcsIdx] && newLines[newIdx] == lcs[lcsIdx] {
allDiffs.append(DiffLine(kind: .context, lineNo: newIdx + 1, text: newLines[newIdx]))
oldIdx += 1; newIdx += 1; lcsIdx += 1
} else if oldIdx < oldLines.count && (lcsIdx >= lcs.count || oldLines[oldIdx] != lcs[lcsIdx]) {
allDiffs.append(DiffLine(kind: .remove, lineNo: oldIdx + 1, text: oldLines[oldIdx]))
oldIdx += 1
} else if newIdx < newLines.count {
allDiffs.append(DiffLine(kind: .add, lineNo: newIdx + 1, text: newLines[newIdx]))
newIdx += 1
}
}
// Find ranges with changes and expand by context
var changedIndices = Set<Int>()
for (i, d) in allDiffs.enumerated() where d.kind != .context {
for j in max(0, i - contextLines)...min(allDiffs.count - 1, i + contextLines) {
changedIndices.insert(j)
}
}
var lastPrinted = -1
for i in changedIndices.sorted() {
if lastPrinted >= 0 && i > lastPrinted + 1 {
print(" \(dim)...\(reset)")
}
let d = allDiffs[i]
let lineLabel = String(format: "%3d", d.lineNo)
switch d.kind {
case .context:
print(" \(dim)\(lineLabel) \(d.text)\(reset)")
case .remove:
print(" \(red)\(lineLabel) -\(d.text)\(reset)")
case .add:
print(" \(green)\(lineLabel) +\(d.text)\(reset)")
}
lastPrinted = i
}
}
private func longestCommonSubsequence(_ a: [String], _ b: [String]) -> [String] {
let m = a.count, n = b.count
var dp = Array(repeating: Array(repeating: 0, count: n + 1), count: m + 1)
for i in 1...m {
for j in 1...n {
if a[i - 1] == b[j - 1] {
dp[i][j] = dp[i - 1][j - 1] + 1
} else {
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
}
}
}
var result: [String] = []
var i = m, j = n
while i > 0 && j > 0 {
if a[i - 1] == b[j - 1] {
result.append(a[i - 1])
i -= 1; j -= 1
} else if dp[i - 1][j] > dp[i][j - 1] {
i -= 1
} else {
j -= 1
}
}
return result.reversed()
}
/// Returns config.toml content with codex_hooks = true under [features].
private func buildConfigWithCodexHooks(_ content: String) -> String {
var lines = content.components(separatedBy: "\n")
// Check if codex_hooks key already exists (exact key match at line start)
if let idx = lines.firstIndex(where: { isTomlKey($0, key: "codex_hooks") }) {
lines[idx] = "codex_hooks = true"
return lines.joined(separator: "\n")
}
// Find [features] section and insert after it (first occurrence only)
if let idx = lines.firstIndex(where: { $0.trimmingCharacters(in: .whitespaces) == "[features]" }) {
lines.insert("codex_hooks = true", at: idx + 1)
return lines.joined(separator: "\n")
}
// No [features] section, append one
var result = content
if !result.isEmpty && !result.hasSuffix("\n") {
result += "\n"
}
result += "\n[features]\ncodex_hooks = true\n"
return result
}
/// Returns config.toml content with codex_hooks removed from [features].
private func buildConfigWithoutCodexHooks(_ content: String) -> String {
var lines = content.components(separatedBy: "\n")
// Remove the codex_hooks line
lines.removeAll { isTomlKey($0, key: "codex_hooks") }
// If [features] section is now empty (only has the header, nothing before next section or EOF),
// remove the header too
if let idx = lines.firstIndex(where: { $0.trimmingCharacters(in: .whitespaces) == "[features]" }) {
let nextNonEmpty = lines[(idx + 1)...].firstIndex(where: {
!$0.trimmingCharacters(in: .whitespaces).isEmpty
})
let sectionEmpty = nextNonEmpty == nil || lines[nextNonEmpty!].trimmingCharacters(in: .whitespaces).hasPrefix("[")
if sectionEmpty {
lines.remove(at: idx)
}
}
return lines.joined(separator: "\n")
}
/// Check if a TOML line sets a specific key (ignoring comments and whitespace).
private func isTomlKey(_ line: String, key: String) -> Bool {
let trimmed = line.trimmingCharacters(in: .whitespaces)
guard !trimmed.hasPrefix("#") else { return false }
guard trimmed.hasPrefix(key) else { return false }
let rest = trimmed.dropFirst(key.count).trimmingCharacters(in: .whitespaces)
return rest.hasPrefix("=")
}
/// Codex hook handler. Gracefully no-ops when not running inside cmux.
private func runCodexHook(
commandArgs: [String],
client: SocketClient,
telemetry: CLISocketSentryTelemetry
) throws {
let env = ProcessInfo.processInfo.environment
// Graceful no-op: if not inside cmux, exit silently with valid JSON
guard env["CMUX_SURFACE_ID"] != nil else {
print("{}")
return
}
let subcommand = commandArgs.first?.lowercased() ?? "help"
let hookArgs = Array(commandArgs.dropFirst())
let hookWsFlag = optionValue(hookArgs, name: "--workspace")
let workspaceArg = hookWsFlag ?? env["CMUX_WORKSPACE_ID"]
let surfaceArg = optionValue(hookArgs, name: "--surface") ?? (hookWsFlag == nil ? env["CMUX_SURFACE_ID"] : nil)
let rawInput = String(data: FileHandle.standardInput.readDataToEndOfFile(), encoding: .utf8) ?? ""
let parsedInput = parseClaudeHookInput(rawInput: rawInput)
let sessionStore = ClaudeHookSessionStore(
processEnv: env.merging(
["CMUX_CLAUDE_HOOK_STATE_PATH": "~/.cmuxterm/codex-hook-sessions.json"],
uniquingKeysWith: { _, new in new }
)
)
telemetry.breadcrumb(
"codex-hook.input",
data: [
"subcommand": subcommand,
"has_session_id": parsedInput.sessionId != nil
]
)
switch subcommand {
case "session-start":
telemetry.breadcrumb("codex-hook.session-start")
let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook(
preferred: nil,
fallback: workspaceArg,
client: client
)
let surfaceId = try resolvePreferredSurfaceIdForClaudeHook(
preferred: nil,
fallback: surfaceArg,
workspaceId: workspaceId,
client: client
)
if let sessionId = parsedInput.sessionId {
try? sessionStore.upsert(
sessionId: sessionId,
workspaceId: workspaceId,
surfaceId: surfaceId,
cwd: parsedInput.cwd
)
}
print("{}")
case "prompt-submit":
telemetry.breadcrumb("codex-hook.prompt-submit")
let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) }
let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook(
preferred: mappedSession?.workspaceId,
fallback: workspaceArg,
client: client
)
_ = try? sendV1Command("clear_notifications --tab=\(workspaceId)", client: client)
try setCodexStatus(
client: client,
workspaceId: workspaceId,
value: "Running",
icon: "bolt.fill",
color: "#4C8DFF"
)
print("{}")
case "stop":
telemetry.breadcrumb("codex-hook.stop")
do {
let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) }
let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook(
preferred: mappedSession?.workspaceId,
fallback: workspaceArg,
client: client
)
let surfaceId = try resolvePreferredSurfaceIdForClaudeHook(
preferred: mappedSession?.surfaceId,
fallback: surfaceArg,
workspaceId: workspaceId,
client: client
)
// Build completion notification from Codex stop payload
let lastMessage = parsedInput.object?["last_assistant_message"] as? String
?? parsedInput.object?["lastAssistantMessage"] as? String
let cwd = parsedInput.cwd ?? mappedSession?.cwd
let projectName: String? = {
guard let cwd, !cwd.isEmpty else { return nil }
return URL(fileURLWithPath: NSString(string: cwd).expandingTildeInPath).lastPathComponent
}()
if let sessionId = parsedInput.sessionId {
try? sessionStore.upsert(
sessionId: sessionId,
workspaceId: workspaceId,
surfaceId: surfaceId,
cwd: cwd,
lastSubtitle: "Completed",
lastBody: lastMessage.map { truncate($0, maxLength: 200) }
)
}
// Send completion notification
var subtitle = "Completed"
if let projectName, !projectName.isEmpty {
subtitle = "Completed in \(projectName)"
}
let body = sanitizeNotificationField(
lastMessage.map { truncate(normalizedSingleLine($0), maxLength: 200) }
?? "Codex session completed"
)
let payload = "Codex|\(sanitizeNotificationField(subtitle))|\(body)"
_ = try? sendV1Command("notify_target \(workspaceId) \(surfaceId) \(payload)", client: client)
try? setCodexStatus(
client: client,
workspaceId: workspaceId,
value: "Idle",
icon: "pause.circle.fill",
color: "#8E8E93"
)
print("{}")
} catch {
if shouldIgnoreClaudeHookTeardownError(error) {
telemetry.breadcrumb("codex-hook.stop.ignored", data: ["error": String(describing: error)])
print("{}")
return
}
throw error
}
case "help", "--help", "-h":
print("cmux codex-hook <session-start|prompt-submit|stop> [--workspace <id>] [--surface <id>]")
default:
throw CLIError(message: "Unknown codex-hook subcommand: \(subcommand)")
}
}
private func setCodexStatus(
client: SocketClient,
workspaceId: String,
value: String,
icon: String,
color: String
) throws {
let cmd = "set_status codex \(value) --icon=\(icon) --color=\(color) --tab=\(workspaceId)"
_ = try client.send(command: cmd)
}
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}T\(reset)\(subdued) New tab\(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.com/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().standardizedFileURL
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
}
}
guard let parent = parentSearchURL(for: current) else {
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))
}
// Foundation can walk past "/" into "/.." when repeatedly deleting path
// components, so stop once the canonical root is reached.
private func parentSearchURL(for url: URL) -> URL? {
let standardized = url.standardizedFileURL
let path = standardized.path
guard !path.isEmpty, path != "/" else {
return nil
}
let parent = standardized.deletingLastPathComponent().standardizedFileURL
guard parent.path != path else {
return nil
}
return parent
}
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().standardizedFileURL
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
}
guard let parent = parentSearchURL(for: current) else {
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().standardizedFileURL,
executableURL.deletingLastPathComponent().deletingLastPathComponent().standardizedFileURL
]
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> ...]]
themes [list|set|clear]
claude-teams [claude-args...]
omo [opencode-args...]
codex <install-hooks|uninstall-hooks>
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>] [--color <name|#hex>]
list-workspaces
new-workspace [--name <title>] [--cwd <path>] [--command <text>]
ssh <destination> [--name <title>] [--port <n>] [--identity <path>] [--ssh-option <opt>] [--no-focus] [-- <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 ~/Library/Application Support/cmux/cmux.sock and auto-discovers tagged/debug sockets.
"""
}
#if DEBUG
func debugUsageTextForTesting() -> String {
usage()
}
func debugFormatDebugTerminalsPayloadForTesting(
_ payload: [String: Any],
idFormat: CLIIDFormat = .refs
) -> String {
formatDebugTerminalsPayload(payload, idFormat: idFormat)
}
#endif
}
@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)
}
}
}