diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 94c90c5e..17308aed 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -899,7 +899,9 @@ struct CMUXCLI { // Check for --help/-h on subcommands before connecting to the socket, // so help text is available even when cmux is not running. - if commandArgs.contains("--help") || commandArgs.contains("-h") { + if command != "__tmux-compat", + command != "claude-teams", + (commandArgs.contains("--help") || commandArgs.contains("-h")) { if dispatchSubcommandHelp(command: command, commandArgs: commandArgs) { return } @@ -932,6 +934,15 @@ struct CMUXCLI { return } + if command == "claude-teams" { + try runClaudeTeams( + commandArgs: commandArgs, + socketPath: resolvedSocketPath, + explicitPassword: socketPasswordArg + ) + return + } + let client = SocketClient(path: resolvedSocketPath) if resolvedSocketPath != socketPath { cliTelemetry.breadcrumb( @@ -1672,6 +1683,15 @@ struct CMUXCLI { 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", @@ -4244,6 +4264,27 @@ struct CMUXCLI { Double check with the end user before sending anything. Review the message and attachments for secrets, private code, credentials, tokens, and other sensitive information first. """ + case "claude-teams": + return String(localized: "cli.claude-teams.usage", defaultValue: """ + Usage: cmux claude-teams [claude-args...] + + Launch Claude Code with agent teams enabled. + + This command: + - defaults Claude teammate mode to auto + - sets a tmux-like environment so Claude auto mode uses cmux splits + - sets CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 + - prepends a private tmux shim to PATH + - forwards all remaining arguments to claude + + The tmux shim translates supported tmux window/pane commands into cmux + workspace and split operations in the current cmux session. + + Examples: + cmux claude-teams + cmux claude-teams --continue + cmux claude-teams --model sonnet + """) case "identify": return """ Usage: cmux identify [--workspace ] [--surface ] [--no-caller] @@ -5948,6 +5989,1132 @@ struct CMUXCLI { return output } + private struct TmuxParsedArguments { + var flags: Set = [] + 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, + boolFlags: Set + ) 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 = ["-L", "-S", "-f"] + + while index < args.count { + let arg = args[index] + if !arg.hasPrefix("-") || arg == "-" { + return (arg.lowercased(), Array(args.dropFirst(index + 1))) + } + if arg == "--" { + break + } + if let flag = globalValueFlags.first(where: { arg == $0 || arg.hasPrefix($0) }) { + if arg == flag { + index += 1 + } + } + index += 1 + } + + throw CLIError(message: "tmux shim requires a command") + } + + private func normalizedTmuxTarget(_ raw: String?) -> String? { + guard let raw else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private func tmuxWindowSelector(from raw: String?) -> String? { + guard let trimmed = normalizedTmuxTarget(raw) else { return nil } + if trimmed.hasPrefix("%") || trimmed.hasPrefix("pane:") { + return nil + } + if let dot = trimmed.lastIndex(of: ".") { + return String(trimmed[.. String? { + guard let trimmed = normalizedTmuxTarget(raw) else { return nil } + if trimmed.hasPrefix("%") { + return String(trimmed.dropFirst()) + } + if trimmed.hasPrefix("pane:") { + return trimmed + } + if let dot = trimmed.lastIndex(of: ".") { + return String(trimmed[trimmed.index(after: dot)...]) + } + return nil + } + + private func tmuxWorkspaceItems(client: SocketClient) throws -> [[String: Any]] { + let payload = try client.sendV2(method: "workspace.list") + return payload["workspaces"] as? [[String: Any]] ?? [] + } + + private func tmuxCallerWorkspaceHandle() -> String? { + normalizedTmuxTarget(ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"]) + } + + private func tmuxCallerPaneHandle() -> String? { + guard let pane = normalizedTmuxTarget(ProcessInfo.processInfo.environment["TMUX_PANE"]) + ?? normalizedTmuxTarget(ProcessInfo.processInfo.environment["CMUX_PANE_ID"]) else { + return nil + } + return pane.hasPrefix("%") ? String(pane.dropFirst()) : pane + } + + private func tmuxCallerSurfaceHandle() -> String? { + normalizedTmuxTarget(ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"]) + } + + private func tmuxCanonicalPaneId( + _ handle: String, + workspaceId: String, + client: SocketClient + ) throws -> String { + if isUUID(handle) { + return handle + } + + let payload = try client.sendV2(method: "pane.list", params: ["workspace_id": workspaceId]) + let panes = payload["panes"] as? [[String: Any]] ?? [] + for pane in panes { + if (pane["ref"] as? String) == handle || (pane["id"] as? String) == handle { + if let id = pane["id"] as? String { + return id + } + } + } + + if let index = Int(handle) { + for pane in panes where intFromAny(pane["index"]) == index { + if let id = pane["id"] as? String { + return id + } + } + } + + throw CLIError(message: "Pane target not found") + } + + private func tmuxCanonicalSurfaceId( + _ handle: String, + workspaceId: String, + client: SocketClient + ) throws -> String { + if isUUID(handle) { + return handle + } + + let payload = try client.sendV2(method: "surface.list", params: ["workspace_id": workspaceId]) + let surfaces = payload["surfaces"] as? [[String: Any]] ?? [] + for surface in surfaces { + if (surface["ref"] as? String) == handle || (surface["id"] as? String) == handle { + if let id = surface["id"] as? String { + return id + } + } + } + + if let index = Int(handle) { + for surface in surfaces where intFromAny(surface["index"]) == index { + if let id = surface["id"] as? String { + return id + } + } + } + + throw CLIError(message: "Surface target not found") + } + + private func tmuxWorkspaceIdForPaneHandle(_ handle: String, client: SocketClient) throws -> String? { + guard isUUID(handle) || isHandleRef(handle) else { + return nil + } + + let workspaces = try tmuxWorkspaceItems(client: client) + for workspace in workspaces { + guard let workspaceId = workspace["id"] as? String else { continue } + let payload = try client.sendV2(method: "pane.list", params: ["workspace_id": workspaceId]) + let panes = payload["panes"] as? [[String: Any]] ?? [] + if panes.contains(where: { ($0["id"] as? String) == handle || ($0["ref"] as? String) == handle }) { + return workspaceId + } + } + + return nil + } + + private func tmuxFocusedPaneId(workspaceId: String, client: SocketClient) throws -> String { + let payload = try client.sendV2(method: "surface.current", params: ["workspace_id": workspaceId]) + if let paneId = payload["pane_id"] as? String { + return paneId + } + if let paneRef = payload["pane_ref"] as? String { + return try tmuxCanonicalPaneId(paneRef, workspaceId: workspaceId, client: client) + } + throw CLIError(message: "Pane target not found") + } + + private func tmuxResolveWorkspaceTarget(_ raw: String?, client: SocketClient) throws -> String { + guard var token = normalizedTmuxTarget(raw) else { + if let callerWorkspace = tmuxCallerWorkspaceHandle() { + return try resolveWorkspaceId(callerWorkspace, client: client) + } + return try resolveWorkspaceId(nil, client: client) + } + + if token == "!" || token == "^" || token == "-" { + let payload = try client.sendV2(method: "workspace.last") + if let workspaceId = payload["workspace_id"] as? String { + return workspaceId + } + throw CLIError(message: "Previous workspace not found") + } + + if let dot = token.lastIndex(of: ".") { + token = String(token[.. (workspaceId: String, paneId: String) { + let paneSelector = tmuxPaneSelector(from: raw) + let workspaceSelector = tmuxWindowSelector(from: raw) + let workspaceId: String = { + if let workspaceSelector { + return (try? tmuxResolveWorkspaceTarget(workspaceSelector, client: client)) ?? "" + } + if let paneSelector, + let workspaceId = try? tmuxWorkspaceIdForPaneHandle(paneSelector, client: client) { + return workspaceId + } + return (try? tmuxResolveWorkspaceTarget(nil, client: client)) ?? "" + }() + guard !workspaceId.isEmpty else { + throw CLIError(message: "Workspace target not found") + } + let paneId: String + if let paneSelector { + paneId = try tmuxCanonicalPaneId(paneSelector, workspaceId: workspaceId, client: client) + } else if tmuxCallerWorkspaceHandle() == workspaceId, + let callerPane = tmuxCallerPaneHandle(), + let callerPaneId = try? tmuxCanonicalPaneId(callerPane, workspaceId: workspaceId, client: client) { + paneId = callerPaneId + } else { + paneId = try tmuxFocusedPaneId(workspaceId: workspaceId, client: client) + } + return (workspaceId, paneId) + } + + private func tmuxSelectedSurfaceId( + workspaceId: String, + paneId: String, + client: SocketClient + ) throws -> String { + let payload = try client.sendV2( + method: "pane.surfaces", + params: ["workspace_id": workspaceId, "pane_id": paneId] + ) + let surfaces = payload["surfaces"] as? [[String: Any]] ?? [] + if let selected = surfaces.first(where: { ($0["selected"] as? Bool) == true }), + let id = selected["id"] as? String { + return id + } + if let first = surfaces.first?["id"] as? String { + return first + } + throw CLIError(message: "Pane has no surface to target") + } + + private func tmuxResolveSurfaceTarget( + _ raw: String?, + client: SocketClient + ) throws -> (workspaceId: String, paneId: String?, surfaceId: String) { + if tmuxPaneSelector(from: raw) != nil { + let resolved = try tmuxResolvePaneTarget(raw, client: client) + let surfaceId = try tmuxSelectedSurfaceId( + workspaceId: resolved.workspaceId, + paneId: resolved.paneId, + client: client + ) + return (resolved.workspaceId, resolved.paneId, surfaceId) + } + + let workspaceId = try tmuxResolveWorkspaceTarget(tmuxWindowSelector(from: raw), client: client) + if tmuxWindowSelector(from: raw) == nil, + tmuxCallerWorkspaceHandle() == workspaceId, + let callerSurface = tmuxCallerSurfaceHandle(), + let surfaceId = try? tmuxCanonicalSurfaceId(callerSurface, workspaceId: workspaceId, client: client) { + return (workspaceId, nil, surfaceId) + } + let surfaceId = try resolveSurfaceId(nil, workspaceId: workspaceId, client: client) + return (workspaceId, nil, surfaceId) + } + + private func tmuxRenderFormat( + _ format: String?, + context: [String: String], + fallback: String + ) -> String { + guard let format, !format.isEmpty else { return fallback } + var rendered = format + for (key, value) in context { + rendered = rendered.replacingOccurrences(of: "#{\(key)}", with: value) + } + rendered = rendered.replacingOccurrences( + of: "#\\{[^}]+\\}", + with: "", + options: .regularExpression + ) + let trimmed = rendered.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? fallback : trimmed + } + + private func tmuxFormatContext( + workspaceId: String, + paneId: String? = nil, + surfaceId: String? = nil, + client: SocketClient + ) throws -> [String: String] { + let canonicalWorkspaceId = try resolveWorkspaceId(workspaceId, client: client) + var context: [String: String] = [ + "session_name": "cmux", + "window_id": "@\(canonicalWorkspaceId)", + "window_uuid": canonicalWorkspaceId + ] + + let workspaceItems = try tmuxWorkspaceItems(client: client) + if let workspace = workspaceItems.first(where: { + ($0["id"] as? String) == canonicalWorkspaceId || ($0["ref"] as? String) == workspaceId + }) { + if let index = intFromAny(workspace["index"]) { + context["window_index"] = String(index) + } + let title = ((workspace["title"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if !title.isEmpty { + context["window_name"] = title + } + } + + let currentPayload = try client.sendV2(method: "surface.current", params: ["workspace_id": canonicalWorkspaceId]) + let resolvedPaneId: String? = try { + if let paneId { + return try tmuxCanonicalPaneId(paneId, workspaceId: canonicalWorkspaceId, client: client) + } + if let currentPaneId = currentPayload["pane_id"] as? String { + return currentPaneId + } + if let currentPaneRef = currentPayload["pane_ref"] as? String { + return try tmuxCanonicalPaneId(currentPaneRef, workspaceId: canonicalWorkspaceId, client: client) + } + return nil + }() + let resolvedSurfaceId: String? = try { + if let surfaceId { + return try tmuxCanonicalSurfaceId(surfaceId, workspaceId: canonicalWorkspaceId, client: client) + } + if let resolvedPaneId { + return try tmuxSelectedSurfaceId( + workspaceId: canonicalWorkspaceId, + paneId: resolvedPaneId, + client: client + ) + } + return currentPayload["surface_id"] as? String + }() + + if let resolvedPaneId { + context["pane_id"] = "%\(resolvedPaneId)" + context["pane_uuid"] = resolvedPaneId + let panePayload = try client.sendV2(method: "pane.list", params: ["workspace_id": canonicalWorkspaceId]) + let panes = panePayload["panes"] as? [[String: Any]] ?? [] + if let pane = panes.first(where: { ($0["id"] as? String) == resolvedPaneId }), + let index = intFromAny(pane["index"]) { + context["pane_index"] = String(index) + } + } + + if let resolvedSurfaceId { + context["surface_id"] = resolvedSurfaceId + let surfacePayload = try client.sendV2(method: "surface.list", params: ["workspace_id": canonicalWorkspaceId]) + let surfaces = surfacePayload["surfaces"] as? [[String: Any]] ?? [] + if let surface = surfaces.first(where: { ($0["id"] as? String) == resolvedSurfaceId }) { + let title = ((surface["title"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if !title.isEmpty { + context["pane_title"] = title + context["window_name"] = context["window_name"] ?? title + } + } + } + + return context + } + + private func tmuxShellQuote(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" + } + + private func tmuxShellCommandText(commandTokens: [String], cwd: String?) -> String? { + let trimmedCwd = cwd?.trimmingCharacters(in: .whitespacesAndNewlines) + let commandText = commandTokens.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) + guard (trimmedCwd?.isEmpty == false) || !commandText.isEmpty else { + return nil + } + + var pieces: [String] = [] + if let trimmedCwd, !trimmedCwd.isEmpty { + pieces.append("cd -- \(tmuxShellQuote(resolvePath(trimmedCwd)))") + } + if !commandText.isEmpty { + pieces.append(commandText) + } + return pieces.joined(separator: " && ") + "\r" + } + + private func tmuxSpecialKeyText(_ token: String) -> String? { + switch token.lowercased() { + case "enter", "c-m", "kpenter": + return "\r" + case "tab", "c-i": + return "\t" + case "space": + return " " + case "bspace", "backspace": + return "\u{7f}" + case "escape", "esc", "c-[": + return "\u{1b}" + case "c-c": + return "\u{03}" + case "c-d": + return "\u{04}" + case "c-z": + return "\u{1a}" + case "c-l": + return "\u{0c}" + default: + return nil + } + } + + private func tmuxSendKeysText(from tokens: [String], literal: Bool) -> String { + if literal { + return tokens.joined(separator: " ") + } + + var result = "" + var pendingSpace = false + for token in tokens { + if let special = tmuxSpecialKeyText(token) { + result += special + pendingSpace = false + continue + } + if pendingSpace { + result += " " + } + result += token + pendingSpace = true + } + return result + } + + private func prependPathEntries(_ newEntries: [String], to currentPath: String?) -> String { + var ordered: [String] = [] + var seen: Set = [] + for entry in newEntries + (currentPath?.split(separator: ":").map(String.init) ?? []) where !entry.isEmpty { + if seen.insert(entry).inserted { + ordered.append(entry) + } + } + return ordered.joined(separator: ":") + } + + private struct ClaudeTeamsFocusedContext { + let socketPath: String + let workspaceId: String + let windowId: String? + let paneHandle: String + let paneId: String? + let surfaceId: String? + } + + private func claudeTeamsResolvedSocketPath(processEnvironment: [String: String]) -> String { + let envSocketPath: String? = { + for key in ["CMUX_SOCKET_PATH", "CMUX_SOCKET"] { + guard let raw = processEnvironment[key] else { continue } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return trimmed + } + } + return nil + }() + + let requestedSocketPath = envSocketPath ?? CLISocketPathResolver.defaultSocketPath + let source: CLISocketPathSource + if let envSocketPath { + source = envSocketPath == CLISocketPathResolver.defaultSocketPath ? .implicitDefault : .environment + } else { + source = .implicitDefault + } + + return CLISocketPathResolver.resolve( + requestedPath: requestedSocketPath, + source: source, + environment: processEnvironment + ) + } + + private func claudeTeamsFocusedContext( + processEnvironment: [String: String], + explicitPassword: String? + ) -> ClaudeTeamsFocusedContext? { + let socketPath = claudeTeamsResolvedSocketPath(processEnvironment: processEnvironment) + let client = SocketClient(path: socketPath) + + do { + try client.connect() + try authenticateClientIfNeeded(client, explicitPassword: explicitPassword) + defer { client.close() } + + let payload = try client.sendV2(method: "system.identify") + let focused = payload["focused"] as? [String: Any] ?? [:] + + let workspaceId = (focused["workspace_id"] as? String) + ?? (focused["workspace_ref"] as? String) + let paneId = (focused["pane_id"] as? String) + ?? (focused["pane_ref"] as? String) + + guard let workspaceId, let paneId else { + return nil + } + + let paneHandle = paneId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !paneHandle.isEmpty else { + return nil + } + + let windowId = (focused["window_id"] as? String) + ?? (focused["window_ref"] as? String) + let surfaceId = (focused["surface_id"] as? String) + ?? (focused["surface_ref"] as? String) + + return ClaudeTeamsFocusedContext( + socketPath: socketPath, + workspaceId: workspaceId, + windowId: windowId, + paneHandle: paneHandle, + paneId: focused["pane_id"] as? String, + surfaceId: surfaceId + ) + } catch { + client.close() + return nil + } + } + + private func isCmuxClaudeWrapper(at path: String) -> Bool { + guard let data = FileManager.default.contents(atPath: path) else { return false } + let prefixData = data.prefix(512) + guard let prefix = String(data: prefixData, encoding: .utf8) else { return false } + return prefix.contains("cmux claude wrapper - injects hooks and session tracking") + } + + private func resolveClaudeExecutable(searchPath: String?) -> String? { + let entries = searchPath?.split(separator: ":").map(String.init) ?? [] + for entry in entries where !entry.isEmpty { + let candidate = URL(fileURLWithPath: entry, isDirectory: true) + .appendingPathComponent("claude", isDirectory: false) + .path + guard FileManager.default.isExecutableFile(atPath: candidate) else { continue } + guard !isCmuxClaudeWrapper(at: candidate) else { continue } + return candidate + } + return nil + } + + private func claudeTeamsHasExplicitTeammateMode(commandArgs: [String]) -> Bool { + commandArgs.contains { arg in + arg == "--teammate-mode" || arg.hasPrefix("--teammate-mode=") + } + } + + private func claudeTeamsLaunchArguments(commandArgs: [String]) -> [String] { + guard !claudeTeamsHasExplicitTeammateMode(commandArgs: commandArgs) else { + return commandArgs + } + return ["--teammate-mode", "auto"] + commandArgs + } + + private func configureClaudeTeamsEnvironment( + processEnvironment: [String: String], + shimDirectory: URL, + executablePath: String, + socketPath: String, + explicitPassword: String?, + focusedContext: ClaudeTeamsFocusedContext? + ) { + let updatedPath = prependPathEntries( + [shimDirectory.path], + to: processEnvironment["PATH"] + ) + let fakeTmuxValue: String = { + if let focusedContext { + let windowToken = focusedContext.windowId ?? focusedContext.workspaceId + return "/tmp/cmux-claude-teams/\(focusedContext.workspaceId),\(windowToken),\(focusedContext.paneHandle)" + } + return processEnvironment["TMUX"] ?? "/tmp/cmux-claude-teams/default,0,0" + }() + let fakeTmuxPane = focusedContext.map { "%\($0.paneHandle)" } + ?? processEnvironment["TMUX_PANE"] + ?? "%1" + let fakeTerm = processEnvironment["CMUX_CLAUDE_TEAMS_TERM"] ?? "screen-256color" + + setenv("CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS", "1", 1) + setenv("CMUX_CLAUDE_TEAMS_CMUX_BIN", executablePath, 1) + setenv("PATH", updatedPath, 1) + setenv("TMUX", fakeTmuxValue, 1) + setenv("TMUX_PANE", fakeTmuxPane, 1) + setenv("TERM", fakeTerm, 1) + setenv("CMUX_SOCKET_PATH", socketPath, 1) + setenv("CMUX_SOCKET", socketPath, 1) + if let explicitPassword, + !explicitPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + setenv("CMUX_SOCKET_PASSWORD", explicitPassword, 1) + } + unsetenv("TERM_PROGRAM") + if let focusedContext { + setenv("CMUX_WORKSPACE_ID", focusedContext.workspaceId, 1) + if let surfaceId = focusedContext.surfaceId, !surfaceId.isEmpty { + setenv("CMUX_SURFACE_ID", surfaceId, 1) + } + } + } + + private func createClaudeTeamsShimDirectory() throws -> URL { + let homePath = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory() + let rootPath = URL(fileURLWithPath: homePath, isDirectory: true) + .appendingPathComponent(".cmuxterm", isDirectory: true) + .appendingPathComponent("claude-teams-bin", isDirectory: true) + .path + let root = URL(fileURLWithPath: rootPath, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true, attributes: nil) + let tmuxURL = root.appendingPathComponent("tmux", isDirectory: false) + let script = """ + #!/usr/bin/env bash + set -euo pipefail + exec "${CMUX_CLAUDE_TEAMS_CMUX_BIN:-cmux}" __tmux-compat "$@" + """ + let normalizedScript = script.trimmingCharacters(in: .whitespacesAndNewlines) + let existingScript = try? String(contentsOf: tmuxURL, encoding: .utf8) + if existingScript?.trimmingCharacters(in: .whitespacesAndNewlines) != normalizedScript { + try script.write(to: tmuxURL, atomically: false, encoding: .utf8) + } + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tmuxURL.path) + return root + } + + private func runClaudeTeams( + commandArgs: [String], + socketPath: String, + explicitPassword: String? + ) throws { + let processEnvironment = ProcessInfo.processInfo.environment + var launcherEnvironment = processEnvironment + launcherEnvironment["CMUX_SOCKET_PATH"] = socketPath + launcherEnvironment["CMUX_SOCKET"] = socketPath + if let explicitPassword, + !explicitPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + launcherEnvironment["CMUX_SOCKET_PASSWORD"] = explicitPassword + } + let shimDirectory = try createClaudeTeamsShimDirectory() + let executablePath = resolvedExecutableURL()?.path ?? (args.first ?? "cmux") + let focusedContext = claudeTeamsFocusedContext( + processEnvironment: launcherEnvironment, + explicitPassword: explicitPassword + ) + let bundledClaudePath = resolvedExecutableURL()? + .deletingLastPathComponent() + .appendingPathComponent("claude", isDirectory: false) + .path + let claudeExecutablePath = resolveClaudeExecutable(searchPath: launcherEnvironment["PATH"]) + ?? { + guard let bundledClaudePath, + FileManager.default.isExecutableFile(atPath: bundledClaudePath) else { return nil } + return bundledClaudePath + }() + configureClaudeTeamsEnvironment( + processEnvironment: launcherEnvironment, + shimDirectory: shimDirectory, + executablePath: executablePath, + socketPath: socketPath, + explicitPassword: explicitPassword, + focusedContext: focusedContext + ) + + let launchPath = claudeExecutablePath ?? "claude" + let launchArguments = claudeTeamsLaunchArguments(commandArgs: commandArgs) + var argv = ([launchPath] + launchArguments).map { strdup($0) } + defer { + for item in argv { + free(item) + } + } + argv.append(nil) + + if claudeExecutablePath != nil { + execv(launchPath, &argv) + } else { + execvp("claude", &argv) + } + let code = errno + throw CLIError(message: "Failed to launch claude: \(String(cString: strerror(code)))") + } + + private func runClaudeTeamsTmuxCompat( + commandArgs: [String], + client: SocketClient, + jsonOutput: Bool, + idFormat: CLIIDFormat, + windowOverride: String? + ) throws { + let (command, rawArgs) = try splitTmuxCommand(commandArgs) + + switch command { + case "new-session", "new": + let parsed = try parseTmuxArguments( + rawArgs, + valueFlags: ["-c", "-F", "-n", "-s"], + boolFlags: ["-A", "-d", "-P"] + ) + if parsed.hasFlag("-A") { + throw CLIError(message: "new-session -A is not supported in cmux claude-teams mode") + } + var params: [String: Any] = ["focus": false] + if let cwd = parsed.value("-c") { + params["cwd"] = resolvePath(cwd) + } + let created = try client.sendV2(method: "workspace.create", params: params) + guard let workspaceId = created["workspace_id"] as? String else { + throw CLIError(message: "workspace.create did not return workspace_id") + } + if let title = parsed.value("-n") ?? parsed.value("-s"), + !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + _ = try client.sendV2(method: "workspace.rename", params: [ + "workspace_id": workspaceId, + "title": title + ]) + } + if let text = tmuxShellCommandText(commandTokens: parsed.positional, cwd: parsed.value("-c")) { + Thread.sleep(forTimeInterval: 0.3) + let surfaceId = try resolveSurfaceId(nil, workspaceId: workspaceId, client: client) + _ = try client.sendV2(method: "surface.send_text", params: [ + "workspace_id": workspaceId, + "surface_id": surfaceId, + "text": text + ]) + } + if parsed.hasFlag("-P") { + let context = try tmuxFormatContext(workspaceId: workspaceId, client: client) + print(tmuxRenderFormat(parsed.value("-F"), context: context, fallback: "@\(workspaceId)")) + } + + case "new-window", "neww": + let parsed = try parseTmuxArguments( + rawArgs, + valueFlags: ["-c", "-F", "-n", "-t"], + boolFlags: ["-d", "-P"] + ) + if parsed.value("-t") != nil { + throw CLIError(message: "new-window -t is not supported in cmux claude-teams mode") + } + var params: [String: Any] = ["focus": false] + if let cwd = parsed.value("-c") { + params["cwd"] = resolvePath(cwd) + } + let created = try client.sendV2(method: "workspace.create", params: params) + guard let workspaceId = created["workspace_id"] as? String else { + throw CLIError(message: "workspace.create did not return workspace_id") + } + if let title = parsed.value("-n"), + !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + _ = try client.sendV2(method: "workspace.rename", params: [ + "workspace_id": workspaceId, + "title": title + ]) + } + if let text = tmuxShellCommandText(commandTokens: parsed.positional, cwd: parsed.value("-c")) { + Thread.sleep(forTimeInterval: 0.3) + let surfaceId = try resolveSurfaceId(nil, workspaceId: workspaceId, client: client) + _ = try client.sendV2(method: "surface.send_text", params: [ + "workspace_id": workspaceId, + "surface_id": surfaceId, + "text": text + ]) + } + if parsed.hasFlag("-P") { + let context = try tmuxFormatContext(workspaceId: workspaceId, client: client) + print(tmuxRenderFormat(parsed.value("-F"), context: context, fallback: "@\(workspaceId)")) + } + + case "split-window", "splitw": + let parsed = try parseTmuxArguments( + rawArgs, + valueFlags: ["-c", "-F", "-l", "-t"], + boolFlags: ["-P", "-b", "-d", "-h", "-v"] + ) + let target = try tmuxResolveSurfaceTarget(parsed.value("-t"), client: client) + let direction: String + if parsed.hasFlag("-h") { + direction = parsed.hasFlag("-b") ? "left" : "right" + } else { + direction = parsed.hasFlag("-b") ? "up" : "down" + } + let created = try client.sendV2(method: "surface.split", params: [ + "workspace_id": target.workspaceId, + "surface_id": target.surfaceId, + "direction": direction + ]) + guard let surfaceId = created["surface_id"] as? String else { + throw CLIError(message: "surface.split did not return surface_id") + } + let paneId = created["pane_id"] as? String + // Keep the leader pane focused while Claude starts teammates beside it. + if let text = tmuxShellCommandText(commandTokens: parsed.positional, cwd: parsed.value("-c")) { + Thread.sleep(forTimeInterval: 0.3) + _ = try client.sendV2(method: "surface.send_text", params: [ + "workspace_id": target.workspaceId, + "surface_id": surfaceId, + "text": text + ]) + } + if parsed.hasFlag("-P") { + let context = try tmuxFormatContext( + workspaceId: target.workspaceId, + paneId: paneId, + surfaceId: surfaceId, + client: client + ) + let fallback = context["pane_id"] ?? surfaceId + print(tmuxRenderFormat(parsed.value("-F"), context: context, fallback: fallback)) + } + + case "select-window", "selectw": + let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: []) + let workspaceId = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client) + _ = try client.sendV2(method: "workspace.select", params: ["workspace_id": workspaceId]) + + case "select-pane", "selectp": + let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-P", "-T", "-t"], boolFlags: []) + if parsed.value("-P") != nil || parsed.value("-T") != nil { + return + } + let target = try tmuxResolvePaneTarget(parsed.value("-t"), client: client) + _ = try client.sendV2(method: "pane.focus", params: [ + "workspace_id": target.workspaceId, + "pane_id": target.paneId + ]) + + case "kill-window", "killw": + let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: []) + let workspaceId = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client) + _ = try client.sendV2(method: "workspace.close", params: ["workspace_id": workspaceId]) + + case "kill-pane", "killp": + let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: []) + let target = try tmuxResolveSurfaceTarget(parsed.value("-t"), client: client) + _ = try client.sendV2(method: "surface.close", params: [ + "workspace_id": target.workspaceId, + "surface_id": target.surfaceId + ]) + + case "send-keys", "send": + let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: ["-l"]) + let target = try tmuxResolveSurfaceTarget(parsed.value("-t"), client: client) + let text = tmuxSendKeysText(from: parsed.positional, literal: parsed.hasFlag("-l")) + if !text.isEmpty { + _ = try client.sendV2(method: "surface.send_text", params: [ + "workspace_id": target.workspaceId, + "surface_id": target.surfaceId, + "text": text + ]) + } + + case "capture-pane", "capturep": + let parsed = try parseTmuxArguments( + rawArgs, + valueFlags: ["-E", "-S", "-t"], + boolFlags: ["-J", "-N", "-p"] + ) + let target = try tmuxResolveSurfaceTarget(parsed.value("-t"), client: client) + var params: [String: Any] = [ + "workspace_id": target.workspaceId, + "surface_id": target.surfaceId, + "scrollback": true + ] + if let start = parsed.value("-S"), let lines = Int(start), lines < 0 { + params["lines"] = abs(lines) + } + let payload = try client.sendV2(method: "surface.read_text", params: params) + let text = (payload["text"] as? String) ?? "" + if parsed.hasFlag("-p") { + print(text) + } else { + var store = loadTmuxCompatStore() + store.buffers["default"] = text + try saveTmuxCompatStore(store) + } + + case "display-message", "display", "displayp": + let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-F", "-t"], boolFlags: ["-p"]) + let target = try tmuxResolveSurfaceTarget(parsed.value("-t"), client: client) + let context = try tmuxFormatContext( + workspaceId: target.workspaceId, + paneId: target.paneId, + surfaceId: target.surfaceId, + client: client + ) + let format = parsed.positional.isEmpty ? parsed.value("-F") : parsed.positional.joined(separator: " ") + let rendered = tmuxRenderFormat(format, context: context, fallback: "") + if parsed.hasFlag("-p") || !rendered.isEmpty { + print(rendered) + } + + case "list-windows", "lsw": + let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-F", "-t"], boolFlags: []) + let items = try tmuxWorkspaceItems(client: client) + for item in items { + guard let workspaceId = item["id"] as? String else { continue } + let context = try tmuxFormatContext(workspaceId: workspaceId, client: client) + let fallback = [ + context["window_index"] ?? "?", + context["window_name"] ?? workspaceId + ].joined(separator: " ") + print(tmuxRenderFormat(parsed.value("-F"), context: context, fallback: fallback)) + } + + case "list-panes", "lsp": + let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-F", "-t"], boolFlags: []) + let workspaceId = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client) + let payload = try client.sendV2(method: "pane.list", params: ["workspace_id": workspaceId]) + let panes = payload["panes"] as? [[String: Any]] ?? [] + for pane in panes { + guard let paneId = pane["id"] as? String else { continue } + let context = try tmuxFormatContext(workspaceId: workspaceId, paneId: paneId, client: client) + let fallback = context["pane_id"] ?? paneId + print(tmuxRenderFormat(parsed.value("-F"), context: context, fallback: fallback)) + } + + case "rename-window", "renamew": + let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: []) + let title = parsed.positional.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) + guard !title.isEmpty else { + throw CLIError(message: "rename-window requires a title") + } + let workspaceId = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client) + _ = try client.sendV2(method: "workspace.rename", params: [ + "workspace_id": workspaceId, + "title": title + ]) + + case "resize-pane", "resizep": + let parsed = try parseTmuxArguments( + rawArgs, + valueFlags: ["-t", "-x", "-y"], + boolFlags: ["-D", "-L", "-R", "-U"] + ) + let hasDirectionalFlags = parsed.hasFlag("-L") + || parsed.hasFlag("-R") + || parsed.hasFlag("-U") + || parsed.hasFlag("-D") + if !hasDirectionalFlags { + return + } + let target = try tmuxResolvePaneTarget(parsed.value("-t"), client: client) + let direction: String + if parsed.hasFlag("-L") { + direction = "left" + } else if parsed.hasFlag("-U") { + direction = "up" + } else if parsed.hasFlag("-D") { + direction = "down" + } else { + direction = "right" + } + let rawAmount = (parsed.value("-x") ?? parsed.value("-y") ?? "5") + .replacingOccurrences(of: "%", with: "") + let amount = Int(rawAmount) ?? 5 + _ = try client.sendV2(method: "pane.resize", params: [ + "workspace_id": target.workspaceId, + "pane_id": target.paneId, + "direction": direction, + "amount": max(1, amount) + ]) + + case "wait-for": + try runTmuxCompatCommand( + command: "wait-for", + commandArgs: rawArgs, + client: client, + jsonOutput: jsonOutput, + idFormat: idFormat, + windowOverride: windowOverride + ) + + case "last-pane": + let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: []) + let workspaceId = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client) + _ = try client.sendV2(method: "pane.last", params: ["workspace_id": workspaceId]) + + case "show-buffer", "showb": + let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-b"], boolFlags: []) + let name = parsed.value("-b") ?? "default" + let store = loadTmuxCompatStore() + if let buffer = store.buffers[name] { + print(buffer) + } + + case "save-buffer", "saveb": + let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-b"], boolFlags: []) + let name = parsed.value("-b") ?? "default" + let store = loadTmuxCompatStore() + guard let buffer = store.buffers[name] else { + throw CLIError(message: "Buffer not found: \(name)") + } + if let outputPath = parsed.positional.last, !outputPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + try buffer.write(toFile: resolvePath(outputPath), atomically: true, encoding: .utf8) + } else { + print(buffer) + } + + case "last-window", "next-window", "previous-window", "set-hook", "set-buffer", "list-buffers": + try runTmuxCompatCommand( + command: command, + commandArgs: rawArgs, + client: client, + jsonOutput: jsonOutput, + idFormat: idFormat, + windowOverride: windowOverride + ) + + case "has-session", "has": + let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: []) + _ = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client) + + case "select-layout", "set-option", "set", "set-window-option", "setw", "source-file", "refresh-client", "attach-session", "detach-client": + return + + default: + throw CLIError(message: "Unsupported tmux compatibility command: \(command)") + } + } + private struct TmuxCompatStore: Codable { var buffers: [String: String] = [:] var hooks: [String: String] = [:] @@ -7204,6 +8371,7 @@ struct CMUXCLI { welcome shortcuts feedback [--email --body [--image ...]] + claude-teams [claude-args...] ping capabilities identify [--workspace ] [--surface ] [--no-caller] diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 337a9e16..61272b0c 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -115,6 +115,23 @@ } } }, + "cli.claude-teams.usage": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux claude-teams [claude-args...]\n\nLaunch Claude Code with agent teams enabled.\n\nThis command:\n - defaults Claude teammate mode to auto\n - sets a tmux-like environment so Claude auto mode uses cmux splits\n - sets CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1\n - prepends a private tmux shim to PATH\n - forwards all remaining arguments to claude\n\nThe tmux shim translates supported tmux window/pane commands into cmux\nworkspace and split operations in the current cmux session.\n\nExamples:\n cmux claude-teams\n cmux claude-teams --continue\n cmux claude-teams --model sonnet" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "使い方: cmux claude-teams [claude-args...]\n\nエージェントチームを有効にした状態で Claude Code を起動します。\n\nこのコマンドは次を行います:\n - Claude の teammate mode を auto に設定\n - Claude の auto mode が cmux の split を使うよう tmux 風の環境を設定\n - CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 を設定\n - 専用の tmux shim を PATH の先頭に追加\n - 残りの引数をそのまま claude に渡す\n\ntmux shim は、対応している tmux の window/pane コマンドを、現在の cmux セッション内の workspace と split 操作に変換します。\n\n例:\n cmux claude-teams\n cmux claude-teams --continue\n cmux claude-teams --model sonnet" + } + } + } + }, "applescript.error.disabled": { "extractionState": "manual", "localizations": { diff --git a/scripts/reload.sh b/scripts/reload.sh index 43a58863..4e758a88 100755 --- a/scripts/reload.sh +++ b/scripts/reload.sh @@ -45,6 +45,11 @@ sanitize_path() { echo "$cleaned" } +tagged_derived_data_path() { + local slug="$1" + echo "$HOME/Library/Developer/Xcode/DerivedData/cmux-${slug}" +} + print_tag_cleanup_reminder() { local current_slug="$1" local path="" @@ -53,7 +58,13 @@ print_tag_cleanup_reminder() { local -a stale_tags=() while IFS= read -r -d '' path; do - tag="${path#/tmp/cmux-}" + if [[ "$path" == /tmp/cmux-* ]]; then + tag="${path#/tmp/cmux-}" + elif [[ "$path" == "$HOME/Library/Developer/Xcode/DerivedData/cmux-"* ]]; then + tag="${path#$HOME/Library/Developer/Xcode/DerivedData/cmux-}" + else + continue + fi if [[ "$tag" == "$current_slug" ]]; then continue fi @@ -66,7 +77,10 @@ print_tag_cleanup_reminder() { fi seen="${seen}${tag} " stale_tags+=("$tag") - done < <(find /tmp -maxdepth 1 -type d -name 'cmux-*' -print0 2>/dev/null) + done < <( + find /tmp -maxdepth 1 -name 'cmux-*' -print0 2>/dev/null + find "$HOME/Library/Developer/Xcode/DerivedData" -maxdepth 1 -type d -name 'cmux-*' -print0 2>/dev/null + ) echo echo "Tag cleanup status:" @@ -82,14 +96,14 @@ print_tag_cleanup_reminder() { echo "Cleanup stale tags only:" for tag in "${stale_tags[@]}"; do echo " pkill -f \"cmux DEV ${tag}.app/Contents/MacOS/cmux DEV\"" - echo " rm -rf \"/tmp/cmux-${tag}\" \"/tmp/cmux-debug-${tag}.sock\"" + echo " rm -rf \"$(tagged_derived_data_path "$tag")\" \"/tmp/cmux-${tag}\" \"/tmp/cmux-debug-${tag}.sock\"" echo " rm -f \"/tmp/cmux-debug-${tag}.log\"" echo " rm -f \"$HOME/Library/Application Support/cmux/cmuxd-dev-${tag}.sock\"" done fi echo "After you verify current tag, cleanup command:" echo " pkill -f \"cmux DEV ${current_slug}.app/Contents/MacOS/cmux DEV\"" - echo " rm -rf \"/tmp/cmux-${current_slug}\" \"/tmp/cmux-debug-${current_slug}.sock\"" + echo " rm -rf \"$(tagged_derived_data_path "$current_slug")\" \"/tmp/cmux-${current_slug}\" \"/tmp/cmux-debug-${current_slug}.sock\"" echo " rm -f \"/tmp/cmux-debug-${current_slug}.log\"" echo " rm -f \"$HOME/Library/Application Support/cmux/cmuxd-dev-${current_slug}.sock\"" } @@ -159,7 +173,7 @@ if [[ -n "$TAG" ]]; then BUNDLE_ID="com.cmuxterm.app.debug.${TAG_ID}" fi if [[ "$DERIVED_SET" -eq 0 ]]; then - DERIVED_DATA="/tmp/cmux-${TAG_SLUG}" + DERIVED_DATA="$(tagged_derived_data_path "$TAG_SLUG")" fi fi @@ -230,6 +244,15 @@ if [[ -z "${APP_PATH}" || ! -d "${APP_PATH}" ]]; then exit 1 fi +if [[ -n "${TAG_SLUG:-}" ]]; then + TMP_COMPAT_DERIVED_LINK="/tmp/cmux-${TAG_SLUG}" + if [[ "$DERIVED_DATA" != "$TMP_COMPAT_DERIVED_LINK" ]]; then + ABS_DERIVED_DATA="$(cd "$DERIVED_DATA" && pwd)" + rm -rf "$TMP_COMPAT_DERIVED_LINK" + ln -s "$ABS_DERIVED_DATA" "$TMP_COMPAT_DERIVED_LINK" + fi +fi + if [[ -n "$TAG" && "$APP_NAME" != "$SEARCH_APP_NAME" ]]; then TAG_APP_PATH="$(dirname "$APP_PATH")/${APP_NAME}.app" rm -rf "$TAG_APP_PATH" @@ -292,6 +315,10 @@ if [[ -x "$CMUXD_SRC" ]]; then cp "$CMUXD_SRC" "$BIN_DIR/cmuxd" chmod +x "$BIN_DIR/cmuxd" fi +CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux" +if [[ -x "$CLI_PATH" ]]; then + echo "$CLI_PATH" > /tmp/cmux-last-cli-path || true +fi # Avoid inheriting cmux/ghostty environment variables from the terminal that # runs this script (often inside another cmux instance), which can cause # socket and resource-path conflicts. diff --git a/tests/claude_teams_test_utils.py b/tests/claude_teams_test_utils.py new file mode 100644 index 00000000..ab5e42e9 --- /dev/null +++ b/tests/claude_teams_test_utils.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import os +from pathlib import Path + + +def resolve_cmux_cli() -> str: + explicit = os.environ.get("CMUX_CLI_BIN") or os.environ.get("CMUX_CLI") + if explicit and os.path.exists(explicit) and os.access(explicit, os.X_OK): + return explicit + + recorded_path = Path("/tmp/cmux-last-cli-path") + if recorded_path.exists(): + candidate = recorded_path.read_text(encoding="utf-8").strip() + if candidate and os.path.exists(candidate) and os.access(candidate, os.X_OK): + return candidate + + raise RuntimeError( + "Unable to find cmux CLI binary. Set CMUX_CLI_BIN or run ./scripts/reload.sh --tag first." + ) diff --git a/tests/test_cli_claude_teams_env.py b/tests/test_cli_claude_teams_env.py new file mode 100644 index 00000000..03e1c7b4 --- /dev/null +++ b/tests/test_cli_claude_teams_env.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +""" +Regression test: `cmux claude-teams` injects the tmux-style auto-mode env. +""" + +from __future__ import annotations + +import os +import subprocess +import tempfile +from pathlib import Path + +from claude_teams_test_utils import resolve_cmux_cli + + +def make_executable(path: Path, content: str) -> None: + path.write_text(content, encoding="utf-8") + path.chmod(0o755) + + +def read_text(path: Path) -> str: + if not path.exists(): + return "" + return path.read_text(encoding="utf-8").strip() + + +def main() -> int: + try: + cli_path = resolve_cmux_cli() + except Exception as exc: + print(f"FAIL: {exc}") + return 1 + + with tempfile.TemporaryDirectory(prefix="cmux-claude-teams-env-") as td: + tmp = Path(td) + real_bin = tmp / "real-bin" + real_bin.mkdir(parents=True, exist_ok=True) + + env_log = tmp / "agent-teams.log" + tmux_log = tmp / "tmux-path.log" + cmux_bin_log = tmp / "cmux-bin.log" + argv_log = tmp / "argv.log" + tmux_env_log = tmp / "tmux-env.log" + tmux_pane_log = tmp / "tmux-pane.log" + term_log = tmp / "term.log" + term_program_log = tmp / "term-program.log" + socket_path_log = tmp / "socket-path.log" + socket_password_log = tmp / "socket-password.log" + fake_home = tmp / "home" + fake_home.mkdir(parents=True, exist_ok=True) + + make_executable( + real_bin / "claude", + """#!/usr/bin/env bash +set -euo pipefail +printf '%s\\n' "${CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS-__UNSET__}" > "$FAKE_AGENT_TEAMS_LOG" +command -v tmux > "$FAKE_TMUX_PATH_LOG" +printf '%s\\n' "${CMUX_CLAUDE_TEAMS_CMUX_BIN-__UNSET__}" > "$FAKE_CMUX_BIN_LOG" +printf '%s\\n' "$@" > "$FAKE_ARGV_LOG" +printf '%s\\n' "${TMUX-__UNSET__}" > "$FAKE_TMUX_ENV_LOG" +printf '%s\\n' "${TMUX_PANE-__UNSET__}" > "$FAKE_TMUX_PANE_LOG" +printf '%s\\n' "${TERM-__UNSET__}" > "$FAKE_TERM_LOG" +printf '%s\\n' "${TERM_PROGRAM-__UNSET__}" > "$FAKE_TERM_PROGRAM_LOG" +printf '%s\\n' "${CMUX_SOCKET_PATH-__UNSET__}" > "$FAKE_SOCKET_PATH_LOG" +printf '%s\\n' "${CMUX_SOCKET_PASSWORD-__UNSET__}" > "$FAKE_SOCKET_PASSWORD_LOG" +""", + ) + + env = os.environ.copy() + env["HOME"] = str(fake_home) + env["PATH"] = f"{real_bin}:/usr/bin:/bin" + env["FAKE_AGENT_TEAMS_LOG"] = str(env_log) + env["FAKE_TMUX_PATH_LOG"] = str(tmux_log) + env["FAKE_CMUX_BIN_LOG"] = str(cmux_bin_log) + env["FAKE_ARGV_LOG"] = str(argv_log) + env["FAKE_TMUX_ENV_LOG"] = str(tmux_env_log) + env["FAKE_TMUX_PANE_LOG"] = str(tmux_pane_log) + env["FAKE_TERM_LOG"] = str(term_log) + env["FAKE_TERM_PROGRAM_LOG"] = str(term_program_log) + env["FAKE_SOCKET_PATH_LOG"] = str(socket_path_log) + env["FAKE_SOCKET_PASSWORD_LOG"] = str(socket_password_log) + env["TMUX"] = "__HOST_TMUX__" + env["TMUX_PANE"] = "%999" + env["TERM"] = "xterm-256color" + env["TERM_PROGRAM"] = "__HOST_TERM_PROGRAM__" + explicit_socket_path = str(tmp / "explicit-cmux.sock") + explicit_socket_password = "topsecret" + + proc = subprocess.run( + [ + cli_path, + "--socket", + explicit_socket_path, + "--password", + explicit_socket_password, + "claude-teams", + "--version", + ], + capture_output=True, + text=True, + check=False, + env=env, + timeout=30, + ) + + if proc.returncode != 0: + print("FAIL: `cmux claude-teams --version` exited non-zero") + print(f"exit={proc.returncode}") + print(f"stdout={proc.stdout.strip()}") + print(f"stderr={proc.stderr.strip()}") + return 1 + + agent_teams_value = read_text(env_log) + if agent_teams_value != "1": + print(f"FAIL: expected CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1, got {agent_teams_value!r}") + return 1 + + tmux_path = read_text(tmux_log) + if not tmux_path: + print("FAIL: fake claude did not observe a tmux binary in PATH") + return 1 + + tmux_name = Path(tmux_path).name + if tmux_name != "tmux": + print(f"FAIL: expected tmux shim path to end with 'tmux', got {tmux_path!r}") + return 1 + + if "claude-teams-bin" not in tmux_path: + print(f"FAIL: expected stable tmux shim path, got {tmux_path!r}") + return 1 + + if tmux_path.startswith(str(real_bin)): + print(f"FAIL: expected cmux tmux shim to shadow PATH, got {tmux_path!r}") + return 1 + + cmux_bin_value = read_text(cmux_bin_log) + if not cmux_bin_value or cmux_bin_value == "__UNSET__": + print("FAIL: missing CMUX_CLAUDE_TEAMS_CMUX_BIN") + return 1 + + if not os.path.exists(cmux_bin_value): + print(f"FAIL: CMUX_CLAUDE_TEAMS_CMUX_BIN does not exist: {cmux_bin_value!r}") + return 1 + + argv_lines = argv_log.read_text(encoding="utf-8").splitlines() + if argv_lines[:2] != ["--teammate-mode", "auto"]: + print(f"FAIL: expected launcher to prepend --teammate-mode auto, got {argv_lines!r}") + return 1 + + if "--version" not in argv_lines: + print(f"FAIL: expected launcher to preserve user args, got {argv_lines!r}") + return 1 + + tmux_env_value = read_text(tmux_env_log) + if tmux_env_value in {"", "__UNSET__"}: + print("FAIL: expected a fake TMUX env value") + return 1 + + tmux_pane_value = read_text(tmux_pane_log) + if tmux_pane_value in {"", "__UNSET__"} or not tmux_pane_value.startswith("%"): + print(f"FAIL: expected a fake TMUX_PANE value, got {tmux_pane_value!r}") + return 1 + + term_value = read_text(term_log) + if term_value != "screen-256color": + print(f"FAIL: expected TERM=screen-256color, got {term_value!r}") + return 1 + + term_program_value = read_text(term_program_log) + if term_program_value != "__UNSET__": + print(f"FAIL: expected TERM_PROGRAM to be unset, got {term_program_value!r}") + return 1 + + socket_path_value = read_text(socket_path_log) + if socket_path_value != explicit_socket_path: + print(f"FAIL: expected CMUX_SOCKET_PATH={explicit_socket_path!r}, got {socket_path_value!r}") + return 1 + + socket_password_value = read_text(socket_password_log) + if socket_password_value != explicit_socket_password: + print( + "FAIL: expected CMUX_SOCKET_PASSWORD to preserve the explicit CLI override, " + f"got {socket_password_value!r}" + ) + return 1 + + print("PASS: cmux claude-teams injects the auto-mode tmux env and shim") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_cli_claude_teams_existing_shim.py b/tests/test_cli_claude_teams_existing_shim.py new file mode 100644 index 00000000..3eadd8e8 --- /dev/null +++ b/tests/test_cli_claude_teams_existing_shim.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +""" +Regression test: `cmux claude-teams` reuses an existing tmux shim. +""" + +from __future__ import annotations + +import os +import stat +import subprocess +import tempfile +from pathlib import Path + +from claude_teams_test_utils import resolve_cmux_cli + + +def make_executable(path: Path, content: str) -> None: + path.write_text(content, encoding="utf-8") + path.chmod(0o755) + + +def main() -> int: + try: + cli_path = resolve_cmux_cli() + except Exception as exc: + print(f"FAIL: {exc}") + return 1 + + with tempfile.TemporaryDirectory(prefix="cmux-claude-teams-shim-") as td: + tmp = Path(td) + home = tmp / "home" + real_bin = tmp / "real-bin" + home.mkdir(parents=True, exist_ok=True) + real_bin.mkdir(parents=True, exist_ok=True) + + shim_dir = home / ".cmuxterm" / "claude-teams-bin" + shim_dir.mkdir(parents=True, exist_ok=True) + shim_path = shim_dir / "tmux" + shim_path.write_text( + "#!/usr/bin/env bash\n" + "set -euo pipefail\n" + "exec \"${CMUX_CLAUDE_TEAMS_CMUX_BIN:-cmux}\" __tmux-compat \"$@\"\n", + encoding="utf-8", + ) + shim_path.chmod(0o555) + shim_dir.chmod(0o555) + + make_executable( + real_bin / "claude", + """#!/usr/bin/env bash +set -euo pipefail +printf 'shim=%s\\n' "$(command -v tmux)" +""", + ) + + env = os.environ.copy() + env["HOME"] = str(home) + env["PATH"] = f"{real_bin}:/usr/bin:/bin" + + proc = subprocess.run( + [cli_path, "claude-teams", "--version"], + capture_output=True, + text=True, + check=False, + env=env, + timeout=30, + ) + + shim_dir.chmod(0o755) + shim_path.chmod(0o755) + + if proc.returncode != 0: + print("FAIL: `cmux claude-teams --version` failed with an existing shim") + print(f"exit={proc.returncode}") + print(f"stdout={proc.stdout.strip()}") + print(f"stderr={proc.stderr.strip()}") + return 1 + + expected = str(shim_path) + actual = proc.stdout.strip() + if actual != f"shim={expected}": + print(f"FAIL: expected existing shim path {expected!r}, got {actual!r}") + return 1 + + print("PASS: cmux claude-teams reuses an existing tmux shim") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_cli_claude_teams_help_passthrough.py b/tests/test_cli_claude_teams_help_passthrough.py new file mode 100644 index 00000000..73f762f9 --- /dev/null +++ b/tests/test_cli_claude_teams_help_passthrough.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +""" +Regression test: `cmux claude-teams --help` passes through to Claude. +""" + +from __future__ import annotations + +import os +import subprocess +import tempfile +from pathlib import Path + +from claude_teams_test_utils import resolve_cmux_cli + + +def make_executable(path: Path, content: str) -> None: + path.write_text(content, encoding="utf-8") + path.chmod(0o755) + + +def main() -> int: + try: + cli_path = resolve_cmux_cli() + except Exception as exc: + print(f"FAIL: {exc}") + return 1 + + with tempfile.TemporaryDirectory(prefix="cmux-claude-teams-help-") as td: + tmp = Path(td) + home = tmp / "home" + real_bin = tmp / "real-bin" + home.mkdir(parents=True, exist_ok=True) + real_bin.mkdir(parents=True, exist_ok=True) + + argv_log = tmp / "argv.log" + + make_executable( + real_bin / "claude", + """#!/usr/bin/env bash +set -euo pipefail +printf '%s\\n' "$@" > "$FAKE_ARGV_LOG" +""", + ) + + env = os.environ.copy() + env["HOME"] = str(home) + env["PATH"] = f"{real_bin}:/usr/bin:/bin" + env["FAKE_ARGV_LOG"] = str(argv_log) + + proc = subprocess.run( + [cli_path, "claude-teams", "--help"], + capture_output=True, + text=True, + check=False, + env=env, + timeout=30, + ) + + if proc.returncode != 0: + print("FAIL: `cmux claude-teams --help` exited non-zero") + print(f"exit={proc.returncode}") + print(f"stdout={proc.stdout.strip()}") + print(f"stderr={proc.stderr.strip()}") + return 1 + + if not argv_log.exists(): + print("FAIL: launcher intercepted --help instead of invoking Claude") + print(f"stdout={proc.stdout.strip()}") + print(f"stderr={proc.stderr.strip()}") + return 1 + + argv_lines = argv_log.read_text(encoding="utf-8").splitlines() + if argv_lines[:2] != ["--teammate-mode", "auto"]: + print(f"FAIL: expected launcher to prepend --teammate-mode auto, got {argv_lines!r}") + return 1 + + if "--help" not in argv_lines: + print(f"FAIL: expected --help to reach Claude, got {argv_lines!r}") + return 1 + + print("PASS: cmux claude-teams forwards --help to Claude") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_cli_claude_teams_skips_wrapper_claude.py b/tests/test_cli_claude_teams_skips_wrapper_claude.py new file mode 100644 index 00000000..fb84e3f7 --- /dev/null +++ b/tests/test_cli_claude_teams_skips_wrapper_claude.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +""" +Regression test: `cmux claude-teams` skips cmux wrapper scripts on PATH. +""" + +from __future__ import annotations + +import os +import subprocess +import tempfile +from pathlib import Path + +from claude_teams_test_utils import resolve_cmux_cli + + +def make_executable(path: Path, content: str) -> None: + path.write_text(content, encoding="utf-8") + path.chmod(0o755) + + +def main() -> int: + try: + cli_path = resolve_cmux_cli() + except Exception as exc: + print(f"FAIL: {exc}") + return 1 + + with tempfile.TemporaryDirectory(prefix="cmux-claude-teams-wrapper-") as td: + tmp = Path(td) + wrapper_bin = tmp / "wrapper-bin" + real_bin = tmp / "real-bin" + logs = tmp / "logs" + wrapper_bin.mkdir(parents=True, exist_ok=True) + real_bin.mkdir(parents=True, exist_ok=True) + logs.mkdir(parents=True, exist_ok=True) + + real_hit = logs / "real-hit.txt" + + make_executable( + wrapper_bin / "claude", + """#!/usr/bin/env bash +# cmux claude wrapper - injects hooks and session tracking +set -euo pipefail +echo WRAPPER_EXECUTED >&2 +exit 91 +""", + ) + + make_executable( + real_bin / "claude", + f"""#!/usr/bin/env bash +set -euo pipefail +printf 'REAL\\n' > {real_hit} +""", + ) + + env = os.environ.copy() + env["PATH"] = f"{wrapper_bin}:{real_bin}:/usr/bin:/bin" + + proc = subprocess.run( + [cli_path, "claude-teams", "--version"], + capture_output=True, + text=True, + check=False, + env=env, + timeout=30, + ) + + if proc.returncode != 0: + print("FAIL: `cmux claude-teams --version` executed a wrapper instead of the real claude binary") + print(f"exit={proc.returncode}") + print(f"stdout={proc.stdout.strip()}") + print(f"stderr={proc.stderr.strip()}") + return 1 + + if not real_hit.exists(): + print("FAIL: real claude binary was not reached") + return 1 + + print("PASS: cmux claude-teams skips cmux wrapper scripts on PATH") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_cli_claude_teams_tmux_sequence.py b/tests/test_cli_claude_teams_tmux_sequence.py new file mode 100644 index 00000000..f0df27ba --- /dev/null +++ b/tests/test_cli_claude_teams_tmux_sequence.py @@ -0,0 +1,362 @@ +#!/usr/bin/env python3 +""" +Regression test: `cmux claude-teams` supports Claude's tmux teammate flow. +""" + +from __future__ import annotations + +import json +import os +import socketserver +import subprocess +import tempfile +import threading +from pathlib import Path + +from claude_teams_test_utils import resolve_cmux_cli +INITIAL_WORKSPACE_ID = "11111111-1111-4111-8111-111111111111" +INITIAL_WINDOW_ID = "22222222-2222-4222-8222-222222222222" +INITIAL_PANE_ID = "33333333-3333-4333-8333-333333333333" +INITIAL_SURFACE_ID = "44444444-4444-4444-8444-444444444444" +INITIAL_TAB_ID = "55555555-5555-4555-8555-555555555555" +NEW_PANE_ID = "66666666-6666-4666-8666-666666666666" +NEW_SURFACE_ID = "77777777-7777-4777-8777-777777777777" + + +def make_executable(path: Path, content: str) -> None: + path.write_text(content, encoding="utf-8") + path.chmod(0o755) + + +def read_text(path: Path) -> str: + if not path.exists(): + return "" + return path.read_text(encoding="utf-8").strip() + + +class FakeCmuxState: + def __init__(self) -> None: + self.lock = threading.Lock() + self.requests: list[str] = [] + self.workspace = { + "id": INITIAL_WORKSPACE_ID, + "ref": "workspace:1", + "index": 1, + "title": "demo-team", + } + self.window = { + "id": INITIAL_WINDOW_ID, + "ref": "window:1", + } + self.current_pane_id = INITIAL_PANE_ID + self.current_surface_id = INITIAL_SURFACE_ID + self.panes = [ + { + "id": INITIAL_PANE_ID, + "ref": "pane:1", + "index": 7, + "surface_ids": [INITIAL_SURFACE_ID], + } + ] + self.surfaces = [ + { + "id": INITIAL_SURFACE_ID, + "ref": "surface:1", + "pane_id": INITIAL_PANE_ID, + "title": "leader", + } + ] + + def handle(self, method: str, params: dict[str, object]) -> dict[str, object]: + with self.lock: + self.requests.append(method) + if method == "system.identify": + return { + "socket_path": str(params.get("socket_path", "")), + "focused": { + "workspace_id": self.workspace["id"], + "workspace_ref": self.workspace["ref"], + "window_id": self.window["id"], + "window_ref": self.window["ref"], + "pane_id": self.current_pane_id, + "pane_ref": self._pane_ref(self.current_pane_id), + "surface_id": self.current_surface_id, + "surface_ref": self._surface_ref(self.current_surface_id), + "tab_id": INITIAL_TAB_ID, + "tab_ref": "tab:1", + "surface_type": "terminal", + "is_browser_surface": False, + }, + } + if method == "workspace.current": + return { + "workspace_id": self.workspace["id"], + "workspace_ref": self.workspace["ref"], + } + if method == "workspace.list": + return { + "workspaces": [ + { + "id": self.workspace["id"], + "ref": self.workspace["ref"], + "index": self.workspace["index"], + "title": self.workspace["title"], + } + ] + } + if method == "window.list": + return { + "windows": [ + { + "id": self.window["id"], + "ref": self.window["ref"], + "workspace_id": self.workspace["id"], + "workspace_ref": self.workspace["ref"], + } + ] + } + if method == "pane.list": + return { + "panes": [ + { + "id": pane["id"], + "ref": pane["ref"], + "index": pane["index"], + } + for pane in self.panes + ] + } + if method == "pane.surfaces": + pane_id = str(params.get("pane_id") or "") + pane = self._pane_by_id(pane_id) + return { + "surfaces": [ + { + "id": surface_id, + "selected": surface_id == self.current_surface_id, + } + for surface_id in pane["surface_ids"] + ] + } + if method == "surface.current": + return { + "workspace_id": self.workspace["id"], + "workspace_ref": self.workspace["ref"], + "pane_id": self.current_pane_id, + "pane_ref": self._pane_ref(self.current_pane_id), + "surface_id": self.current_surface_id, + "surface_ref": self._surface_ref(self.current_surface_id), + } + if method == "surface.list": + return { + "surfaces": [ + { + "id": surface["id"], + "ref": surface["ref"], + "title": surface["title"], + "pane_id": surface["pane_id"], + "pane_ref": self._pane_ref(surface["pane_id"]), + } + for surface in self.surfaces + ] + } + if method == "surface.split": + self.panes.append( + { + "id": NEW_PANE_ID, + "ref": "pane:2", + "index": 8, + "surface_ids": [NEW_SURFACE_ID], + } + ) + self.surfaces.append( + { + "id": NEW_SURFACE_ID, + "ref": "surface:2", + "pane_id": NEW_PANE_ID, + "title": "teammate", + } + ) + return { + "surface_id": NEW_SURFACE_ID, + "pane_id": NEW_PANE_ID, + } + if method == "surface.focus": + self.current_surface_id = str(params.get("surface_id") or self.current_surface_id) + surface = self._surface_by_id(self.current_surface_id) + self.current_pane_id = surface["pane_id"] + return {"ok": True} + if method == "pane.resize": + return {"ok": True} + if method == "surface.send_text": + return {"ok": True} + raise RuntimeError(f"Unsupported fake cmux method: {method}") + + def _pane_by_id(self, pane_id: str) -> dict[str, object]: + for pane in self.panes: + if pane["id"] == pane_id or pane["ref"] == pane_id: + return pane + raise RuntimeError(f"Unknown pane id: {pane_id}") + + def _surface_by_id(self, surface_id: str) -> dict[str, object]: + for surface in self.surfaces: + if surface["id"] == surface_id or surface["ref"] == surface_id: + return surface + raise RuntimeError(f"Unknown surface id: {surface_id}") + + def _pane_ref(self, pane_id: str) -> str: + return self._pane_by_id(pane_id)["ref"] # type: ignore[return-value] + + def _surface_ref(self, surface_id: str) -> str: + return self._surface_by_id(surface_id)["ref"] # type: ignore[return-value] + + +class FakeCmuxUnixServer(socketserver.ThreadingUnixStreamServer): + allow_reuse_address = True + + def __init__(self, socket_path: str, state: FakeCmuxState) -> None: + self.state = state + super().__init__(socket_path, FakeCmuxHandler) + + +class FakeCmuxHandler(socketserver.StreamRequestHandler): + def handle(self) -> None: + while True: + line = self.rfile.readline() + if not line: + return + request = json.loads(line.decode("utf-8")) + response = { + "ok": True, + "result": self.server.state.handle( # type: ignore[attr-defined] + request["method"], + request.get("params", {}), + ), + "id": request.get("id"), + } + self.wfile.write((json.dumps(response) + "\n").encode("utf-8")) + self.wfile.flush() + + +def main() -> int: + try: + cli_path = resolve_cmux_cli() + except Exception as exc: + print(f"FAIL: {exc}") + return 1 + + with tempfile.TemporaryDirectory(prefix="cmux-claude-teams-seq-") as td: + tmp = Path(td) + home = tmp / "home" + home.mkdir(parents=True, exist_ok=True) + + socket_path = tmp / "fake-cmux.sock" + state = FakeCmuxState() + server = FakeCmuxUnixServer(str(socket_path), state) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + real_bin = tmp / "real-bin" + real_bin.mkdir(parents=True, exist_ok=True) + + tmux_pane_log = tmp / "tmux-pane.log" + tmux_socket_log = tmp / "tmux-socket.log" + window_target_log = tmp / "window-target.log" + split_pane_log = tmp / "split-pane.log" + pane_list_log = tmp / "pane-list.log" + + make_executable( + real_bin / "claude", + """#!/usr/bin/env bash +set -euo pipefail +printf '%s\\n' "${TMUX_PANE-__UNSET__}" > "$FAKE_TMUX_PANE_LOG" +printf '%s\\n' "${CMUX_SOCKET_PATH-__UNSET__}" > "$FAKE_SOCKET_LOG" +window_target="$(tmux display-message -t "${TMUX_PANE}" -p '#{session_name}:#{window_index}')" +printf '%s\\n' "$window_target" > "$FAKE_WINDOW_TARGET_LOG" +split_pane="$(tmux split-window -t "${TMUX_PANE}" -h -l 70% -P -F '#{pane_id}')" +printf '%s\\n' "$split_pane" > "$FAKE_SPLIT_PANE_LOG" +tmux select-layout -t "$window_target" main-vertical +tmux resize-pane -t "${TMUX_PANE}" -x 30% +tmux list-panes -t "$window_target" -F '#{pane_id}' > "$FAKE_PANE_LIST_LOG" +""", + ) + + env = os.environ.copy() + env["HOME"] = str(home) + env["PATH"] = f"{real_bin}:/usr/bin:/bin" + env["CMUX_SOCKET_PATH"] = str(socket_path) + env["FAKE_TMUX_PANE_LOG"] = str(tmux_pane_log) + env["FAKE_SOCKET_LOG"] = str(tmux_socket_log) + env["FAKE_WINDOW_TARGET_LOG"] = str(window_target_log) + env["FAKE_SPLIT_PANE_LOG"] = str(split_pane_log) + env["FAKE_PANE_LIST_LOG"] = str(pane_list_log) + + try: + proc = subprocess.run( + [cli_path, "claude-teams", "--version"], + capture_output=True, + text=True, + check=False, + env=env, + timeout=30, + ) + except subprocess.TimeoutExpired as exc: + print("FAIL: `cmux claude-teams --version` timed out") + print(f"cmd={exc.cmd!r}") + return 1 + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + if proc.returncode != 0: + print("FAIL: `cmux claude-teams --version` exited non-zero") + print(f"exit={proc.returncode}") + print(f"stdout={proc.stdout.strip()}") + print(f"stderr={proc.stderr.strip()}") + return 1 + + tmux_pane = read_text(tmux_pane_log) + if tmux_pane != f"%{INITIAL_PANE_ID}": + print(f"FAIL: expected TMUX_PANE=%{INITIAL_PANE_ID}, got {tmux_pane!r}") + return 1 + + socket_value = read_text(tmux_socket_log) + if socket_value != str(socket_path): + print(f"FAIL: expected CMUX_SOCKET_PATH={socket_path}, got {socket_value!r}") + return 1 + + window_target = read_text(window_target_log) + if window_target != "cmux:1": + print(f"FAIL: expected tmux window target 'cmux:1', got {window_target!r}") + return 1 + + split_pane = read_text(split_pane_log) + if split_pane != f"%{NEW_PANE_ID}": + print(f"FAIL: expected split-window to print %{NEW_PANE_ID}, got {split_pane!r}") + return 1 + + pane_lines = pane_list_log.read_text(encoding="utf-8").splitlines() + expected_panes = [f"%{INITIAL_PANE_ID}", f"%{NEW_PANE_ID}"] + if pane_lines != expected_panes: + print(f"FAIL: expected list-panes output {expected_panes!r}, got {pane_lines!r}") + return 1 + + if state.current_pane_id != INITIAL_PANE_ID: + print( + "FAIL: expected split-window to keep the leader pane focused, " + f"got current pane {state.current_pane_id!r}" + ) + return 1 + + if "surface.send_text" in state.requests: + print("FAIL: split-window treated '-l 70%' like shell text and called surface.send_text") + print(f"requests={state.requests!r}") + return 1 + + print("PASS: cmux claude-teams supports Claude's tmux teammate flow") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())