From 7793d2946d1b64fa23f998dd28f57fbdcd7781f7 Mon Sep 17 00:00:00 2001 From: Ethan Lee Date: Sun, 5 Jul 2026 00:44:20 +0900 Subject: [PATCH 1/2] Add a control socket and bundled macterm CLI (read-only verbs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit External apps and AI agents need to drive Macterm programmatically (#107), and the CI benchmark needs to spawn realistic workloads headlessly. Darwin notifications (BenchmarkControl) carry no payload or response, so this adds a Unix-socket control plane: newline-delimited JSON requests dispatched on the main actor into the same AppState paths the UI uses, and a bundled ArgumentParser CLI at Contents/Resources/bin/macterm (PATH-injected into every pane along with MACTERM_SOCKET). This first slice ships the protocol, server, and read-only verbs — status, project/tab/pane list, session list/info. Mutations (create, select, split, run) and the benchmark workload follow in separate PRs. Design notes drawn from cmux/Zentty/Supacode source: socket-ownership check before connect, env var as discovery hint (never a hard pin, so a stale export can't brick shells after an app restart), non-blocking connect with a short poll, error responses carrying recovery hints, and a safe-fail CLI contract (stdout only on success; exit 2 when the app is unreachable). --- AGENTS.md | 12 + CLI/ControlClient.swift | 197 ++++++++++++ CLI/MactermCommand.swift | 160 ++++++++++ CLI/Output.swift | 111 +++++++ Macterm/App/MactermApp.swift | 23 ++ Macterm/Control/ControlHandler.swift | 295 ++++++++++++++++++ Macterm/Control/ControlProtocol.swift | 236 ++++++++++++++ Macterm/Control/ControlSocketServer.swift | 260 +++++++++++++++ .../Control/ControlHandlerTests.swift | 286 +++++++++++++++++ .../Control/ControlProtocolTests.swift | 77 +++++ .../Control/ControlSocketServerTests.swift | 169 ++++++++++ project.yml | 38 +++ 12 files changed, 1864 insertions(+) create mode 100644 CLI/ControlClient.swift create mode 100644 CLI/MactermCommand.swift create mode 100644 CLI/Output.swift create mode 100644 Macterm/Control/ControlHandler.swift create mode 100644 Macterm/Control/ControlProtocol.swift create mode 100644 Macterm/Control/ControlSocketServer.swift create mode 100644 MactermTests/Control/ControlHandlerTests.swift create mode 100644 MactermTests/Control/ControlProtocolTests.swift create mode 100644 MactermTests/Control/ControlSocketServerTests.swift diff --git a/AGENTS.md b/AGENTS.md index 2381ccf..5681ae2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,6 +83,18 @@ A tab's auto-title is, by default, the pane's live **foreground process name** ( - `Macterm/System/ProcessInspector.swift` — resolves a pane's foreground pid three ways: `runningCommand` (full argv via `KERN_PROCARGS2` → layout `run:`), `runningShell` (non-default shell's `exec_path` → layout `shell:`), `runningProcessName` (kernel `comm` → tab name). - `Macterm/Config/` — `MactermConfig` (wrapper ghostty config files), `ShellIntegrationFeatures` (override merger, #75). - `Macterm/Settings/SettingsView.swift` — preferences window. +- `Macterm/Control/` — control-socket plumbing for the bundled `macterm` CLI: `ControlProtocol` (wire types, shared source with the CLI target), `ControlSocketServer`, `ControlHandler`. +- `CLI/` — the `MactermCLI` target (`macterm` binary): ArgumentParser command tree, `ControlClient` (socket discovery + one-shot request), `Output` (human/`--json` renderers). + +### Control CLI (`macterm`) + +A bundled CLI (issue #107) controls the running app over a Unix socket at `/control.sock` (per build flavor; `MACTERM_BENCHMARK_DATA_DIR` relocates it with the rest of app data). Built as the `MactermCLI` tool target — `PRODUCT_NAME` is `macterm` but `PRODUCT_MODULE_NAME` must stay `MactermCLI` (a `macterm` module would collide case-insensitively with the app's `Macterm.swiftmodule` in the shared products dir). A post-build script copies it to `Contents/Resources/bin/macterm`; the app prepends that dir to `PATH` and exports `MACTERM_SOCKET` before any shell spawns, so `macterm` works inside every pane. + +- **Wire protocol** (`ControlProtocol.swift`, compiled into both targets so the codec can't drift): one request per connection; newline-terminated JSON both ways; client half-closes after sending. Requests are `{v, id, command, args}` with `command` namespaced `noun.verb` (`pane.list`); responses echo `id` with `{ok, data}` or `{ok:false, error:{code, message, action?}}` — `action` is a human recovery hint. Errors use snake_case codes (`not_found`, `busy`, `no_surface`, `starting`…). +- **Server** (`ControlSocketServer`): dedicated thread polling a non-blocking listen fd (20ms) — never a blocking `accept()` on a queue shared with `stop()`, which would starve shutdown. Per-connection dispatch hops to `@MainActor`. Starts in `applicationDidFinishLaunching` (skipped under xctest); the handler attaches later in `installResponders` when AppState exists — early requests get a `starting` error. `FD_CLOEXEC` on the listen fd so spawned shells can't hold the socket past quit. +- **Handler** (`ControlHandler`): translate-and-delegate only — resolves selectors (name/UUID/1-based index for projects and tabs; conflicts and dupes are typed `ambiguous` errors) and calls the same `AppState`/`ProjectStore`/`ZmxClient` methods the UI uses. Reads zmx through `appState.zmx` so tests stub one seam. +- **CLI client**: discovery order `--socket` (hard pin) → `MACTERM_SOCKET` (hint only — falls through to the well-known release/debug paths when stale, avoiding cmux's pinned-env trap) → per-flavor App Support paths. Refuses sockets not owned by the current user; non-blocking connect with a 250ms poll so a dead socket never hangs. Safe-fail contract: stdout only on success; exit 0 ok / 1 app error / 2 unreachable. +- Security posture: same-user only, enforced by filesystem permissions (0600 socket, 0700 dir) — the same boundary zmx itself uses. No token auth by design; the request envelope leaves room for an `auth` field if that ever changes. ### Ghostty Config Pipeline diff --git a/CLI/ControlClient.swift b/CLI/ControlClient.swift new file mode 100644 index 0000000..0bcb351 --- /dev/null +++ b/CLI/ControlClient.swift @@ -0,0 +1,197 @@ +import Foundation + +/// CLI-side of the control socket: discovery, connection, one +/// request/response round-trip. Synchronous by design — the CLI has nothing +/// else to do while it waits. +struct ControlClient { + /// Explicit `--socket` value: a hard pin, tried alone. + var socketOverride: String? + + struct ClientError: Error, CustomStringConvertible { + var description: String + /// True when no socket answered at all (app not running) — exit 2. + var isConnectionFailure: Bool + } + + /// Send one request and return the decoded response. + func send(command: String, args: ControlArgs? = nil) throws -> ControlResponse { + let request = ControlRequest(command: command, args: args) + let payload = try ControlProtocol.encode(request) + + var attempts: [String] = [] + for path in candidatePaths() { + switch tryPath(path, payload: payload) { + case let .success(raw): + let response: ControlResponse + do { + response = try ControlProtocol.decodeResponse(raw) + } catch { + throw ClientError( + description: "undecodable response from \(path): \(error.localizedDescription)", + isConnectionFailure: false + ) + } + guard response.id == request.id else { + throw ClientError( + description: "response id mismatch from \(path)", + isConnectionFailure: false + ) + } + return response + case let .failure(reason): + attempts.append(" \(path): \(reason)") + } + } + let hint = socketOverride == nil + ? "\nIs Macterm running? (launch it, or pass --socket for a non-default location)" + : "" + throw ClientError( + description: "could not reach Macterm's control socket:\n" + attempts.joined(separator: "\n") + hint, + isConnectionFailure: true + ) + } + + /// Discovery order: explicit `--socket` is a hard pin (tried alone); + /// otherwise the `MACTERM_SOCKET` env hint first — but only as a *hint*: + /// a stale exported path (app restarted since this shell spawned) falls + /// through to the well-known per-flavor locations instead of bricking + /// the CLI in that shell (cmux's documented sleep/wake trap). + func candidatePaths(environment: [String: String] = ProcessInfo.processInfo.environment) -> [String] { + if let override = socketOverride, !override.isEmpty { + return [override] + } + var paths: [String] = [] + if let hinted = environment[ControlProtocol.socketEnvVar], !hinted.isEmpty { + paths.append(hinted) + } + let appSupport = FileManager.default + .urls(for: .applicationSupportDirectory, in: .userDomainMask) + .first ?? URL(fileURLWithPath: NSHomeDirectory() + "/Library/Application Support") + for flavor in ["Macterm", "Macterm Debug"] { + paths.append( + appSupport + .appendingPathComponent(flavor, isDirectory: true) + .appendingPathComponent(ControlProtocol.socketFilename) + .path + ) + } + var seen = Set() + return paths.filter { seen.insert($0).inserted } + } + + // MARK: - One connection attempt + + private enum Attempt { + case success(Data) + case failure(String) + } + + private func tryPath(_ path: String, payload: Data) -> Attempt { + var st = stat() + guard stat(path, &st) == 0 else { + return .failure("no socket file") + } + // Refuse sockets owned by another user — a planted socket at a + // predictable path must not receive our commands. + guard st.st_uid == getuid() else { + return .failure("not owned by the current user; refusing to connect") + } + + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + return .failure("socket(): \(String(cString: strerror(errno)))") + } + defer { close(fd) } + var one: Int32 = 1 + setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &one, socklen_t(MemoryLayout.size)) + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let pathBytes = path.utf8CString + let maxLen = MemoryLayout.size(ofValue: addr.sun_path) + guard pathBytes.count <= maxLen else { + return .failure("path exceeds sun_path limit") + } + withUnsafeMutablePointer(to: &addr.sun_path) { dst in + dst.withMemoryRebound(to: CChar.self, capacity: maxLen) { dstPtr in + pathBytes.withUnsafeBufferPointer { src in + guard let srcBase = src.baseAddress else { return } + dstPtr.update(from: srcBase, count: src.count) + } + } + } + + // Non-blocking connect + short poll: a dead socket file (app crashed, + // listener gone) fails in ~250ms instead of hanging the CLI. + let flags = fcntl(fd, F_GETFL, 0) + _ = fcntl(fd, F_SETFL, flags | O_NONBLOCK) + let connectResult = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in + connect(fd, sockPtr, socklen_t(MemoryLayout.size)) + } + } + if connectResult != 0 { + guard errno == EINPROGRESS else { + return .failure(String(cString: strerror(errno))) + } + var pollFD = pollfd(fd: fd, events: Int16(POLLOUT), revents: 0) + guard poll(&pollFD, 1, 250) > 0, pollFD.revents & Int16(POLLERR | POLLHUP) == 0 else { + return .failure("connect timed out") + } + var soError: Int32 = 0 + var len = socklen_t(MemoryLayout.size) + getsockopt(fd, SOL_SOCKET, SO_ERROR, &soError, &len) + guard soError == 0 else { + return .failure(String(cString: strerror(soError))) + } + } + // Back to blocking for the write/read; bound the read instead. + _ = fcntl(fd, F_SETFL, flags) + var timeout = timeval(tv_sec: 10, tv_usec: 0) + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) + + guard writeAll(fd: fd, data: payload) else { + return .failure("write failed: \(String(cString: strerror(errno)))") + } + // Half-close: tells the server the request is complete. + shutdown(fd, SHUT_WR) + + var data = Data() + var buffer = [UInt8](repeating: 0, count: 16384) + while true { + let n = read(fd, &buffer, buffer.count) + if n > 0 { + data.append(contentsOf: buffer[0 ..< n]) + } else if n == 0 { + break + } else { + if errno == EINTR { continue } + if errno == EAGAIN || errno == EWOULDBLOCK { + return .failure("timed out waiting for a response") + } + return .failure("read failed: \(String(cString: strerror(errno)))") + } + } + guard !data.isEmpty else { + return .failure("connection closed without a response") + } + return .success(data) + } + + private func writeAll(fd: Int32, data: Data) -> Bool { + data.withUnsafeBytes { raw -> Bool in + guard let base = raw.bindMemory(to: UInt8.self).baseAddress else { return false } + var offset = 0 + while offset < data.count { + let n = write(fd, base + offset, data.count - offset) + if n > 0 { + offset += n + } else { + if errno == EINTR { continue } + return false + } + } + return true + } + } +} diff --git a/CLI/MactermCommand.swift b/CLI/MactermCommand.swift new file mode 100644 index 0000000..3240f82 --- /dev/null +++ b/CLI/MactermCommand.swift @@ -0,0 +1,160 @@ +import ArgumentParser +import Foundation + +/// `macterm` — control a running Macterm app from the shell. +/// +/// Talks to the app over its Unix control socket (see `ControlProtocol`). +/// Exit codes: 0 success, 1 the app returned an error, 2 the app couldn't +/// be reached. stdout carries only successful output. +@main +struct MactermCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "macterm", + abstract: "Control a running Macterm: projects, tabs, panes, zmx sessions.", + subcommands: [Status.self, ProjectCommand.self, TabCommand.self, PaneCommand.self, SessionCommand.self] + ) +} + +/// Options every subcommand shares. +struct ConnectionOptions: ParsableArguments { + @Option(help: "Control socket path (overrides discovery).") + var socket: String? + + @Flag(help: "Print the raw JSON payload instead of a table.") + var json = false +} + +/// Send a request, apply the safe-fail contract, render the result. +func runControlCommand(command: String, args: ControlArgs? = nil, options: ConnectionOptions) throws { + let client = ControlClient(socketOverride: options.socket) + let response: ControlResponse + do { + response = try client.send(command: command, args: args) + } catch let error as ControlClient.ClientError { + Output.printError(error.description) + throw ExitCode(error.isConnectionFailure ? 2 : 1) + } + if response.ok { + try Output.render(response.data, asJSON: options.json) + return + } + let error = response.error ?? ControlError(code: .internalError, message: "unknown error") + Output.printError(error.message, action: error.action) + throw ExitCode(1) +} + +// MARK: - Subcommands + +struct Status: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Show whether Macterm is running and which project is active." + ) + + @OptionGroup var options: ConnectionOptions + + func run() throws { + try runControlCommand(command: "status", options: options) + } +} + +struct ProjectCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "project", + abstract: "List and inspect projects.", + subcommands: [List.self], + defaultSubcommand: List.self + ) + + struct List: ParsableCommand { + static let configuration = CommandConfiguration(abstract: "List all projects.") + + @OptionGroup var options: ConnectionOptions + + func run() throws { + try runControlCommand(command: "project.list", options: options) + } + } +} + +struct TabCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "tab", + abstract: "List and inspect tabs.", + subcommands: [List.self], + defaultSubcommand: List.self + ) + + struct List: ParsableCommand { + static let configuration = CommandConfiguration(abstract: "List tabs (active project by default).") + + @Option(help: "Project to list (name, UUID, or index). Defaults to the active project.") + var project: String? + + @OptionGroup var options: ConnectionOptions + + func run() throws { + try runControlCommand(command: "tab.list", args: ControlArgs(project: project), options: options) + } + } +} + +struct PaneCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "pane", + abstract: "List and inspect panes.", + subcommands: [List.self], + defaultSubcommand: List.self + ) + + struct List: ParsableCommand { + static let configuration = CommandConfiguration(abstract: "List panes (active project by default).") + + @Option(help: "Project to list (name, UUID, or index). Defaults to the active project.") + var project: String? + + @Option(help: "Restrict to one tab (title, UUID, or index).") + var tab: String? + + @OptionGroup var options: ConnectionOptions + + func run() throws { + try runControlCommand( + command: "pane.list", + args: ControlArgs(project: project, tab: tab), + options: options + ) + } + } +} + +struct SessionCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "session", + abstract: "Inspect zmx-backed terminal sessions.", + subcommands: [List.self, Info.self], + defaultSubcommand: List.self + ) + + struct List: ParsableCommand { + static let configuration = CommandConfiguration(abstract: "List live zmx sessions.") + + @OptionGroup var options: ConnectionOptions + + func run() throws { + try runControlCommand(command: "session.list", options: options) + } + } + + struct Info: ParsableCommand { + static let configuration = CommandConfiguration(abstract: "Show one zmx session.") + + @Argument(help: "Session name (macterm--).") + var name: String + + @OptionGroup var options: ConnectionOptions + + func run() throws { + try runControlCommand(command: "session.info", args: ControlArgs(session: name), options: options) + } + } +} diff --git a/CLI/Output.swift b/CLI/Output.swift new file mode 100644 index 0000000..a4b9493 --- /dev/null +++ b/CLI/Output.swift @@ -0,0 +1,111 @@ +import Foundation + +/// Human and JSON renderers for control responses. The safe-fail contract: +/// stdout carries ONLY successful command output; every diagnostic goes to +/// stderr so scripted callers can trust what they capture. +enum Output { + /// Render a successful response: `--json` prints the raw `data` payload; + /// otherwise the human formatter for the fields present. + static func render(_ data: ControlData?, asJSON: Bool) throws { + if asJSON { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let payload = try encoder.encode(data ?? ControlData()) + print(String(decoding: payload, as: UTF8.self)) + return + } + guard let data else { return } + if let status = data.status { renderStatus(status) } + if let projects = data.projects { renderProjects(projects) } + if let tabs = data.tabs { renderTabs(tabs) } + if let panes = data.panes { renderPanes(panes) } + if let sessions = data.sessions { renderSessions(sessions) } + } + + private static func renderStatus(_ status: ControlStatusInfo) { + var line = "Macterm \(status.version) (pid \(status.pid))" + if let project = status.activeProject { + line += " — active project: \(project)" + } + print(line) + } + + private static func renderProjects(_ projects: [ControlProjectInfo]) { + let rows = projects.enumerated().map { index, project -> [String] in + let tabs = project.tabCount.map { "\($0) tab\($0 == 1 ? "" : "s")" } ?? "—" + return [ + "project:\(index + 1)", + project.active ? "*" : " ", + project.name, + project.loaded ? tabs : "not loaded", + project.path, + ] + } + printColumns(rows) + } + + private static func renderTabs(_ tabs: [ControlTabInfo]) { + let rows = tabs.map { tab -> [String] in + [ + "tab:\(tab.index)", + tab.active ? "*" : " ", + tab.title, + "\(tab.paneCount) pane\(tab.paneCount == 1 ? "" : "s")", + ] + } + printColumns(rows) + } + + private static func renderPanes(_ panes: [ControlPaneInfo]) { + let rows = panes.map { pane -> [String] in + [ + "tab:\(pane.tabIndex)", + "pane:\(pane.index)", + pane.focused ? "*" : " ", + pane.session, + pane.process ?? "-", + pane.cwd ?? "-", + ] + } + printColumns(rows) + } + + private static func renderSessions(_ sessions: [ControlSessionInfo]) { + let rows = sessions.map { session -> [String] in + let clients = session.clients.map(String.init) ?? "?" + return [ + session.name, + "clients:\(clients)", + session.leaderPID.map { "pid:\($0)" } ?? "pid:-", + session.paneID != nil ? "attached-pane" : "orphan", + ] + } + printColumns(rows) + } + + /// Left-align columns to the widest cell in each. + private static func printColumns(_ rows: [[String]]) { + guard !rows.isEmpty else { return } + let columnCount = rows.map(\.count).max() ?? 0 + var widths = [Int](repeating: 0, count: columnCount) + for row in rows { + for (i, cell) in row.enumerated() { + widths[i] = max(widths[i], cell.count) + } + } + for row in rows { + let line = row.enumerated() + .map { i, cell in i == row.count - 1 ? cell : cell.padding(toLength: widths[i], withPad: " ", startingAt: 0) } + .joined(separator: " ") + print(line) + } + } + + /// Print an error to stderr, with its recovery hint when present. + static func printError(_ message: String, action: String? = nil) { + FileHandle.standardError.write(Data("macterm: \(message)\n".utf8)) + if let action { + FileHandle.standardError.write(Data(" hint: \(action)\n".utf8)) + } + } +} diff --git a/Macterm/App/MactermApp.swift b/Macterm/App/MactermApp.swift index d70729e..8a526c1 100644 --- a/Macterm/App/MactermApp.swift +++ b/Macterm/App/MactermApp.swift @@ -242,6 +242,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var appFocusObservers: [Any] = [] private var mainAppResponder: MainAppResponder? private var hasInstalledResponders = false + private var controlServer: ControlSocketServer? + private var controlHandler: ControlHandler? func applicationDidFinishLaunching(_: Notification) { // Skip the heavy launch path when the app is hosting unit tests. @@ -255,6 +257,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Before anything can spawn a surface: a launcher terminal's zmx // session marker must not leak into our panes (see ZmxEnvironment). ZmxEnvironment.scrubInheritedSession() + // Control socket for the bundled `macterm` CLI. Started before any + // surface spawns so every shell inherits MACTERM_SOCKET and the + // bundled CLI on PATH (setenv mutates this process's environ, which + // libghostty passes to spawned shells). Requests get a `starting` + // error until installResponders attaches the handler. + let controlServer = ControlSocketServer(socketPath: ControlSocketServer.defaultSocketPath()) + controlServer.start() + self.controlServer = controlServer + setenv(ControlProtocol.socketEnvVar, controlServer.path, 1) + if let binDir = Bundle.main.resourceURL?.appendingPathComponent("bin", isDirectory: true).path, + FileManager.default.isExecutableFile(atPath: binDir + "/macterm") + { + let existingPath = ProcessInfo.processInfo.environment["PATH"] ?? "" + setenv("PATH", existingPath.isEmpty ? binDir : "\(binDir):\(existingPath)", 1) + } UNUserNotificationCenter.current().delegate = NotificationHandler.shared if BenchmarkControl.isEnabled { // Under the CI benchmark, the notification-permission alert would @@ -329,6 +346,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { if BenchmarkControl.isEnabled { BenchmarkControl.connect(appState: appState, projectStore: projectStore) } + if let controlServer { + let handler = ControlHandler(appState: appState, projectStore: projectStore) + controlHandler = handler + controlServer.attach { raw in await handler.handle(raw) } + } KeyRouter.shared.register(PaletteResponder(appState: appState)) KeyRouter.shared.register(QuickTerminalResponder()) let mainResponder = MainAppResponder(appState: appState, projectStore: projectStore) @@ -348,6 +370,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } func applicationWillTerminate(_: Notification) { + controlServer?.stop() onTerminate?() // Quit is a DETACH by default: workspace panes' sessions survive and // reattach on relaunch (the snapshot saved by onTerminate carries each diff --git a/Macterm/Control/ControlHandler.swift b/Macterm/Control/ControlHandler.swift new file mode 100644 index 0000000..acd427c --- /dev/null +++ b/Macterm/Control/ControlHandler.swift @@ -0,0 +1,295 @@ +import Foundation +import os + +private let logger = Logger(subsystem: appBundleID, category: "ControlHandler") + +/// Dispatches decoded control-socket requests into app state. Pure +/// translate-and-delegate: every operation calls the same `AppState` / +/// `ProjectStore` / `ZmxClient` methods the UI uses — no business logic of +/// its own. Injectable stores make it fully testable with the tempdir +/// pattern used by `AppStateTests`. +@MainActor +final class ControlHandler { + private let appState: AppState + private let projectStore: ProjectStore + /// Follows `appState.zmx` so tests that stub the client (the established + /// AppStateTests pattern) drive this handler too. + private var zmx: ZmxClient { appState.zmx } + + init(appState: AppState, projectStore: ProjectStore) { + self.appState = appState + self.projectStore = projectStore + } + + /// Data-level entry point for `ControlSocketServer`: decode, dispatch, + /// encode. Never throws — every failure becomes an error response. + func handle(_ raw: Data) async -> Data { + let request: ControlRequest + do { + request = try ControlProtocol.decodeRequest(raw) + } catch { + return ControlProtocol.encode(.failure( + id: "", + error: ControlError(code: .badRequest, message: "undecodable request: \(error.localizedDescription)") + )) + } + let response = await handle(request) + return ControlProtocol.encode(response) + } + + func handle(_ request: ControlRequest) async -> ControlResponse { + logger.debug("control request: \(request.command, privacy: .public)") + do { + let data = try await dispatch(request) + return .success(id: request.id, data: data) + } catch let error as ControlError { + return .failure(id: request.id, error: error) + } catch { + return .failure( + id: request.id, + error: ControlError(code: .internalError, message: error.localizedDescription) + ) + } + } + + private func dispatch(_ request: ControlRequest) async throws -> ControlData { + let args = request.args ?? ControlArgs() + switch request.command { + case "status": return status() + case "project.list": return projectList() + case "tab.list": return try tabList(args) + case "pane.list": return try paneList(args) + case "session.list": return try await sessionList() + case "session.info": return try await sessionInfo(args) + default: + throw ControlError( + code: .unknownCommand, + message: "unknown command \"\(request.command)\"", + action: "run `macterm --help` for the supported commands" + ) + } + } + + // MARK: - Queries + + private func status() -> ControlData { + let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0" + let active = projectStore.projects.first { $0.id == appState.activeProjectID } + return ControlData(status: ControlStatusInfo( + version: version, + pid: getpid(), + activeProject: active?.name, + activeProjectID: active?.id.uuidString + )) + } + + private func projectList() -> ControlData { + let infos = projectStore.projects.map { project in + ControlProjectInfo( + id: project.id.uuidString, + name: project.name, + path: project.path, + active: project.id == appState.activeProjectID, + // "Loaded" = a workspace exists (tabs/panes addressable over + // this protocol) — NOT `AppState.isProjectLoaded`, which asks + // whether live terminal *surfaces* exist and is false for a + // restored-but-never-shown project. + loaded: appState.workspaces[project.id] != nil, + tabCount: appState.workspaces[project.id]?.tabs.count + ) + } + return ControlData(projects: infos) + } + + private func tabList(_ args: ControlArgs) throws -> ControlData { + let (_, workspace) = try resolveWorkspace(args) + return ControlData(tabs: tabInfos(in: workspace)) + } + + private func paneList(_ args: ControlArgs) throws -> ControlData { + let (_, workspace) = try resolveWorkspace(args) + let tabs: [(Int, TerminalTab)] + if args.tab != nil { + let (index, tab) = try resolveTab(args, in: workspace) + tabs = [(index, tab)] + } else { + tabs = Array(zip(1..., workspace.tabs)) + } + var infos: [ControlPaneInfo] = [] + for (tabIndex, tab) in tabs { + for (paneIndex, pane) in zip(1..., tab.splitRoot.allPanes()) { + infos.append(ControlPaneInfo( + index: paneIndex, + id: pane.id.uuidString, + session: pane.sessionName, + tabIndex: tabIndex, + tabID: tab.id.uuidString, + title: pane.displayTitle, + process: pane.foregroundProcessName, + cwd: pane.nsView?.currentPwd ?? pane.projectPath, + focused: tab.id == workspace.activeTabID && pane.id == tab.focusedPaneID + )) + } + } + return ControlData(panes: infos) + } + + private func sessionList() async throws -> ControlData { + guard let entries = await zmx.listSessionsWithClients() else { + throw ControlError( + code: .internalError, + message: "zmx session listing unavailable", + action: "check Settings → session persistence for details" + ) + } + let leaders = await zmx.sessionLeaderPIDs() + let paneBySession = paneIDsBySessionName() + let infos = entries.map { entry in + ControlSessionInfo( + name: entry.name, + clients: entry.clients, + leaderPID: leaders[entry.name], + paneID: paneBySession[entry.name] + ) + } + return ControlData(sessions: infos) + } + + private func sessionInfo(_ args: ControlArgs) async throws -> ControlData { + guard let name = args.session, !name.isEmpty else { + throw ControlError(code: .badRequest, message: "session.info requires a session name") + } + let data = try await sessionList() + guard let match = data.sessions?.first(where: { $0.name == name }) else { + throw ControlError( + code: .notFound, + message: "no zmx session named \"\(name)\"", + action: "run `macterm session list` to see live sessions" + ) + } + return ControlData(sessions: [match]) + } + + // MARK: - Selector resolution + + /// Resolve the project selector (name, UUID, or 1-based list index) to a + /// project with a live workspace; defaults to the active project. + private func resolveWorkspace(_ args: ControlArgs) throws -> (Project, Workspace) { + let project = try resolveProject(args.project) + guard let workspace = appState.workspaces[project.id] else { + throw ControlError( + code: .notFound, + message: "project \"\(project.name)\" has no loaded workspace", + action: "select it first: `macterm project select \(project.name)`" + ) + } + return (project, workspace) + } + + private func resolveProject(_ selector: String?) throws -> Project { + guard let selector, !selector.isEmpty else { + guard let active = projectStore.projects.first(where: { $0.id == appState.activeProjectID }) else { + throw ControlError( + code: .notFound, + message: "no active project", + action: "pass --project or select one in the app" + ) + } + return active + } + let projects = projectStore.projects + if let id = UUID(uuidString: selector), let match = projects.first(where: { $0.id == id }) { + return match + } + if let index = parseIndex(selector, prefix: "project"), projects.indices.contains(index - 1) { + return projects[index - 1] + } + let byName = projects.filter { $0.name == selector } + switch byName.count { + case 1: return byName[0] + case 0: + throw ControlError( + code: .notFound, + message: "no project matches \"\(selector)\"", + action: "run `macterm project list`" + ) + default: + throw ControlError( + code: .ambiguous, + message: "\(byName.count) projects are named \"\(selector)\"", + action: "target by UUID from `macterm project list --json`" + ) + } + } + + /// Resolve the tab selector (1-based index, `tab:N` ref, UUID, or exact + /// title) within a workspace. + private func resolveTab(_ args: ControlArgs, in workspace: Workspace) throws -> (Int, TerminalTab) { + guard let selector = args.tab, !selector.isEmpty else { + guard let active = workspace.activeTab, + let index = workspace.tabs.firstIndex(where: { $0.id == active.id }) + else { + throw ControlError(code: .notFound, message: "the workspace has no active tab") + } + return (index + 1, active) + } + if let id = UUID(uuidString: selector), + let index = workspace.tabs.firstIndex(where: { $0.id == id }) + { + return (index + 1, workspace.tabs[index]) + } + if let index = parseIndex(selector, prefix: "tab"), workspace.tabs.indices.contains(index - 1) { + return (index, workspace.tabs[index - 1]) + } + let byTitle = workspace.tabs.enumerated().filter { $0.element.sidebarTitle == selector } + switch byTitle.count { + case 1: return (byTitle[0].offset + 1, byTitle[0].element) + case 0: + throw ControlError( + code: .notFound, + message: "no tab matches \"\(selector)\"", + action: "run `macterm tab list`" + ) + default: + throw ControlError( + code: .ambiguous, + message: "\(byTitle.count) tabs are titled \"\(selector)\"", + action: "target by index or UUID from `macterm tab list --json`" + ) + } + } + + /// Accepts `3` or `prefix:3` (the ref form the CLI renders). + private func parseIndex(_ selector: String, prefix: String) -> Int? { + var text = Substring(selector) + if text.hasPrefix("\(prefix):") { text = text.dropFirst(prefix.count + 1) } + guard let value = Int(text), value >= 1 else { return nil } + return value + } + + // MARK: - Shared projections + + private func tabInfos(in workspace: Workspace) -> [ControlTabInfo] { + zip(1..., workspace.tabs).map { index, tab in + ControlTabInfo( + index: index, + id: tab.id.uuidString, + title: tab.sidebarTitle, + active: tab.id == workspace.activeTabID, + paneCount: tab.splitRoot.allPanes().count + ) + } + } + + private func paneIDsBySessionName() -> [String: String] { + var map: [String: String] = [:] + for workspace in appState.workspaces.values { + for tab in workspace.tabs { + for pane in tab.splitRoot.allPanes() { + map[pane.sessionName] = pane.id.uuidString + } + } + } + return map + } +} diff --git a/Macterm/Control/ControlProtocol.swift b/Macterm/Control/ControlProtocol.swift new file mode 100644 index 0000000..6f19486 --- /dev/null +++ b/Macterm/Control/ControlProtocol.swift @@ -0,0 +1,236 @@ +import Foundation + +/// Wire types for the Macterm control socket — the IPC contract between the +/// running app (`ControlSocketServer` + `ControlHandler`) and the bundled +/// `macterm` CLI. This file is compiled into BOTH targets (app and CLI) so +/// the codec can never drift; it must stay free of app-only dependencies +/// (AppKit, FileStorage, AppState). +/// +/// Framing: one request per connection. The client writes a single +/// newline-terminated JSON `ControlRequest` line and half-closes its write +/// end; the server replies with a single newline-terminated JSON +/// `ControlResponse` line and closes. Newline-delimited JSON keeps the +/// protocol debuggable with `nc`/`socat` and leaves room for streaming later. +enum ControlProtocol { + /// Bumped only for breaking changes; additive fields are always safe + /// (both sides decode with optional fields). + static let version = 1 + + /// Socket file inside the app-support directory (per build flavor: + /// `Macterm/` vs `Macterm Debug/`). + static let socketFilename = "control.sock" + + /// Exported by the app into every spawned shell. A *hint*, not a pin: + /// clients fall back to the well-known per-flavor paths when the hinted + /// socket doesn't answer (a pinned stale path otherwise breaks every + /// shell spawned before an app restart). + static let socketEnvVar = "MACTERM_SOCKET" + + /// Injected per-pane so `macterm` invoked inside a pane can target the + /// pane it runs in. Session names are restart-stable (persisted verbatim + /// in the workspace snapshot), unlike pane UUIDs. + static let sessionEnvVar = "MACTERM_SESSION" +} + +// MARK: - Request + +struct ControlRequest: Codable { + var v: Int + /// Client-generated; echoed in the response. + var id: String + /// Namespaced verb, e.g. `status`, `project.list`, `pane.list`. + var command: String + var args: ControlArgs? + + init(command: String, args: ControlArgs? = nil) { + v = ControlProtocol.version + id = UUID().uuidString + self.command = command + self.args = args + } +} + +/// Flat bag of every argument any command accepts (all optional). A single +/// struct instead of per-command payloads keeps the codec trivial and makes +/// unknown/extra fields harmless across versions — the same shape Zentty's +/// battle-tested `AgentIPCRequest` uses. +struct ControlArgs: Codable, Equatable { + /// Project selector: name, UUID, or 1-based index as rendered by + /// `project list`. + var project: String? + /// Tab selector: title, UUID, or 1-based index (`tab:3` or `3`). + var tab: String? + /// Pane selector: UUID or 1-based index within its tab. + var pane: String? + /// zmx session name (`macterm--`) — the restart-stable pane + /// address. + var session: String? + + init( + project: String? = nil, + tab: String? = nil, + pane: String? = nil, + session: String? = nil + ) { + self.project = project + self.tab = tab + self.pane = pane + self.session = session + } +} + +// MARK: - Response + +struct ControlResponse: Codable { + var v: Int + var id: String + var ok: Bool + var data: ControlData? + var error: ControlError? + + static func success(id: String, data: ControlData? = nil) -> ControlResponse { + ControlResponse(v: ControlProtocol.version, id: id, ok: true, data: data, error: nil) + } + + static func failure(id: String, error: ControlError) -> ControlResponse { + ControlResponse(v: ControlProtocol.version, id: id, ok: false, data: nil, error: error) + } +} + +struct ControlError: Codable, Equatable, Error { + var code: ControlErrorCode + var message: String + /// Optional recovery hint shown to humans, e.g. "launch Macterm first". + var action: String? +} + +enum ControlErrorCode: String, Codable { + /// The socket is up but AppState hasn't attached yet (app mid-launch). + case starting + case unknownCommand = "unknown_command" + case badRequest = "bad_request" + case notFound = "not_found" + case ambiguous + /// The operation was staged for user confirmation instead of executing + /// (e.g. closing a busy tab without `--force`). + case busy + /// The target pane exists but its terminal surface hasn't been created. + case noSurface = "no_surface" + case internalError = "internal" +} + +/// Union-of-optionals result payload (one struct for every command, like +/// Zentty's `AgentIPCResponseResult`): each command populates exactly the +/// fields it owns, and old clients ignore fields they don't know. +struct ControlData: Codable { + var status: ControlStatusInfo? + var projects: [ControlProjectInfo]? + var tabs: [ControlTabInfo]? + var panes: [ControlPaneInfo]? + var sessions: [ControlSessionInfo]? + + init( + status: ControlStatusInfo? = nil, + projects: [ControlProjectInfo]? = nil, + tabs: [ControlTabInfo]? = nil, + panes: [ControlPaneInfo]? = nil, + sessions: [ControlSessionInfo]? = nil + ) { + self.status = status + self.projects = projects + self.tabs = tabs + self.panes = panes + self.sessions = sessions + } +} + +struct ControlStatusInfo: Codable, Equatable { + var version: String + var pid: Int32 + var activeProject: String? + var activeProjectID: String? +} + +struct ControlProjectInfo: Codable, Equatable { + var id: String + var name: String + var path: String + var active: Bool + /// Whether a live workspace exists for the project this launch. + var loaded: Bool + var tabCount: Int? +} + +struct ControlTabInfo: Codable, Equatable { + /// 1-based position in the sidebar, rendered as `tab:N`. + var index: Int + var id: String + var title: String + var active: Bool + var paneCount: Int +} + +struct ControlPaneInfo: Codable, Equatable { + /// 1-based position within its tab (split-tree order), rendered `pane:N`. + var index: Int + var id: String + /// zmx session name — the stable address for scripting. + var session: String + var tabIndex: Int + var tabID: String + var title: String + /// Live foreground process name, if the poll has resolved one. + var process: String? + var cwd: String? + var focused: Bool +} + +struct ControlSessionInfo: Codable, Equatable { + var name: String + /// Attached client count from `zmx ls`; nil when the daemon reported the + /// session in a state the parser couldn't count (err/status line). + var clients: Int? + /// Daemon leader pid, when resolved. + var leaderPID: Int32? + /// The live pane currently bound to this session, if any (a session with + /// no pane is an orphan awaiting reap or reattach). + var paneID: String? +} + +// MARK: - Codec + +extension ControlProtocol { + static func encode(_ request: ControlRequest) throws -> Data { + var data = try JSONEncoder().encode(request) + data.append(0x0A) + return data + } + + static func encode(_ response: ControlResponse) -> Data { + // A response that fails to encode is a programming error; fall back to + // a hand-built internal error so the client always gets valid JSON. + if var data = try? JSONEncoder().encode(response) { + data.append(0x0A) + return data + } + let fallback = #"{"v":1,"id":"","ok":false,"error":{"code":"internal","message":"response encoding failed"}}"# + return Data((fallback + "\n").utf8) + } + + static func decodeRequest(_ data: Data) throws -> ControlRequest { + try JSONDecoder().decode(ControlRequest.self, from: trimmed(data)) + } + + static func decodeResponse(_ data: Data) throws -> ControlResponse { + try JSONDecoder().decode(ControlResponse.self, from: trimmed(data)) + } + + /// Strip the trailing newline (and any stray whitespace) before decoding. + private static func trimmed(_ data: Data) -> Data { + var slice = data[...] + while let last = slice.last, last == 0x0A || last == 0x0D || last == 0x20 { + slice = slice.dropLast() + } + return Data(slice) + } +} diff --git a/Macterm/Control/ControlSocketServer.swift b/Macterm/Control/ControlSocketServer.swift new file mode 100644 index 0000000..078086a --- /dev/null +++ b/Macterm/Control/ControlSocketServer.swift @@ -0,0 +1,260 @@ +import Foundation +import os + +private let logger = Logger(subsystem: appBundleID, category: "ControlSocketServer") + +/// App-side listener for the `macterm` control CLI. Accepts one request per +/// connection on a Unix-domain socket, reads a newline-terminated JSON line +/// (the client half-closes after sending), and hands it to the attached +/// `@MainActor` handler, whose JSON response is written back before close. +/// +/// The accept loop runs on its own thread against a NON-BLOCKING listen +/// socket polled every 20ms — never a blocking `accept()` on a queue shared +/// with `stop()`, which would starve shutdown behind the block. Connection +/// handling hops to the main actor via a Task that owns the fd from then on, +/// so a slow handler never stalls the accept loop. +/// +/// Started at app launch, before AppState exists; requests arriving before +/// `attach(handler:)` get a `starting` error so a fast CLI poll (e.g. the +/// benchmark harness waiting for readiness) sees a well-formed response +/// instead of a hang. +final class ControlSocketServer: @unchecked Sendable { + typealias Handler = @MainActor @Sendable (Data) async -> Data + + private let socketPath: String + /// Guards `listenFD`, `isRunning`, and `handler` — touched by the control + /// methods (main thread) and the accept thread. + private let lock = NSLock() + private var listenFD: Int32 = -1 + private var isRunning = false + private var handler: Handler? + private var acceptThread: Thread? + + init(socketPath: String) { + self.socketPath = socketPath + } + + /// The path exported to spawned shells via `MACTERM_SOCKET`. + var path: String { socketPath } + + /// The per-flavor well-known socket location (`Macterm/` vs + /// `Macterm Debug/`, or the benchmark's `MACTERM_BENCHMARK_DATA_DIR`). + static func defaultSocketPath() -> String { + FileStorage.fileURL(filename: ControlProtocol.socketFilename).path + } + + func start() { + lock.lock() + defer { lock.unlock() } + guard !isRunning else { return } + guard bindAndListen() else { return } + isRunning = true + logger.info("control socket listening at \(self.socketPath, privacy: .public)") + let thread = Thread { [weak self] in self?.acceptLoop() } + thread.name = "com.thdxg.macterm.control-socket" + thread.stackSize = 512 * 1024 + acceptThread = thread + thread.start() + } + + /// Wire up request dispatch once AppState/ProjectStore exist (the server + /// itself starts earlier, at applicationDidFinishLaunching). + func attach(handler: @escaping Handler) { + lock.lock() + defer { lock.unlock() } + self.handler = handler + } + + func stop() { + lock.lock() + isRunning = false + if listenFD >= 0 { + close(listenFD) + listenFD = -1 + } + lock.unlock() + unlink(socketPath) + } + + private var running: Bool { + lock.lock() + defer { lock.unlock() } + return isRunning + } + + private var currentListenFD: Int32 { + lock.lock() + defer { lock.unlock() } + return listenFD + } + + private var currentHandler: Handler? { + lock.lock() + defer { lock.unlock() } + return handler + } + + // MARK: - Socket setup + + private func bindAndListen() -> Bool { + // Ensure the parent dir exists and any stale socket (crashed previous + // run) is gone before binding. + let dir = (socketPath as NSString).deletingLastPathComponent + try? FileManager.default.createDirectory( + atPath: dir, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700] + ) + unlink(socketPath) + + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + logger.error("socket() failed: \(String(cString: strerror(errno)), privacy: .public)") + return false + } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let pathBytes = socketPath.utf8CString + // sun_path is a fixed ~104-byte C array; refuse overlong paths rather + // than truncate (a truncated bind would listen somewhere else). + let maxLen = MemoryLayout.size(ofValue: addr.sun_path) + guard pathBytes.count <= maxLen else { + logger.error("socket path too long (\(pathBytes.count, privacy: .public) > \(maxLen, privacy: .public))") + close(fd) + return false + } + withUnsafeMutablePointer(to: &addr.sun_path) { dst in + dst.withMemoryRebound(to: CChar.self, capacity: maxLen) { dstPtr in + pathBytes.withUnsafeBufferPointer { src in + guard let srcBase = src.baseAddress else { return } + dstPtr.update(from: srcBase, count: src.count) + } + } + } + + let bindResult = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in + Darwin.bind(fd, sockPtr, socklen_t(MemoryLayout.size)) + } + } + guard bindResult == 0 else { + logger.error("bind() failed: \(String(cString: strerror(errno)), privacy: .public)") + close(fd) + return false + } + // Same-user only: filesystem permissions are the auth boundary. + chmod(socketPath, 0o600) + guard listen(fd, 16) == 0 else { + logger.error("listen() failed: \(String(cString: strerror(errno)), privacy: .public)") + close(fd) + return false + } + // Non-blocking listen socket: the accept loop polls and re-checks + // `running`, so `stop()` takes effect within one poll tick. + let flags = fcntl(fd, F_GETFL, 0) + _ = fcntl(fd, F_SETFL, flags | O_NONBLOCK) + // Shells spawned by the app must not inherit the listen fd — a + // long-lived child holding it would keep the socket alive past quit. + _ = fcntl(fd, F_SETFD, FD_CLOEXEC) + listenFD = fd + return true + } + + private func acceptLoop() { + while running { + let fd = currentListenFD + guard fd >= 0 else { break } + let clientFD = accept(fd, nil, nil) + if clientFD < 0 { + if errno == EINTR { continue } + if errno == EAGAIN || errno == EWOULDBLOCK { + usleep(20000) // 20ms; bounds stop() latency without busy-wait + continue + } + if running { + logger.error("accept() failed: \(String(cString: strerror(errno)), privacy: .public)") + } + break + } + _ = fcntl(clientFD, F_SETFD, FD_CLOEXEC) + // A disappeared client must surface as EPIPE on write, not a + // process-killing SIGPIPE. + var one: Int32 = 1 + setsockopt(clientFD, SOL_SOCKET, SO_NOSIGPIPE, &one, socklen_t(MemoryLayout.size)) + // Backstop read timeout — the client half-closes right after + // sending, so a stalled read means a broken client. + var timeout = timeval(tv_sec: 10, tv_usec: 0) + setsockopt(clientFD, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) + handleConnection(clientFD) + } + } + + // MARK: - Per-connection handling + + /// Read the request synchronously on the accept thread (fast — one small + /// line, then EOF), then hand the fd to a `@MainActor` task for dispatch + /// and response. The task owns the fd from then on. + private func handleConnection(_ fd: Int32) { + guard let raw = Self.readAll(fd: fd), !raw.isEmpty else { + Self.write(fd: fd, data: ControlProtocol.encode(.failure( + id: "", + error: ControlError(code: .badRequest, message: "empty or unreadable request") + ))) + close(fd) + return + } + guard let handler = currentHandler else { + // Echo the request id when it parses so the client can correlate. + let id = (try? ControlProtocol.decodeRequest(raw))?.id ?? "" + Self.write(fd: fd, data: ControlProtocol.encode(.failure( + id: id, + error: ControlError( + code: .starting, + message: "Macterm is still starting up", + action: "retry in a moment" + ) + ))) + close(fd) + return + } + Task { @MainActor in + let response = await handler(raw) + Self.write(fd: fd, data: response) + close(fd) + } + } + + // MARK: - Socket IO + + /// Read until EOF (the client half-closes its write end after sending). + private static func readAll(fd: Int32) -> Data? { + var data = Data() + var buffer = [UInt8](repeating: 0, count: 16384) + while true { + let n = read(fd, &buffer, buffer.count) + if n > 0 { + data.append(contentsOf: buffer[0 ..< n]) + } else if n == 0 { + return data + } else { + if errno == EINTR { continue } + return nil + } + } + } + + private static func write(fd: Int32, data: Data) { + data.withUnsafeBytes { raw in + guard let base = raw.bindMemory(to: UInt8.self).baseAddress else { return } + var offset = 0 + while offset < data.count { + let n = Darwin.write(fd, base + offset, data.count - offset) + if n > 0 { + offset += n + } else { + if errno == EINTR { continue } + break + } + } + } + } +} diff --git a/MactermTests/Control/ControlHandlerTests.swift b/MactermTests/Control/ControlHandlerTests.swift new file mode 100644 index 0000000..ee59649 --- /dev/null +++ b/MactermTests/Control/ControlHandlerTests.swift @@ -0,0 +1,286 @@ +import Foundation +@testable import Macterm +import Testing + +@MainActor +struct ControlHandlerTests { + // MARK: - Setup helpers (tempdir stores, mirroring AppStateTests) + + private func makeAppState() -> AppState { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("macterm-control-tests-\(UUID().uuidString).json") + let projectsDir = FileManager.default.temporaryDirectory + .appendingPathComponent("macterm-control-tests-projects-\(UUID().uuidString)", isDirectory: true) + return AppState( + workspaceStore: WorkspaceStore(fileURL: tmp), + projectFiles: ProjectFileStore(directoryURL: projectsDir) + ) + } + + private func makeHandler( + state: AppState? = nil, + store: ProjectStore? = nil + ) -> (ControlHandler, AppState, ProjectStore) { + let appState = state ?? makeAppState() + let projectStore = store ?? ProjectStore(fileURL: FileManager.default.temporaryDirectory + .appendingPathComponent("macterm-control-tests-store-\(UUID().uuidString).json")) + let handler = ControlHandler(appState: appState, projectStore: projectStore) + return (handler, appState, projectStore) + } + + private func seedProject( + _ appState: AppState, + _ projectStore: ProjectStore, + name: String = "demo", + path: String = "/tmp", + select: Bool = true + ) -> Project { + let project = Project(name: name, path: path, sortOrder: projectStore.projects.count) + projectStore.add(project) + if select { appState.selectProject(project) } + return project + } + + private func request(_ command: String, args: ControlArgs? = nil) -> ControlRequest { + ControlRequest(command: command, args: args) + } + + // MARK: - Envelope behavior + + @Test + func unknown_command_yields_typed_error_with_hint() async { + let (handler, _, _) = makeHandler() + let response = await handler.handle(request("frobnicate")) + #expect(!response.ok) + #expect(response.error?.code == .unknownCommand) + #expect(response.error?.action?.contains("--help") == true) + } + + @Test + func undecodable_data_yields_bad_request() async { + let (handler, _, _) = makeHandler() + let raw = await handler.handle(Data("not json\n".utf8)) + let response = try? ControlProtocol.decodeResponse(raw) + #expect(response?.ok == false) + #expect(response?.error?.code == .badRequest) + } + + @Test + func response_echoes_request_id() async { + let (handler, _, _) = makeHandler() + var req = request("status") + req.id = "custom-id-123" + let response = await handler.handle(req) + #expect(response.id == "custom-id-123") + } + + // MARK: - status + + @Test + func status_reports_pid_and_active_project() async { + let (handler, appState, projectStore) = makeHandler() + let project = seedProject(appState, projectStore, name: "alpha") + let response = await handler.handle(request("status")) + #expect(response.ok) + #expect(response.data?.status?.pid == getpid()) + #expect(response.data?.status?.activeProject == "alpha") + #expect(response.data?.status?.activeProjectID == project.id.uuidString) + } + + @Test + func status_with_no_active_project_omits_it() async { + let (handler, _, _) = makeHandler() + let response = await handler.handle(request("status")) + #expect(response.ok) + #expect(response.data?.status?.activeProject == nil) + } + + // MARK: - project.list + + @Test + func project_list_marks_active_and_loaded() async { + let (handler, appState, projectStore) = makeHandler() + let selected = seedProject(appState, projectStore, name: "one") + _ = seedProject(appState, projectStore, name: "two", select: false) + let response = await handler.handle(request("project.list")) + let projects = response.data?.projects + #expect(projects?.count == 2) + let one = projects?.first { $0.name == "one" } + let two = projects?.first { $0.name == "two" } + #expect(one?.active == true) + #expect(one?.loaded == true) + #expect(one?.id == selected.id.uuidString) + #expect(one?.tabCount == 1) + #expect(two?.active == false) + #expect(two?.loaded == false) + #expect(two?.tabCount == nil) + } + + // MARK: - tab.list + + @Test + func tab_list_defaults_to_active_project() async { + let (handler, appState, projectStore) = makeHandler() + let project = seedProject(appState, projectStore) + appState.createTab(projectID: project.id, projectPath: project.path) + let response = await handler.handle(request("tab.list")) + let tabs = response.data?.tabs + #expect(tabs?.count == 2) + #expect(tabs?.map(\.index) == [1, 2]) + // createTab selects the new tab. + #expect(tabs?.last?.active == true) + #expect(tabs?.allSatisfy { $0.paneCount == 1 } == true) + } + + @Test + func tab_list_resolves_project_by_name_index_and_uuid() async { + let (handler, appState, projectStore) = makeHandler() + let first = seedProject(appState, projectStore, name: "first") + let second = seedProject(appState, projectStore, name: "second") + appState.createTab(projectID: second.id, projectPath: second.path) + + for selector in ["first", "project:1", "1", first.id.uuidString] { + let response = await handler.handle(request("tab.list", args: ControlArgs(project: selector))) + #expect(response.data?.tabs?.count == 1, "selector \(selector)") + } + let response = await handler.handle(request("tab.list", args: ControlArgs(project: "second"))) + #expect(response.data?.tabs?.count == 2) + } + + @Test + func tab_list_unknown_project_is_not_found() async { + let (handler, appState, projectStore) = makeHandler() + _ = seedProject(appState, projectStore) + let response = await handler.handle(request("tab.list", args: ControlArgs(project: "ghost"))) + #expect(response.error?.code == .notFound) + } + + @Test + func tab_list_unloaded_project_is_not_found_with_hint() async { + let (handler, appState, projectStore) = makeHandler() + _ = seedProject(appState, projectStore, name: "loaded") + _ = seedProject(appState, projectStore, name: "cold", select: false) + let response = await handler.handle(request("tab.list", args: ControlArgs(project: "cold"))) + #expect(response.error?.code == .notFound) + #expect(response.error?.action?.contains("project select") == true) + } + + @Test + func no_active_project_is_not_found() async { + let (handler, _, _) = makeHandler() + let response = await handler.handle(request("tab.list")) + #expect(response.error?.code == .notFound) + } + + @Test + func ambiguous_project_name_is_reported() async { + let (handler, appState, projectStore) = makeHandler() + _ = seedProject(appState, projectStore, name: "twin") + _ = seedProject(appState, projectStore, name: "twin", select: false) + let response = await handler.handle(request("tab.list", args: ControlArgs(project: "twin"))) + #expect(response.error?.code == .ambiguous) + } + + // MARK: - pane.list + + @Test + func pane_list_walks_splits_and_marks_focus() async { + let (handler, appState, projectStore) = makeHandler() + let project = seedProject(appState, projectStore) + appState.splitPane(direction: .horizontal, projectID: project.id) + let response = await handler.handle(request("pane.list")) + let panes = response.data?.panes + #expect(panes?.count == 2) + #expect(panes?.map(\.index) == [1, 2]) + #expect(panes?.allSatisfy { $0.tabIndex == 1 } == true) + // splitPane focuses the new (second) pane. + #expect(panes?.filter(\.focused).count == 1) + #expect(panes?.last?.focused == true) + #expect(panes?.allSatisfy { $0.session.hasPrefix("macterm-") } == true) + #expect(panes?.allSatisfy { $0.cwd == project.path } == true) + } + + @Test + func pane_list_scopes_to_a_tab_selector() async { + let (handler, appState, projectStore) = makeHandler() + let project = seedProject(appState, projectStore) + appState.createTab(projectID: project.id, projectPath: project.path) + appState.splitPane(direction: .vertical, projectID: project.id) + + let all = await handler.handle(request("pane.list")) + #expect(all.data?.panes?.count == 3) + + let scoped = await handler.handle(request("pane.list", args: ControlArgs(tab: "tab:2"))) + #expect(scoped.data?.panes?.count == 2) + + let first = await handler.handle(request("pane.list", args: ControlArgs(tab: "1"))) + #expect(first.data?.panes?.count == 1) + + let missing = await handler.handle(request("pane.list", args: ControlArgs(tab: "9"))) + #expect(missing.error?.code == .notFound) + } + + // MARK: - session.list / session.info + + private func stubZmx( + _ appState: AppState, + entries: [ZmxSessionListParser.Entry]?, + leaders: [String: pid_t] = [:] + ) { + appState.zmx = ZmxClient( + executableURL: { nil }, + isBundled: { true }, + killSession: { _ in }, + listSessionsWithClients: { entries }, + sessionLeaderPIDs: { leaders } + ) + } + + @Test + func session_list_maps_entries_and_live_panes() async throws { + let (handler, appState, projectStore) = makeHandler() + let project = seedProject(appState, projectStore) + let pane = try #require(appState.workspaces[project.id]?.activeTab?.splitRoot.allPanes().first) + stubZmx( + appState, + entries: [ + .init(name: pane.sessionName, clients: 1), + .init(name: "macterm-orphan-aaaabbbbcccc", clients: 0), + ], + leaders: [pane.sessionName: 4242] + ) + let response = await handler.handle(request("session.list")) + let sessions = response.data?.sessions + #expect(sessions?.count == 2) + let live = sessions?.first { $0.name == pane.sessionName } + #expect(live?.paneID == pane.id.uuidString) + #expect(live?.leaderPID == 4242) + #expect(live?.clients == 1) + let orphan = sessions?.first { $0.name == "macterm-orphan-aaaabbbbcccc" } + #expect(orphan?.paneID == nil) + } + + @Test + func session_list_probe_failure_is_internal_error() async { + let (handler, appState, _) = makeHandler() + stubZmx(appState, entries: nil) + let response = await handler.handle(request("session.list")) + #expect(response.error?.code == .internalError) + } + + @Test + func session_info_finds_by_name_or_404s() async { + let (handler, appState, _) = makeHandler() + stubZmx(appState, entries: [.init(name: "macterm-x-000011112222", clients: 0)]) + + let hit = await handler.handle(request("session.info", args: ControlArgs(session: "macterm-x-000011112222"))) + #expect(hit.ok) + #expect(hit.data?.sessions?.count == 1) + + let miss = await handler.handle(request("session.info", args: ControlArgs(session: "macterm-y-000011112222"))) + #expect(miss.error?.code == .notFound) + + let empty = await handler.handle(request("session.info")) + #expect(empty.error?.code == .badRequest) + } +} diff --git a/MactermTests/Control/ControlProtocolTests.swift b/MactermTests/Control/ControlProtocolTests.swift new file mode 100644 index 0000000..3308cea --- /dev/null +++ b/MactermTests/Control/ControlProtocolTests.swift @@ -0,0 +1,77 @@ +import Foundation +@testable import Macterm +import Testing + +struct ControlProtocolTests { + // MARK: - Framing + + @Test + func request_roundtrips_with_newline_framing() throws { + let request = ControlRequest(command: "pane.list", args: ControlArgs(project: "demo", tab: "tab:2")) + let encoded = try ControlProtocol.encode(request) + #expect(encoded.last == 0x0A) + let decoded = try ControlProtocol.decodeRequest(encoded) + #expect(decoded.id == request.id) + #expect(decoded.command == "pane.list") + #expect(decoded.args == ControlArgs(project: "demo", tab: "tab:2")) + #expect(decoded.v == ControlProtocol.version) + } + + @Test + func response_roundtrips_success_and_failure() throws { + let success = ControlResponse.success( + id: "abc", + data: ControlData(status: ControlStatusInfo(version: "1.0", pid: 42, activeProject: "p", activeProjectID: nil)) + ) + let decodedSuccess = try ControlProtocol.decodeResponse(ControlProtocol.encode(success)) + #expect(decodedSuccess.ok) + #expect(decodedSuccess.id == "abc") + #expect(decodedSuccess.data?.status?.pid == 42) + #expect(decodedSuccess.error == nil) + + let failure = ControlResponse.failure( + id: "def", + error: ControlError(code: .notFound, message: "nope", action: "look elsewhere") + ) + let decodedFailure = try ControlProtocol.decodeResponse(ControlProtocol.encode(failure)) + #expect(!decodedFailure.ok) + #expect(decodedFailure.error?.code == .notFound) + #expect(decodedFailure.error?.action == "look elsewhere") + } + + /// The wire format the CLI emits, hand-built: guards against accidental + /// key renames on the app side. + @Test + func app_decodes_hand_built_cli_request() throws { + let json = #"{"v":1,"id":"x1","command":"session.info","args":{"session":"macterm-demo-abc123"}}"# + let decoded = try ControlProtocol.decodeRequest(Data((json + "\n").utf8)) + #expect(decoded.command == "session.info") + #expect(decoded.args?.session == "macterm-demo-abc123") + } + + /// Unknown args keys must be ignored (forward compatibility: a newer CLI + /// against an older app). + @Test + func unknown_args_fields_are_ignored() throws { + let json = #"{"v":1,"id":"x2","command":"status","args":{"future_flag":"yes"}}"# + let decoded = try ControlProtocol.decodeRequest(Data((json + "\n").utf8)) + #expect(decoded.command == "status") + #expect(decoded.args == ControlArgs()) + } + + @Test + func error_codes_use_snake_case_raw_values() { + #expect(ControlErrorCode.notFound.rawValue == "not_found") + #expect(ControlErrorCode.unknownCommand.rawValue == "unknown_command") + #expect(ControlErrorCode.noSurface.rawValue == "no_surface") + #expect(ControlErrorCode.badRequest.rawValue == "bad_request") + #expect(ControlErrorCode.internalError.rawValue == "internal") + } + + @Test + func decode_tolerates_trailing_whitespace_and_crlf() throws { + let json = #"{"v":1,"id":"x3","command":"status"}"# + let decoded = try ControlProtocol.decodeRequest(Data((json + "\r\n").utf8)) + #expect(decoded.command == "status") + } +} diff --git a/MactermTests/Control/ControlSocketServerTests.swift b/MactermTests/Control/ControlSocketServerTests.swift new file mode 100644 index 0000000..dfc2bd9 --- /dev/null +++ b/MactermTests/Control/ControlSocketServerTests.swift @@ -0,0 +1,169 @@ +import Foundation +@testable import Macterm +import Testing + +/// Exercises the real Unix socket end to end against a temp path — bind, +/// connect, request/response framing, and lifecycle hygiene. The "client" +/// here is a minimal raw-socket writer so these tests cover the server +/// contract independently of the CLI's `ControlClient`. +@MainActor +struct ControlSocketServerTests { + private func makeSocketPath() -> String { + // Keep it short: sun_path caps at ~104 bytes and the default tempdir + // path can be long. + "/tmp/macterm-test-\(UInt32.random(in: 0 ..< UInt32.max)).sock" + } + + /// Run the raw round-trip off the main actor so the server's MainActor + /// response task can execute while the client blocks on read. + private func awaitResponse(path: String, line: Data) async -> Data? { + let payload = line + return await Task.detached { + roundTripDetached(path: path, line: payload) + }.value + } + + // MARK: - Lifecycle + + @Test + func start_creates_socket_and_stop_unlinks_it() { + let path = makeSocketPath() + let server = ControlSocketServer(socketPath: path) + server.start() + #expect(FileManager.default.fileExists(atPath: path)) + server.stop() + #expect(!FileManager.default.fileExists(atPath: path)) + } + + @Test + func start_is_idempotent_and_replaces_a_stale_socket_file() { + let path = makeSocketPath() + // Simulate a crashed previous run: a dead socket file at the path. + FileManager.default.createFile(atPath: path, contents: nil) + let server = ControlSocketServer(socketPath: path) + server.start() + server.start() // second start is a no-op, not a rebind + #expect(FileManager.default.fileExists(atPath: path)) + server.stop() + #expect(!FileManager.default.fileExists(atPath: path)) + } + + @Test + func stop_before_start_is_safe() { + let server = ControlSocketServer(socketPath: makeSocketPath()) + server.stop() + } + + // MARK: - Request handling + + @Test + func request_before_attach_gets_starting_error_echoing_id() async throws { + let path = makeSocketPath() + let server = ControlSocketServer(socketPath: path) + server.start() + defer { server.stop() } + + var request = ControlRequest(command: "status") + request.id = "early-bird" + let raw = try #require(await awaitResponse(path: path, line: ControlProtocol.encode(request))) + let response = try ControlProtocol.decodeResponse(raw) + #expect(!response.ok) + #expect(response.error?.code == .starting) + #expect(response.id == "early-bird") + } + + @Test + func attached_handler_round_trips_a_response() async throws { + let path = makeSocketPath() + let server = ControlSocketServer(socketPath: path) + server.start() + defer { server.stop() } + server.attach { raw in + let request = (try? ControlProtocol.decodeRequest(raw)) + return ControlProtocol.encode(.success( + id: request?.id ?? "", + data: ControlData(status: ControlStatusInfo( + version: "test", pid: 7, activeProject: nil, activeProjectID: nil + )) + )) + } + + let request = ControlRequest(command: "status") + let raw = try #require(await awaitResponse(path: path, line: ControlProtocol.encode(request))) + let response = try ControlProtocol.decodeResponse(raw) + #expect(response.ok) + #expect(response.id == request.id) + #expect(response.data?.status?.version == "test") + } + + @Test + func empty_request_gets_bad_request() async throws { + let path = makeSocketPath() + let server = ControlSocketServer(socketPath: path) + server.start() + defer { server.stop() } + + let raw = try #require(await awaitResponse(path: path, line: Data())) + let response = try ControlProtocol.decodeResponse(raw) + #expect(response.error?.code == .badRequest) + } + + @Test + func overlong_socket_path_refuses_to_start() { + let path = "/tmp/" + String(repeating: "x", count: 200) + ".sock" + let server = ControlSocketServer(socketPath: path) + server.start() + #expect(!FileManager.default.fileExists(atPath: path)) + server.stop() + } + + @Test + func default_socket_path_lives_in_app_support() { + let path = ControlSocketServer.defaultSocketPath() + #expect(path.hasSuffix("/" + ControlProtocol.socketFilename)) + } +} + +/// Free function so the detached task doesn't capture the MainActor test +/// struct. Mirrors the private helper above. +private func roundTripDetached(path: String, line: Data) -> Data? { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { return nil } + defer { close(fd) } + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let bytes = path.utf8CString + let maxLen = MemoryLayout.size(ofValue: addr.sun_path) + guard bytes.count <= maxLen else { return nil } + withUnsafeMutablePointer(to: &addr.sun_path) { dst in + dst.withMemoryRebound(to: CChar.self, capacity: maxLen) { dstPtr in + bytes.withUnsafeBufferPointer { src in + guard let base = src.baseAddress else { return } + dstPtr.update(from: base, count: src.count) + } + } + } + let connected = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in + connect(fd, sockPtr, socklen_t(MemoryLayout.size)) + } + } + guard connected == 0 else { return nil } + var timeout = timeval(tv_sec: 5, tv_usec: 0) + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) + if !line.isEmpty { + let sent = line.withUnsafeBytes { raw -> Bool in + guard let base = raw.baseAddress else { return false } + return write(fd, base, line.count) == line.count + } + guard sent else { return nil } + } + shutdown(fd, SHUT_WR) + var data = Data() + var buffer = [UInt8](repeating: 0, count: 4096) + while true { + let n = read(fd, &buffer, buffer.count) + if n > 0 { data.append(contentsOf: buffer[0 ..< n]) } else { break } + } + return data +} diff --git a/project.yml b/project.yml index 77e7a0b..5405188 100644 --- a/project.yml +++ b/project.yml @@ -38,6 +38,9 @@ packages: Yams: url: https://github.com/jpsim/Yams from: "5.0.0" + ArgumentParser: + url: https://github.com/apple/swift-argument-parser + from: "1.3.0" targets: Macterm: @@ -117,6 +120,10 @@ targets: PRODUCT_BUNDLE_IDENTIFIER: com.thdxg.macterm.debug PRODUCT_DISPLAY_NAME: Macterm Debug dependencies: + # Build ordering only: the post-build script copies the CLI binary into + # the bundle, so it must exist before the app target finishes. + - target: MactermCLI + embed: false - framework: GhosttyKit.xcframework embed: false - package: Sparkle @@ -141,6 +148,37 @@ targets: - script: "${SRCROOT}/scripts/embed-zmx.sh" name: "Embed zmx" basedOnDependencyAnalysis: false + postBuildScripts: + # Bundle the control CLI at Contents/Resources/bin/macterm. The app + # prepends this dir to PATH for spawned shells; scripts outside the app + # (the CI benchmark) invoke it by bundle path. + - name: "Bundle macterm CLI" + basedOnDependencyAnalysis: false + script: | + set -e + BIN_DIR="$BUILT_PRODUCTS_DIR/$CONTENTS_FOLDER_PATH/Resources/bin" + mkdir -p "$BIN_DIR" + cp "$BUILT_PRODUCTS_DIR/macterm" "$BIN_DIR/macterm" + + # The bundled control CLI (`macterm`). Shares ControlProtocol.swift with the + # app target so the wire codec can never drift between the two. + MactermCLI: + type: tool + platform: macOS + sources: + - path: CLI + - path: Macterm/Control/ControlProtocol.swift + dependencies: + - package: ArgumentParser + product: ArgumentParser + settings: + base: + PRODUCT_NAME: macterm + # The binary is `macterm` but the MODULE must not collide with the + # app's `Macterm` module (case-insensitive .swiftmodule paths in the + # shared products dir would otherwise clash). + PRODUCT_MODULE_NAME: MactermCLI + PRODUCT_BUNDLE_IDENTIFIER: com.thdxg.macterm.cli MactermTests: type: bundle.unit-test From b86a8c590fbac549eb28986f20748cbed270bccd Mon Sep 17 00:00:00 2001 From: Ethan Lee Date: Sun, 5 Jul 2026 01:04:02 +0900 Subject: [PATCH 2/2] Add mutation verbs to the macterm CLI (create, split, run, grid) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the control plane in place, this teaches it to actually drive the app: project create/select (idempotent by canonical path, ProjectPath- validated, remote specs rejected until #104), tab new/select/close, pane split/focus/close/run, an equal-cells grid verb (the benchmark workhorse), session kill, and layout apply/save. Semantics worth reviewing: - New panes/tabs take a command via the layout run: path (spawn-time initial_input); pane run types into an EXISTING live shell through a new GhosttyTerminalNSView.sendText — the paste path, guarded so a missing surface is a typed no_surface error, never a silent drop. - Headless callers can't answer dialogs, so close/apply verbs return a typed busy error where the UI would stage a confirmation; --force takes the destructive path deliberately. - Every pane's env now carries MACTERM_SESSION (its restart-stable zmx session name), so macterm run inside a pane self-targets. pane close deliberately ignores that fallback: destroying "whatever pane I'm in" by omission is a footgun. docs/cli.md documents the grammar, targeting rules, wire protocol for non-CLI clients, and the same-user security posture. --- AGENTS.md | 2 +- CLI/MactermCommand.swift | 357 ++++++++++++++++- Macterm/App/AppState.swift | 49 ++- Macterm/Control/ControlHandler.swift | 362 ++++++++++++++++-- Macterm/Control/ControlProtocol.swift | 36 +- Macterm/Model/SplitNode.swift | 24 +- Macterm/Model/Workspace.swift | 55 ++- .../Terminal/GhosttyTerminalNSView.swift | 25 ++ .../Control/ControlHandlerTests.swift | 287 ++++++++++++++ MactermTests/Model/TerminalTabTests.swift | 53 +++ docs/cli.md | 136 +++++++ 11 files changed, 1338 insertions(+), 48 deletions(-) create mode 100644 docs/cli.md diff --git a/AGENTS.md b/AGENTS.md index 5681ae2..798675b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,7 +88,7 @@ A tab's auto-title is, by default, the pane's live **foreground process name** ( ### Control CLI (`macterm`) -A bundled CLI (issue #107) controls the running app over a Unix socket at `/control.sock` (per build flavor; `MACTERM_BENCHMARK_DATA_DIR` relocates it with the rest of app data). Built as the `MactermCLI` tool target — `PRODUCT_NAME` is `macterm` but `PRODUCT_MODULE_NAME` must stay `MactermCLI` (a `macterm` module would collide case-insensitively with the app's `Macterm.swiftmodule` in the shared products dir). A post-build script copies it to `Contents/Resources/bin/macterm`; the app prepends that dir to `PATH` and exports `MACTERM_SOCKET` before any shell spawns, so `macterm` works inside every pane. +A bundled CLI (issue #107) controls the running app over a Unix socket at `/control.sock` (per build flavor; `MACTERM_BENCHMARK_DATA_DIR` relocates it with the rest of app data). Full grammar + protocol reference in `docs/cli.md`. Built as the `MactermCLI` tool target — `PRODUCT_NAME` is `macterm` but `PRODUCT_MODULE_NAME` must stay `MactermCLI` (a `macterm` module would collide case-insensitively with the app's `Macterm.swiftmodule` in the shared products dir). A post-build script copies it to `Contents/Resources/bin/macterm`; the app prepends that dir to `PATH` and exports `MACTERM_SOCKET` before any shell spawns, so `macterm` works inside every pane. Verbs: `status`; `project list/create/select`; `tab list/new/select/close`; `pane list/split/focus/close/run`; `grid RxC`; `session list/info/kill`; `layout apply/save`. New panes/tabs take `--run CMD` (spawn-time `initial_input`, the layout `run:` path); `pane run` types into an *existing* live shell via `GhosttyTerminalNSView.sendText` (the paste path — `ghostty_surface_key` with `.text`). Every pane's env carries `MACTERM_SESSION` (its restart-stable session name) so a bare `macterm pane split` inside a pane self-targets; close verbs demand explicit targets and return a typed `busy` error instead of staging UI confirmation dialogs (headless callers can't click). - **Wire protocol** (`ControlProtocol.swift`, compiled into both targets so the codec can't drift): one request per connection; newline-terminated JSON both ways; client half-closes after sending. Requests are `{v, id, command, args}` with `command` namespaced `noun.verb` (`pane.list`); responses echo `id` with `{ok, data}` or `{ok:false, error:{code, message, action?}}` — `action` is a human recovery hint. Errors use snake_case codes (`not_found`, `busy`, `no_surface`, `starting`…). - **Server** (`ControlSocketServer`): dedicated thread polling a non-blocking listen fd (20ms) — never a blocking `accept()` on a queue shared with `stop()`, which would starve shutdown. Per-connection dispatch hops to `@MainActor`. Starts in `applicationDidFinishLaunching` (skipped under xctest); the handler attaches later in `installResponders` when AppState exists — early requests get a `starting` error. `FD_CLOEXEC` on the listen fd so spawned shells can't hold the socket past quit. diff --git a/CLI/MactermCommand.swift b/CLI/MactermCommand.swift index 3240f82..d8db83c 100644 --- a/CLI/MactermCommand.swift +++ b/CLI/MactermCommand.swift @@ -11,7 +11,15 @@ struct MactermCommand: ParsableCommand { static let configuration = CommandConfiguration( commandName: "macterm", abstract: "Control a running Macterm: projects, tabs, panes, zmx sessions.", - subcommands: [Status.self, ProjectCommand.self, TabCommand.self, PaneCommand.self, SessionCommand.self] + subcommands: [ + Status.self, + ProjectCommand.self, + TabCommand.self, + PaneCommand.self, + Grid.self, + SessionCommand.self, + LayoutCommand.self, + ] ) } @@ -43,7 +51,41 @@ func runControlCommand(command: String, args: ControlArgs? = nil, options: Conne throw ExitCode(1) } -// MARK: - Subcommands +/// The pane self-address the app injects into every pane's shell. Used as the +/// implicit target when a pane verb names no explicit one (Zentty-style +/// "current pane" context) — explicit selectors always win. +func sessionFromEnvironment() -> String? { + let value = ProcessInfo.processInfo.environment[ControlProtocol.sessionEnvVar] + return (value?.isEmpty ?? true) ? nil : value +} + +/// Shared pane-target options: `--session`/`--pane` are explicit; inside a +/// Macterm pane, `MACTERM_SESSION` fills in when neither is given (nor a tab +/// scope — an explicit tab means "that tab's focused pane", not self). +struct PaneTarget: ParsableArguments { + @Option(help: "Project scope (name, UUID, or index). Defaults to the active project.") + var project: String? + + @Option(help: "Tab scope (title, UUID, or index).") + var tab: String? + + @Option(help: "Target pane by UUID, or index within the tab (pane:2).") + var pane: String? + + @Option(help: "Target pane by zmx session name (restart-stable).") + var session: String? + + func controlArgs() -> ControlArgs { + ControlArgs( + project: project, + tab: tab, + pane: pane, + session: session ?? (pane == nil && tab == nil ? sessionFromEnvironment() : nil) + ) + } +} + +// MARK: - Status struct Status: ParsableCommand { static let configuration = CommandConfiguration( @@ -57,11 +99,13 @@ struct Status: ParsableCommand { } } +// MARK: - Project + struct ProjectCommand: ParsableCommand { static let configuration = CommandConfiguration( commandName: "project", - abstract: "List and inspect projects.", - subcommands: [List.self], + abstract: "List, create, and select projects.", + subcommands: [List.self, Create.self, Select.self], defaultSubcommand: List.self ) @@ -74,13 +118,53 @@ struct ProjectCommand: ParsableCommand { try runControlCommand(command: "project.list", options: options) } } + + struct Create: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Add a project for a local directory (idempotent by path)." + ) + + @Argument(help: "Project directory (absolute or ~-prefixed).") + var path: String + + @Option(help: "Display name. Defaults to the directory name.") + var name: String? + + @Flag(help: "Also select it (applies a matching project file's layout on first open).") + var select = false + + @OptionGroup var options: ConnectionOptions + + func run() throws { + try runControlCommand( + command: "project.create", + args: ControlArgs(path: path, name: name, select: select), + options: options + ) + } + } + + struct Select: ParsableCommand { + static let configuration = CommandConfiguration(abstract: "Make a project active.") + + @Argument(help: "Project name, UUID, or index.") + var project: String + + @OptionGroup var options: ConnectionOptions + + func run() throws { + try runControlCommand(command: "project.select", args: ControlArgs(project: project), options: options) + } + } } +// MARK: - Tab + struct TabCommand: ParsableCommand { static let configuration = CommandConfiguration( commandName: "tab", - abstract: "List and inspect tabs.", - subcommands: [List.self], + abstract: "List, create, select, and close tabs.", + subcommands: [List.self, New.self, Select.self, Close.self], defaultSubcommand: List.self ) @@ -96,13 +180,80 @@ struct TabCommand: ParsableCommand { try runControlCommand(command: "tab.list", args: ControlArgs(project: project), options: options) } } + + struct New: ParsableCommand { + static let configuration = CommandConfiguration(abstract: "Open a new tab (becomes active).") + + @Option(help: "Project (name, UUID, or index). Defaults to the active project.") + var project: String? + + @Option(name: .customLong("run"), help: "Command to run in the new tab's shell.") + var runCommand: String? + + @OptionGroup var options: ConnectionOptions + + func run() throws { + try runControlCommand( + command: "tab.new", + args: ControlArgs(project: project, run: runCommand), + options: options + ) + } + } + + struct Select: ParsableCommand { + static let configuration = CommandConfiguration(abstract: "Activate a tab.") + + @Argument(help: "Tab title, UUID, or index (tab:3).") + var tab: String + + @Option(help: "Project scope. Defaults to the active project.") + var project: String? + + @OptionGroup var options: ConnectionOptions + + func run() throws { + try runControlCommand( + command: "tab.select", + args: ControlArgs(project: project, tab: tab), + options: options + ) + } + } + + struct Close: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Close a tab (kills its panes' zmx sessions)." + ) + + @Argument(help: "Tab title, UUID, or index (tab:3).") + var tab: String + + @Option(help: "Project scope. Defaults to the active project.") + var project: String? + + @Flag(help: "Close even if a pane has a running program.") + var force = false + + @OptionGroup var options: ConnectionOptions + + func run() throws { + try runControlCommand( + command: "tab.close", + args: ControlArgs(project: project, tab: tab, force: force), + options: options + ) + } + } } +// MARK: - Pane + struct PaneCommand: ParsableCommand { static let configuration = CommandConfiguration( commandName: "pane", - abstract: "List and inspect panes.", - subcommands: [List.self], + abstract: "List, split, focus, close panes, and run commands in them.", + subcommands: [List.self, Split.self, Focus.self, Close.self, Run.self], defaultSubcommand: List.self ) @@ -125,13 +276,137 @@ struct PaneCommand: ParsableCommand { ) } } + + struct Split: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Split a pane. Defaults to the focused pane (or the pane you're in)." + ) + + @Option(help: "right, down, or auto (longer on-screen axis).") + var direction: String = "auto" + + @Option(name: .customLong("run"), help: "Command to run in the new pane's shell.") + var runCommand: String? + + @OptionGroup var target: PaneTarget + @OptionGroup var options: ConnectionOptions + + func run() throws { + var args = target.controlArgs() + args.direction = direction + args.run = runCommand + try runControlCommand(command: "pane.split", args: args, options: options) + } + } + + struct Focus: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Focus a pane (selects its tab and fronts the window)." + ) + + @OptionGroup var target: PaneTarget + @OptionGroup var options: ConnectionOptions + + func run() throws { + try runControlCommand(command: "pane.focus", args: target.controlArgs(), options: options) + } + } + + struct Close: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Close a pane (kills its zmx session)." + ) + + @OptionGroup var target: PaneTarget + + @Flag(help: "Close even if a program is running.") + var force = false + + @OptionGroup var options: ConnectionOptions + + func run() throws { + var args = ControlArgs( + project: target.project, + tab: target.tab, + pane: target.pane, + // Deliberately NOT defaulted from MACTERM_SESSION: closing + // "whatever pane I'm in" because no target was given is a + // destructive surprise. Explicit targets only. + session: target.session + ) + args.force = force + guard args.pane != nil || args.session != nil else { + Output.printError( + "pane close requires --pane or --session", + action: "run `macterm pane list` for targets" + ) + throw ExitCode(1) + } + try runControlCommand(command: "pane.close", args: args, options: options) + } + } + + struct Run: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Type a command into a live pane's shell (adds a newline)." + ) + + @Argument(parsing: .captureForPassthrough, help: "The command line to run.") + var command: [String] + + @OptionGroup var target: PaneTarget + @OptionGroup var options: ConnectionOptions + + func run() throws { + let line = command.joined(separator: " ") + guard !line.isEmpty else { + Output.printError("nothing to run") + throw ExitCode(1) + } + var args = target.controlArgs() + args.run = line + try runControlCommand(command: "pane.run", args: args, options: options) + } + } } +// MARK: - Grid + +struct Grid: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Split a pane into an equal ROWSxCOLS grid." + ) + + @Argument(help: "Grid shape, e.g. 2x2 or 3x1.") + var shape: String + + @Option(name: .customLong("run"), help: "Command to run in each NEW pane (the source pane keeps its shell).") + var runCommand: String? + + @OptionGroup var target: PaneTarget + @OptionGroup var options: ConnectionOptions + + func run() throws { + let parts = shape.lowercased().split(separator: "x") + guard parts.count == 2, let rows = Int(parts[0]), let cols = Int(parts[1]) else { + Output.printError("grid shape must look like 2x2") + throw ExitCode(1) + } + var args = target.controlArgs() + args.rows = rows + args.cols = cols + args.run = runCommand + try runControlCommand(command: "grid", args: args, options: options) + } +} + +// MARK: - Session + struct SessionCommand: ParsableCommand { static let configuration = CommandConfiguration( commandName: "session", - abstract: "Inspect zmx-backed terminal sessions.", - subcommands: [List.self, Info.self], + abstract: "Inspect and kill zmx-backed terminal sessions.", + subcommands: [List.self, Info.self, Kill.self], defaultSubcommand: List.self ) @@ -157,4 +432,66 @@ struct SessionCommand: ParsableCommand { try runControlCommand(command: "session.info", args: ControlArgs(session: name), options: options) } } + + struct Kill: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Kill a zmx session (its shell dies; an attached pane's shell exits)." + ) + + @Argument(help: "Session name (macterm--).") + var name: String + + @OptionGroup var options: ConnectionOptions + + func run() throws { + try runControlCommand(command: "session.kill", args: ControlArgs(session: name), options: options) + } + } +} + +// MARK: - Layout + +struct LayoutCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "layout", + abstract: "Apply or save a project's declarative layout file.", + subcommands: [Apply.self, Save.self] + ) + + struct Apply: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Reconcile the workspace to the project's central layout file." + ) + + @Option(help: "Project (name, UUID, or index). Defaults to the active project.") + var project: String? + + @Flag(help: "Apply even when it would close panes.") + var force = false + + @OptionGroup var options: ConnectionOptions + + func run() throws { + try runControlCommand( + command: "layout.apply", + args: ControlArgs(project: project, force: force), + options: options + ) + } + } + + struct Save: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Save the live workspace as the project's layout file." + ) + + @Option(help: "Project (name, UUID, or index). Defaults to the active project.") + var project: String? + + @OptionGroup var options: ConnectionOptions + + func run() throws { + try runControlCommand(command: "layout.save", args: ControlArgs(project: project), options: options) + } + } } diff --git a/Macterm/App/AppState.swift b/Macterm/App/AppState.swift index ca73bbf..6dc15e3 100644 --- a/Macterm/App/AppState.swift +++ b/Macterm/App/AppState.swift @@ -665,11 +665,16 @@ final class AppState { // MARK: - Tabs - func createTab(projectID: UUID, projectPath: String) { - guard let ws = workspaces[projectID] else { return } - ws.createTab(projectPath: projectPath) + /// A `command` spawns in the new tab's pane via `initial_input` (layout + /// `run:` semantics). Returns the new tab's ID, nil when the project has + /// no live workspace. + @discardableResult + func createTab(projectID: UUID, projectPath: String, command: String? = nil) -> UUID? { + guard let ws = workspaces[projectID] else { return nil } + let tab = ws.createTab(projectPath: projectPath, command: command) logger.debug("createTab: project=\(projectID, privacy: .public) tabs=\(ws.tabs.count, privacy: .public)") saveWorkspaces() + return tab.id } /// Convenience overload: look up the project's canonical path from the @@ -842,6 +847,44 @@ final class AppState { saveWorkspaces() } + /// Split a SPECIFIC pane — found in whichever of the project's tabs holds + /// it, unlike the focused-pane overload above — optionally spawning + /// `command` in the new pane. The control CLI's split path. Returns the + /// new pane's ID. + @discardableResult + func splitPane( + _ paneID: UUID, + direction: SplitDirection, + projectID: UUID, + command: String? = nil + ) -> UUID? { + guard let ws = workspaces[projectID], + let tab = ws.tabs.first(where: { $0.splitRoot.findPane(id: paneID) != nil }) + else { return nil } + let newID = tab.split(paneID: paneID, direction: direction, command: command) + saveWorkspaces() + return newID + } + + /// Split a pane into an equal `rows`×`columns` grid (see + /// `TerminalTab.makeGrid`), spawning `command` in each new pane. Returns + /// the new pane IDs. + @discardableResult + func makeGrid( + _ paneID: UUID, + rows: Int, + columns: Int, + projectID: UUID, + command: String? = nil + ) -> [UUID] { + guard let ws = workspaces[projectID], + let tab = ws.tabs.first(where: { $0.splitRoot.findPane(id: paneID) != nil }) + else { return [] } + let created = tab.makeGrid(paneID: paneID, rows: rows, columns: columns, command: command) + if !created.isEmpty { saveWorkspaces() } + return created + } + /// Split the focused pane along its longer on-screen axis (Ghostty's /// `new_split` / BSP behavior). Direction is decided by `TerminalTab.autoSplit`. func autoSplitPane(projectID: UUID) { diff --git a/Macterm/Control/ControlHandler.swift b/Macterm/Control/ControlHandler.swift index acd427c..14a8006 100644 --- a/Macterm/Control/ControlHandler.swift +++ b/Macterm/Control/ControlHandler.swift @@ -57,10 +57,23 @@ final class ControlHandler { switch request.command { case "status": return status() case "project.list": return projectList() + case "project.create": return try projectCreate(args) + case "project.select": return try projectSelect(args) case "tab.list": return try tabList(args) + case "tab.new": return try tabNew(args) + case "tab.select": return try tabSelect(args) + case "tab.close": return try tabClose(args) case "pane.list": return try paneList(args) + case "pane.split": return try paneSplit(args) + case "pane.focus": return try paneFocus(args) + case "pane.close": return try paneClose(args) + case "pane.run": return try paneRun(args) + case "grid": return try grid(args) case "session.list": return try await sessionList() case "session.info": return try await sessionInfo(args) + case "session.kill": return try await sessionKill(args) + case "layout.apply": return try layoutApply(args) + case "layout.save": return try layoutSave(args) default: throw ControlError( code: .unknownCommand, @@ -115,21 +128,8 @@ final class ControlHandler { } else { tabs = Array(zip(1..., workspace.tabs)) } - var infos: [ControlPaneInfo] = [] - for (tabIndex, tab) in tabs { - for (paneIndex, pane) in zip(1..., tab.splitRoot.allPanes()) { - infos.append(ControlPaneInfo( - index: paneIndex, - id: pane.id.uuidString, - session: pane.sessionName, - tabIndex: tabIndex, - tabID: tab.id.uuidString, - title: pane.displayTitle, - process: pane.foregroundProcessName, - cwd: pane.nsView?.currentPwd ?? pane.projectPath, - focused: tab.id == workspace.activeTabID && pane.id == tab.focusedPaneID - )) - } + let infos = tabs.flatMap { _, tab in + tab.splitRoot.allPanes().map { paneInfo($0, in: tab, workspace: workspace) } } return ControlData(panes: infos) } @@ -170,6 +170,243 @@ final class ControlHandler { return ControlData(sessions: [match]) } + // MARK: - Project mutations + + /// Create (or find) a project for a local path. Idempotent by canonical + /// path — re-creating an existing project returns it instead of erroring, + /// so scripted setups (the benchmark) can run unconditionally. + private func projectCreate(_ args: ControlArgs) throws -> ControlData { + guard let rawPath = args.path, !rawPath.isEmpty else { + throw ControlError(code: .badRequest, message: "project.create requires a path") + } + guard let parsed = ProjectPath.parse(rawPath) else { + throw ControlError(code: .badRequest, message: "\"\(rawPath)\" is not an absolute or ~-prefixed path") + } + guard case .local = parsed else { + throw ControlError( + code: .badRequest, + message: "remote projects aren't supported yet (#104)", + action: "pass a local directory path" + ) + } + let canonical = ProjectPath.canonicalLocal(rawPath) + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: canonical, isDirectory: &isDirectory), isDirectory.boolValue else { + throw ControlError(code: .notFound, message: "no directory at \(canonical)") + } + + let project: Project + if let existing = projectStore.projects.first(where: { ProjectPath.canonicalLocal($0.path) == canonical }) { + project = existing + } else { + let name = args.name ?? (canonical as NSString).lastPathComponent + project = Project(name: name, path: canonical, sortOrder: projectStore.projects.count) + projectStore.add(project) + } + if args.select == true { + // selectProject runs the same first-open path the sidebar does — + // including auto-applying a matching central project file, so a + // declared layout spawns its tabs. + appState.selectProject(project) + } + return projectData(project) + } + + private func projectSelect(_ args: ControlArgs) throws -> ControlData { + guard args.project != nil else { + throw ControlError(code: .badRequest, message: "project.select requires a project selector") + } + let project = try resolveProject(args.project) + appState.selectProject(project) + return projectData(project) + } + + // MARK: - Tab mutations + + private func tabNew(_ args: ControlArgs) throws -> ControlData { + let (project, workspace) = try resolveWorkspace(args) + guard let tabID = appState.createTab(projectID: project.id, projectPath: project.path, command: args.run), + let index = workspace.tabs.firstIndex(where: { $0.id == tabID }) + else { + throw ControlError(code: .internalError, message: "tab creation failed") + } + return ControlData(tabs: [tabInfo(workspace.tabs[index], index: index + 1, in: workspace)]) + } + + private func tabSelect(_ args: ControlArgs) throws -> ControlData { + guard args.tab != nil else { + throw ControlError(code: .badRequest, message: "tab.select requires a tab selector") + } + let (project, workspace) = try resolveWorkspace(args) + let (index, tab) = try resolveTab(args, in: workspace) + appState.selectTab(tab.id, projectID: project.id) + return ControlData(tabs: [tabInfo(tab, index: index, in: workspace)]) + } + + private func tabClose(_ args: ControlArgs) throws -> ControlData { + guard args.tab != nil else { + throw ControlError(code: .badRequest, message: "tab.close requires a tab selector") + } + let (project, workspace) = try resolveWorkspace(args) + let (_, tab) = try resolveTab(args, in: workspace) + // Closing kills the panes' zmx sessions. The UI stages a confirmation + // dialog for busy tabs; a headless caller gets a typed `busy` error + // instead — never a dialog the CLI can't answer. + let busy = tab.splitRoot.allPanes().contains { $0.nsView?.needsConfirmQuit() == true } + if busy, args.force != true { + throw ControlError( + code: .busy, + message: "a pane in that tab has a running program (closing kills its session)", + action: "re-run with --force to close anyway" + ) + } + appState.closeTab(tab.id, projectID: project.id) + return ControlData() + } + + // MARK: - Pane mutations + + private func paneSplit(_ args: ControlArgs) throws -> ControlData { + let (project, workspace) = try resolveWorkspace(args) + let target = try resolvePane(args, in: workspace) + let direction: SplitDirection + switch args.direction ?? "auto" { + case "right": direction = .horizontal + case "down": direction = .vertical + case "auto": + // The UI's auto-split picks the longer on-screen axis from the + // pane's live NSView bounds; a never-shown pane measures zero and + // falls back to horizontal — same as TerminalTab.autoSplit. + let bounds = target.pane.nsView?.bounds.size ?? .zero + direction = bounds.height > bounds.width ? .vertical : .horizontal + default: + throw ControlError(code: .badRequest, message: "direction must be right, down, or auto") + } + guard let newID = appState.splitPane( + target.pane.id, direction: direction, projectID: project.id, command: args.run + ), let newPane = target.tab.splitRoot.findPane(id: newID) + else { + throw ControlError(code: .internalError, message: "split failed") + } + return ControlData(panes: [paneInfo(newPane, in: target.tab, workspace: workspace)]) + } + + private func paneFocus(_ args: ControlArgs) throws -> ControlData { + let (project, workspace) = try resolveWorkspace(args) + let target = try resolvePane(args, in: workspace) + // navigateToPane selects the containing tab, fronts the window, and + // restores first responder — everything "focus" means for a human. + appState.navigateToPane(target.pane.id, projectID: project.id) + return ControlData(panes: [paneInfo(target.pane, in: target.tab, workspace: workspace)]) + } + + private func paneClose(_ args: ControlArgs) throws -> ControlData { + let (project, workspace) = try resolveWorkspace(args) + guard args.pane != nil || args.session != nil else { + throw ControlError(code: .badRequest, message: "pane.close requires a pane or session selector") + } + let target = try resolvePane(args, in: workspace) + let busy = target.pane.nsView?.needsConfirmQuit() == true + if busy, args.force != true { + throw ControlError( + code: .busy, + message: "that pane has a running program (closing kills its session)", + action: "re-run with --force to close anyway" + ) + } + appState.closePane(target.pane.id, projectID: project.id) + return ControlData() + } + + private func paneRun(_ args: ControlArgs) throws -> ControlData { + guard let command = args.run, !command.isEmpty else { + throw ControlError(code: .badRequest, message: "pane.run requires a command") + } + let (_, workspace) = try resolveWorkspace(args) + let target = try resolvePane(args, in: workspace) + guard let view = target.pane.nsView, view.sendText(command + "\n") else { + throw ControlError( + code: .noSurface, + message: "the pane's terminal isn't live yet", + action: "select its tab once so the surface spawns, then retry" + ) + } + return ControlData(panes: [paneInfo(target.pane, in: target.tab, workspace: workspace)]) + } + + private func grid(_ args: ControlArgs) throws -> ControlData { + guard let rows = args.rows, let cols = args.cols, rows >= 1, cols >= 1, rows * cols > 1 else { + throw ControlError(code: .badRequest, message: "grid requires rows×cols with at least 2 cells") + } + let cellCap = 16 + guard rows * cols <= cellCap else { + throw ControlError(code: .badRequest, message: "grid caps at \(cellCap) cells") + } + let (project, workspace) = try resolveWorkspace(args) + let target = try resolvePane(args, in: workspace) + let created = appState.makeGrid( + target.pane.id, rows: rows, columns: cols, projectID: project.id, command: args.run + ) + guard !created.isEmpty else { + throw ControlError(code: .internalError, message: "grid produced no panes") + } + let infos = created.compactMap { id in + target.tab.splitRoot.findPane(id: id).map { paneInfo($0, in: target.tab, workspace: workspace) } + } + return ControlData(panes: infos) + } + + // MARK: - Session / layout mutations + + private func sessionKill(_ args: ControlArgs) async throws -> ControlData { + guard let name = args.session, !name.isEmpty else { + throw ControlError(code: .badRequest, message: "session.kill requires a session name") + } + guard let entries = await zmx.listSessionsWithClients() else { + throw ControlError(code: .internalError, message: "zmx session listing unavailable") + } + guard entries.contains(where: { $0.name == name }) else { + throw ControlError( + code: .notFound, + message: "no zmx session named \"\(name)\"", + action: "run `macterm session list` to see live sessions" + ) + } + await zmx.killSession(name) + return ControlData() + } + + private func layoutApply(_ args: ControlArgs) throws -> ControlData { + let project = try resolveProject(args.project) + if let error = appState.applyLayout(project: project) { + throw ControlError(code: .notFound, message: error.localizedDescription) + } + // A destructive reconcile is staged for UI confirmation; headless + // callers either force it through or get a typed `busy` — the staged + // dialog must never dangle waiting for a click that won't come. + if appState.pendingLayoutApply != nil { + if args.force == true { + appState.confirmPendingLayoutApply() + } else { + appState.cancelPendingLayoutApply() + throw ControlError( + code: .busy, + message: "applying would close panes and end their processes", + action: "re-run with --force to apply anyway" + ) + } + } + return ControlData() + } + + private func layoutSave(_ args: ControlArgs) throws -> ControlData { + let project = try resolveProject(args.project) + if let error = appState.saveLayout(project: project) { + throw ControlError(code: .internalError, message: error.localizedDescription) + } + return ControlData() + } + // MARK: - Selector resolution /// Resolve the project selector (name, UUID, or 1-based list index) to a @@ -267,20 +504,101 @@ final class ControlHandler { return value } + /// Resolve the pane target: `session` (restart-stable name, searched + /// across the whole workspace), `pane` (UUID anywhere, or `pane:N` index + /// within the resolved tab), else the focused pane of the active tab. + /// `session` and `pane` together conflict — an explicit error, never a + /// silent winner. + private func resolvePane(_ args: ControlArgs, in workspace: Workspace) throws -> (tab: TerminalTab, pane: Pane) { + if args.session != nil, args.pane != nil { + throw ControlError(code: .badRequest, message: "pass either --session or --pane, not both") + } + if let session = args.session, !session.isEmpty { + for tab in workspace.tabs { + if let pane = tab.splitRoot.allPanes().first(where: { $0.sessionName == session }) { + return (tab, pane) + } + } + throw ControlError( + code: .notFound, + message: "no pane in this project runs session \"\(session)\"", + action: "run `macterm pane list` for live panes" + ) + } + if let selector = args.pane, !selector.isEmpty { + if let id = UUID(uuidString: selector) { + for tab in workspace.tabs { + if let pane = tab.splitRoot.findPane(id: id) { return (tab, pane) } + } + throw ControlError(code: .notFound, message: "no pane with id \(selector)") + } + let (_, tab) = try resolveTab(args, in: workspace) + let panes = tab.splitRoot.allPanes() + guard let index = parseIndex(selector, prefix: "pane"), panes.indices.contains(index - 1) else { + throw ControlError( + code: .notFound, + message: "no pane \(selector) in that tab", + action: "run `macterm pane list` for indexes" + ) + } + return (tab, panes[index - 1]) + } + // No pane selector: an explicit tab selector means "that tab's + // focused pane"; otherwise the active tab's. + let (_, tab) = try resolveTab(args, in: workspace) + guard let focusedID = tab.focusedPaneID, let pane = tab.splitRoot.findPane(id: focusedID) else { + throw ControlError(code: .notFound, message: "the tab has no focused pane") + } + return (tab, pane) + } + // MARK: - Shared projections + private func projectData(_ project: Project) -> ControlData { + let info = ControlProjectInfo( + id: project.id.uuidString, + name: project.name, + path: project.path, + active: project.id == appState.activeProjectID, + loaded: appState.workspaces[project.id] != nil, + tabCount: appState.workspaces[project.id]?.tabs.count + ) + return ControlData(projects: [info]) + } + + private func tabInfo(_ tab: TerminalTab, index: Int, in workspace: Workspace) -> ControlTabInfo { + ControlTabInfo( + index: index, + id: tab.id.uuidString, + title: tab.sidebarTitle, + active: tab.id == workspace.activeTabID, + paneCount: tab.splitRoot.allPanes().count + ) + } + private func tabInfos(in workspace: Workspace) -> [ControlTabInfo] { zip(1..., workspace.tabs).map { index, tab in - ControlTabInfo( - index: index, - id: tab.id.uuidString, - title: tab.sidebarTitle, - active: tab.id == workspace.activeTabID, - paneCount: tab.splitRoot.allPanes().count - ) + tabInfo(tab, index: index, in: workspace) } } + private func paneInfo(_ pane: Pane, in tab: TerminalTab, workspace: Workspace) -> ControlPaneInfo { + let panes = tab.splitRoot.allPanes() + let paneIndex = (panes.firstIndex(where: { $0.id == pane.id }) ?? 0) + 1 + let tabIndex = (workspace.tabs.firstIndex(where: { $0.id == tab.id }) ?? 0) + 1 + return ControlPaneInfo( + index: paneIndex, + id: pane.id.uuidString, + session: pane.sessionName, + tabIndex: tabIndex, + tabID: tab.id.uuidString, + title: pane.displayTitle, + process: pane.foregroundProcessName, + cwd: pane.nsView?.currentPwd ?? pane.projectPath, + focused: tab.id == workspace.activeTabID && pane.id == tab.focusedPaneID + ) + } + private func paneIDsBySessionName() -> [String: String] { var map: [String: String] = [:] for workspace in appState.workspaces.values { diff --git a/Macterm/Control/ControlProtocol.swift b/Macterm/Control/ControlProtocol.swift index 6f19486..87a0bbd 100644 --- a/Macterm/Control/ControlProtocol.swift +++ b/Macterm/Control/ControlProtocol.swift @@ -65,17 +65,51 @@ struct ControlArgs: Codable, Equatable { /// zmx session name (`macterm--`) — the restart-stable pane /// address. var session: String? + /// Filesystem path (`project.create`). + var path: String? + /// Display name (`project.create`). + var name: String? + /// Also select/activate what was created (`project.create`). + var select: Bool? + /// Command to run: spawned via `initial_input` in new panes + /// (`tab.new`, `pane.split`, `grid`), typed into the live shell for + /// `pane.run`. + var run: String? + /// Split direction: `right`, `down`, or `auto`. + var direction: String? + /// Skip the busy-confirmation and destructive-plan guards + /// (`tab.close`, `pane.close`, `layout.apply`). + var force: Bool? + /// Grid shape (`grid`). + var rows: Int? + var cols: Int? init( project: String? = nil, tab: String? = nil, pane: String? = nil, - session: String? = nil + session: String? = nil, + path: String? = nil, + name: String? = nil, + select: Bool? = nil, + run: String? = nil, + direction: String? = nil, + force: Bool? = nil, + rows: Int? = nil, + cols: Int? = nil ) { self.project = project self.tab = tab self.pane = pane self.session = session + self.path = path + self.name = name + self.select = select + self.run = run + self.direction = direction + self.force = force + self.rows = rows + self.cols = cols } } diff --git a/Macterm/Model/SplitNode.swift b/Macterm/Model/SplitNode.swift index 8036fcb..2f5058d 100644 --- a/Macterm/Model/SplitNode.swift +++ b/Macterm/Model/SplitNode.swift @@ -469,12 +469,21 @@ final class Pane: Identifiable { func ensureNSView() -> GhosttyTerminalNSView { if let existing = _nsView { return existing } + // Every pane's shell learns its own restart-stable address so + // `macterm` invoked inside it can self-target (`MACTERM_SESSION`). + // Injected at spawn, which means a zmx-reattached shell keeps the + // value from its original spawn — correct, because the session name + // is persisted verbatim and survives restarts (pane UUIDs don't). + // Our value wins over a layout-declared duplicate: this is identity, + // not configuration. + var mergedEnv = env ?? [:] + mergedEnv[ControlProtocol.sessionEnvVar] = sessionName let view = GhosttyTerminalNSView( workingDirectory: projectPath, sessionName: sessionName, command: command, shell: shell, - env: env + env: mergedEnv ) _nsView = view return view @@ -657,13 +666,16 @@ extension SplitNode { direction: SplitDirection, position: SplitPosition, projectPath: String, - projectID: UUID + projectID: UUID, + command: String? = nil ) -> (node: SplitNode, newPaneID: UUID?) { switch self { case let .pane(p) where p.id == paneID: // Inherit the source pane's session slug so the new sibling groups // under the same project in `zmx ls`. - let newPane = Pane(projectPath: projectPath, projectID: projectID, sessionSlug: p.sessionSlug) + let newPane = Pane( + projectPath: projectPath, projectID: projectID, sessionSlug: p.sessionSlug, command: command + ) let first: SplitNode = position == .first ? .pane(newPane) : .pane(p) let second: SplitNode = position == .first ? .pane(p) : .pane(newPane) return (.split(SplitBranch(direction: direction, first: first, second: second)), newPane.id) @@ -675,7 +687,8 @@ extension SplitNode { direction: direction, position: position, projectPath: projectPath, - projectID: projectID + projectID: projectID, + command: command ) branch.first = newFirst if id1 != nil { return (.split(branch), id1) } @@ -684,7 +697,8 @@ extension SplitNode { direction: direction, position: position, projectPath: projectPath, - projectID: projectID + projectID: projectID, + command: command ) branch.second = newSecond return (.split(branch), id2) diff --git a/Macterm/Model/Workspace.swift b/Macterm/Model/Workspace.swift index 2360c06..b8cb23e 100644 --- a/Macterm/Model/Workspace.swift +++ b/Macterm/Model/Workspace.swift @@ -64,9 +64,9 @@ final class TerminalTab: Identifiable { return didAcknowledge } - init(projectPath: String, projectID: UUID, sessionSlug: String? = nil) { + init(projectPath: String, projectID: UUID, sessionSlug: String? = nil, command: String? = nil) { id = UUID() - let pane = Pane(projectPath: projectPath, projectID: projectID, sessionSlug: sessionSlug) + let pane = Pane(projectPath: projectPath, projectID: projectID, sessionSlug: sessionSlug, command: command) splitRoot = .pane(pane) focusedPaneID = pane.id } @@ -100,8 +100,10 @@ final class TerminalTab: Identifiable { /// Split the focused pane (or a specific pane) in `direction`, placing the /// new pane in the `.second` position. Returns the new pane ID if created. + /// A `command` spawns in the new pane via libghostty's `initial_input` + /// (the layout `run:` path — typed into the fresh shell verbatim). @discardableResult - func split(paneID: UUID, direction: SplitDirection) -> UUID? { + func split(paneID: UUID, direction: SplitDirection, command: String? = nil) -> UUID? { let pane = splitRoot.findPane(id: paneID) // Inherit the source pane's cwd. Prefer the shell's OSC 7-reported pwd // (most accurate when shell integration is active), then fall back to @@ -112,7 +114,12 @@ final class TerminalTab: Identifiable { let sourcePath = livePwd ?? pane?.projectPath ?? NSHomeDirectory() let sourceProjectID = pane?.projectID ?? UUID() let (newRoot, newID) = splitRoot.splitting( - paneID: paneID, direction: direction, position: .second, projectPath: sourcePath, projectID: sourceProjectID + paneID: paneID, + direction: direction, + position: .second, + projectPath: sourcePath, + projectID: sourceProjectID, + command: command ) splitRoot = newRoot // Splitting reveals a new pane — exit zoom so it's visible. @@ -122,6 +129,42 @@ final class TerminalTab: Identifiable { return newID } + /// Split a pane into a `rows`×`columns` grid of equal cells (row-major), + /// optionally spawning `command` in every NEW pane. The source pane + /// becomes the top-left cell and keeps its running shell — a command + /// can only be injected at spawn (`initial_input`), so the caller runs + /// text into it separately if needed. Returns the new pane IDs. + @discardableResult + func makeGrid(paneID: UUID, rows: Int, columns: Int, command: String? = nil) -> [UUID] { + guard rows >= 1, columns >= 1, rows * columns > 1, + splitRoot.findPane(id: paneID) != nil + else { return [] } + var created: [UUID] = [] + // Build the row column first (splitting the newest pane each time + // keeps top-to-bottom order), then widen each row left-to-right. + var rowHeads = [paneID] + for _ in 1 ..< rows { + guard let previous = rowHeads.last, + let newID = split(paneID: previous, direction: .vertical, command: command) + else { break } + rowHeads.append(newID) + created.append(newID) + } + for head in rowHeads { + var current = head + for _ in 1 ..< columns { + guard let newID = split(paneID: current, direction: .horizontal, command: command) else { break } + created.append(newID) + current = newID + } + } + // Equal cells regardless of the auto-tiling preference — a grid is + // an explicit request for uniformity. + splitRoot = splitRoot.rebalanced() + focusPane(paneID) + return created + } + /// Split the focused pane along its longer on-screen axis (Ghostty's /// `new_split` / BSP behavior): a wide pane splits left/right, a tall pane /// splits top/bottom. Falls back to a horizontal split when the focused @@ -236,8 +279,8 @@ final class Workspace: Identifiable { } @discardableResult - func createTab(projectPath: String) -> TerminalTab { - let tab = TerminalTab(projectPath: projectPath, projectID: projectID) + func createTab(projectPath: String, command: String? = nil) -> TerminalTab { + let tab = TerminalTab(projectPath: projectPath, projectID: projectID, command: command) tabs.append(tab) if let current = activeTabID { tabHistory.push(current) } activeTabID = tab.id diff --git a/Macterm/Views/Terminal/GhosttyTerminalNSView.swift b/Macterm/Views/Terminal/GhosttyTerminalNSView.swift index e63cca3..3b5c5bd 100644 --- a/Macterm/Views/Terminal/GhosttyTerminalNSView.swift +++ b/Macterm/Views/Terminal/GhosttyTerminalNSView.swift @@ -1001,6 +1001,31 @@ final class GhosttyTerminalNSView: NSView { } } +// MARK: - Programmatic text input + +extension GhosttyTerminalNSView { + /// Write text to the terminal as if pasted — the same `ghostty_surface_key` + /// text path `insertText` takes outside a key event, bypassing keyboard + /// handling entirely. The control CLI's `pane run` uses this to type a + /// command (plus newline) into a live shell. Returns false when the + /// surface doesn't exist yet, so callers can report the miss instead of + /// silently dropping input. + @discardableResult + func sendText(_ text: String) -> Bool { + guard let surface, !text.isEmpty else { return false } + // Same liveness signal a keystroke sends (execution tracking + poll + // resume), so an injected command updates the tab title promptly. + onInteraction?() + text.withCString { ptr in + var ke = ghostty_input_key_s() + ke.action = GHOSTTY_ACTION_PRESS + ke.text = ptr + _ = ghostty_surface_key(surface, ke) + } + return true + } +} + // MARK: - NSTextInputClient extension GhosttyTerminalNSView: @preconcurrency NSTextInputClient { diff --git a/MactermTests/Control/ControlHandlerTests.swift b/MactermTests/Control/ControlHandlerTests.swift index ee59649..145a468 100644 --- a/MactermTests/Control/ControlHandlerTests.swift +++ b/MactermTests/Control/ControlHandlerTests.swift @@ -283,4 +283,291 @@ struct ControlHandlerTests { let empty = await handler.handle(request("session.info")) #expect(empty.error?.code == .badRequest) } + + // MARK: - project.create / project.select + + @Test + func project_create_adds_and_optionally_selects() async throws { + let (handler, appState, projectStore) = makeHandler() + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("macterm-create-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + + let created = await handler.handle(request( + "project.create", args: ControlArgs(path: dir.path, name: "fresh", select: true) + )) + #expect(created.ok) + let info = created.data?.projects?.first + #expect(info?.name == "fresh") + #expect(info?.active == true) + #expect(info?.loaded == true) + #expect(projectStore.projects.count == 1) + #expect(appState.activeProjectID?.uuidString == info?.id) + + // Idempotent: same path returns the existing project, adds nothing. + let again = await handler.handle(request("project.create", args: ControlArgs(path: dir.path))) + #expect(again.data?.projects?.first?.id == info?.id) + #expect(projectStore.projects.count == 1) + } + + @Test + func project_create_rejects_bad_paths() async { + let (handler, _, projectStore) = makeHandler() + let relative = await handler.handle(request("project.create", args: ControlArgs(path: "dev/api"))) + #expect(relative.error?.code == .badRequest) + let remote = await handler.handle(request("project.create", args: ControlArgs(path: "host:~/dev/api"))) + #expect(remote.error?.code == .badRequest) + #expect(remote.error?.message.contains("#104") == true) + let missing = await handler.handle(request( + "project.create", args: ControlArgs(path: "/nonexistent-\(UUID().uuidString)") + )) + #expect(missing.error?.code == .notFound) + let empty = await handler.handle(request("project.create")) + #expect(empty.error?.code == .badRequest) + #expect(projectStore.projects.isEmpty) + } + + @Test + func project_select_switches_active() async { + let (handler, appState, projectStore) = makeHandler() + _ = seedProject(appState, projectStore, name: "one") + let two = seedProject(appState, projectStore, name: "two", select: false) + let response = await handler.handle(request("project.select", args: ControlArgs(project: "two"))) + #expect(response.ok) + #expect(appState.activeProjectID == two.id) + + let empty = await handler.handle(request("project.select")) + #expect(empty.error?.code == .badRequest) + } + + // MARK: - tab.new / tab.select / tab.close + + @Test + func tab_new_creates_selects_and_reports() async throws { + let (handler, appState, projectStore) = makeHandler() + let project = seedProject(appState, projectStore) + let response = await handler.handle(request("tab.new", args: ControlArgs(run: "btop"))) + #expect(response.ok) + let info = try #require(response.data?.tabs?.first) + #expect(info.index == 2) + #expect(info.active == true) + let workspace = try #require(appState.workspaces[project.id]) + #expect(workspace.tabs.count == 2) + // The declared command reaches the new tab's pane (spawns via + // initial_input when the surface is created). + #expect(workspace.tabs.last?.splitRoot.allPanes().first?.command == "btop") + } + + @Test + func tab_select_activates_by_index() async throws { + let (handler, appState, projectStore) = makeHandler() + let project = seedProject(appState, projectStore) + appState.createTab(projectID: project.id, projectPath: project.path) + let workspace = try #require(appState.workspaces[project.id]) + let firstID = try #require(workspace.tabs.first?.id) + + let response = await handler.handle(request("tab.select", args: ControlArgs(tab: "tab:1"))) + #expect(response.ok) + #expect(workspace.activeTabID == firstID) + + let empty = await handler.handle(request("tab.select")) + #expect(empty.error?.code == .badRequest) + } + + @Test + func tab_close_removes_tab_and_kills_sessions() async throws { + let (handler, appState, projectStore) = makeHandler() + let project = seedProject(appState, projectStore) + appState.createTab(projectID: project.id, projectPath: project.path) + let workspace = try #require(appState.workspaces[project.id]) + #expect(workspace.tabs.count == 2) + + let response = await handler.handle(request("tab.close", args: ControlArgs(tab: "tab:2"))) + #expect(response.ok) + #expect(workspace.tabs.count == 1) + + let empty = await handler.handle(request("tab.close")) + #expect(empty.error?.code == .badRequest) + } + + // MARK: - pane.split / pane.focus / pane.close / pane.run + + @Test + func pane_split_directions_and_command() async throws { + let (handler, appState, projectStore) = makeHandler() + let project = seedProject(appState, projectStore) + let tab = try #require(appState.workspaces[project.id]?.activeTab) + + let right = await handler.handle(request( + "pane.split", args: ControlArgs(run: "yes", direction: "right") + )) + #expect(right.ok) + let newInfo = try #require(right.data?.panes?.first) + #expect(tab.splitRoot.allPanes().count == 2) + let newID = try #require(UUID(uuidString: newInfo.id)) + let newPane = try #require(tab.splitRoot.findPane(id: newID)) + #expect(newPane.command == "yes") + + let bogus = await handler.handle(request("pane.split", args: ControlArgs(direction: "sideways"))) + #expect(bogus.error?.code == .badRequest) + + // Headless auto (no NSView bounds) falls back to horizontal. + let auto = await handler.handle(request("pane.split", args: ControlArgs(direction: "auto"))) + #expect(auto.ok) + #expect(tab.splitRoot.allPanes().count == 3) + } + + @Test + func pane_split_targets_session_selector() async throws { + let (handler, appState, projectStore) = makeHandler() + let project = seedProject(appState, projectStore) + let tab = try #require(appState.workspaces[project.id]?.activeTab) + let source = try #require(tab.splitRoot.allPanes().first) + + let response = await handler.handle(request( + "pane.split", args: ControlArgs(session: source.sessionName, direction: "down") + )) + #expect(response.ok) + #expect(tab.splitRoot.allPanes().count == 2) + + let both = await handler.handle(request( + "pane.split", args: ControlArgs(pane: "pane:1", session: source.sessionName, direction: "down") + )) + #expect(both.error?.code == .badRequest) + + let unknown = await handler.handle(request( + "pane.split", args: ControlArgs(session: "macterm-nope-000000000000", direction: "down") + )) + #expect(unknown.error?.code == .notFound) + } + + @Test + func pane_focus_selects_pane_across_tabs() async throws { + let (handler, appState, projectStore) = makeHandler() + let project = seedProject(appState, projectStore) + let workspace = try #require(appState.workspaces[project.id]) + let firstTab = try #require(workspace.tabs.first) + let firstPane = try #require(firstTab.splitRoot.allPanes().first) + appState.createTab(projectID: project.id, projectPath: project.path) + #expect(workspace.activeTabID != firstTab.id) + + let response = await handler.handle(request( + "pane.focus", args: ControlArgs(pane: firstPane.id.uuidString) + )) + #expect(response.ok) + #expect(workspace.activeTabID == firstTab.id) + #expect(firstTab.focusedPaneID == firstPane.id) + } + + @Test + func pane_close_requires_explicit_target() async throws { + let (handler, appState, projectStore) = makeHandler() + let project = seedProject(appState, projectStore) + let tab = try #require(appState.workspaces[project.id]?.activeTab) + appState.splitPane(direction: .horizontal, projectID: project.id) + #expect(tab.splitRoot.allPanes().count == 2) + + let bare = await handler.handle(request("pane.close")) + #expect(bare.error?.code == .badRequest) + + let second = try #require(tab.splitRoot.allPanes().last) + let response = await handler.handle(request( + "pane.close", args: ControlArgs(pane: second.id.uuidString) + )) + #expect(response.ok) + #expect(tab.splitRoot.allPanes().count == 1) + } + + @Test + func pane_run_without_surface_is_no_surface() async { + let (handler, appState, projectStore) = makeHandler() + _ = seedProject(appState, projectStore) + // Headless test panes never create an NSView/surface, so this is the + // no-surface path; the live path is covered by manual verification. + let response = await handler.handle(request("pane.run", args: ControlArgs(run: "echo hi"))) + #expect(response.error?.code == .noSurface) + + let empty = await handler.handle(request("pane.run")) + #expect(empty.error?.code == .badRequest) + } + + // MARK: - grid + + @Test + func grid_builds_cells_and_reports_created_panes() async throws { + let (handler, appState, projectStore) = makeHandler() + let project = seedProject(appState, projectStore) + let tab = try #require(appState.workspaces[project.id]?.activeTab) + + let response = await handler.handle(request( + "grid", args: ControlArgs(run: "yes", rows: 2, cols: 2) + )) + #expect(response.ok) + #expect(response.data?.panes?.count == 3) + #expect(tab.splitRoot.allPanes().count == 4) + + let degenerate = await handler.handle(request("grid", args: ControlArgs(rows: 1, cols: 1))) + #expect(degenerate.error?.code == .badRequest) + + let huge = await handler.handle(request("grid", args: ControlArgs(rows: 10, cols: 10))) + #expect(huge.error?.code == .badRequest) + } + + // MARK: - session.kill + + @Test + func session_kill_verifies_then_kills() async { + let (handler, appState, _) = makeHandler() + let killed = KilledNames() + appState.zmx = ZmxClient( + executableURL: { nil }, + isBundled: { true }, + killSession: { name in await killed.append(name) }, + listSessionsWithClients: { [.init(name: "macterm-x-000011112222", clients: 0)] }, + sessionLeaderPIDs: { [:] } + ) + + let miss = await handler.handle(request("session.kill", args: ControlArgs(session: "macterm-y-000011112222"))) + #expect(miss.error?.code == .notFound) + #expect(await killed.names.isEmpty) + + let hit = await handler.handle(request("session.kill", args: ControlArgs(session: "macterm-x-000011112222"))) + #expect(hit.ok) + #expect(await killed.names == ["macterm-x-000011112222"]) + } + + // MARK: - layout.apply / layout.save + + @Test + func layout_save_then_apply_round_trips() async throws { + let (handler, appState, projectStore) = makeHandler() + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("macterm-layout-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let project = Project(name: "roundtrip", path: dir.path, sortOrder: 0) + projectStore.add(project) + appState.selectProject(project) + + // No file yet → apply reports the miss. + let before = await handler.handle(request("layout.apply")) + #expect(before.error?.code == .notFound) + + let saved = await handler.handle(request("layout.save")) + #expect(saved.ok) + #expect(appState.projectFiles.find(forProjectPath: dir.path) != nil) + + // Reconciling the unchanged workspace against its own save is + // non-destructive: applies cleanly without --force. + let applied = await handler.handle(request("layout.apply")) + #expect(applied.ok) + #expect(appState.pendingLayoutApply == nil) + } +} + +/// Actor recording killed session names (kills hop through async closures). +private actor KilledNames { + private(set) var names: Set = [] + func append(_ name: String) { + names.insert(name) + } } diff --git a/MactermTests/Model/TerminalTabTests.swift b/MactermTests/Model/TerminalTabTests.swift index a274ee4..2ad0950 100644 --- a/MactermTests/Model/TerminalTabTests.swift +++ b/MactermTests/Model/TerminalTabTests.swift @@ -378,4 +378,57 @@ struct TerminalTabTests { #expect(tab.focusedPaneID != nil) #expect(try remaining.contains(#require(tab.focusedPaneID))) } + + // MARK: - split with command + + @Test + func split_threads_command_into_new_pane() throws { + let (tab, ids) = makeTab(H(pane("a"), pane("b")), focused: "a") + let aID = try #require(ids["a"]) + let newID = try #require(tab.split(paneID: aID, direction: .vertical, command: "btop")) + let newPane = try #require(tab.splitRoot.findPane(id: newID)) + #expect(newPane.command == "btop") + // The source pane's command is untouched. + let sourcePane = try #require(tab.splitRoot.findPane(id: aID)) + #expect(sourcePane.command == nil) + } + + // MARK: - makeGrid + + @Test + func makeGrid_creates_rows_times_cols_panes() throws { + let tab = TerminalTab(projectPath: "/", projectID: UUID()) + let source = try #require(tab.focusedPaneID) + let created = tab.makeGrid(paneID: source, rows: 2, columns: 3, command: "yes") + #expect(created.count == 5) + #expect(tab.splitRoot.allPanes().count == 6) + // Every NEW pane carries the command; the source pane doesn't. + for id in created { + let newPane = try #require(tab.splitRoot.findPane(id: id)) + #expect(newPane.command == "yes") + } + let sourcePane = try #require(tab.splitRoot.findPane(id: source)) + #expect(sourcePane.command == nil) + // Focus returns to the source (top-left) pane. + #expect(tab.focusedPaneID == source) + } + + @Test + func makeGrid_single_column_stacks_rows() throws { + let tab = TerminalTab(projectPath: "/", projectID: UUID()) + let source = try #require(tab.focusedPaneID) + let created = tab.makeGrid(paneID: source, rows: 3, columns: 1) + #expect(created.count == 2) + #expect(tab.splitRoot.allPanes().count == 3) + } + + @Test + func makeGrid_rejects_degenerate_shapes() throws { + let tab = TerminalTab(projectPath: "/", projectID: UUID()) + let source = try #require(tab.focusedPaneID) + #expect(tab.makeGrid(paneID: source, rows: 1, columns: 1).isEmpty) + #expect(tab.makeGrid(paneID: source, rows: 0, columns: 4).isEmpty) + #expect(tab.makeGrid(paneID: UUID(), rows: 2, columns: 2).isEmpty) + #expect(tab.splitRoot.allPanes().count == 1) + } } diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..fef9dd0 --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,136 @@ +# The `macterm` CLI + +Macterm bundles a control CLI at `Macterm.app/Contents/Resources/bin/macterm`. +It drives the **running app** — projects, tabs, panes, zmx sessions — over a +local Unix socket, so AI agents, scripts, and other apps can orchestrate the +terminal (issue #107). Shells spawned by Macterm get it on `PATH` +automatically; from elsewhere, invoke it by bundle path or symlink it +somewhere on your `PATH`. + +```console +$ macterm status +Macterm 1.4.0 (pid 4242) — active project: api + +$ macterm tab new --run "npm run dev" +tab:3 * npm 1 pane + +$ macterm grid 2x2 --run "tail -f log/dev.log" +$ macterm pane run -- git pull +$ macterm session list +macterm-api-8f327ce4a3f8 clients:1 pid:4310 attached-pane +``` + +Every command takes `--json` (raw response payload, stable field names) and +`--socket ` (explicit socket override). `--help` works on every level +of the tree. + +## Commands + +| Command | Description | +|---|---| +| `status` | Liveness probe: version, pid, active project. | +| `project list` | All projects with refs (`project:1`), active/loaded markers, tab counts. | +| `project create [--name N] [--select]` | Add a project for a local directory. Idempotent by canonical path. `--select` activates it — and, on first open, applies a matching [central project file](../assets/project.schema.json), spawning its declared tabs. | +| `project select ` | Make a project active. | +| `tab list [--project P]` | Tabs of a project (default: active project). | +| `tab new [--project P] [--run CMD]` | New tab, becomes active. `--run` types CMD into the fresh shell (layout `run:` semantics). | +| `tab select ` | Activate a tab (`tab:3`, index, UUID, or exact title). | +| `tab close [--force]` | Close a tab — kills its panes' zmx sessions. Refuses with a `busy` error when a pane has a running program, unless forced. | +| `pane list [--project P] [--tab T]` | Panes with refs, session names, cwd, foreground process, focus marker. | +| `pane split [--direction right\|down\|auto] [--run CMD]` | Split a pane; the new pane inherits the source's cwd. | +| `pane focus` | Focus a pane: selects its tab, fronts the window, restores keyboard focus. | +| `pane close (--pane P \| --session S) [--force]` | Close a pane (kills its session). Always explicit — never defaults to "the pane you're in". | +| `pane run ` | Type a command (plus newline) into a live pane's shell — works on an existing shell, unlike `--run` which only applies at spawn. | +| `grid [--run CMD]` | Split a pane into an equal R×C grid (≤16 cells). `--run` spawns CMD in every **new** pane; the source pane keeps its shell. | +| `session list` / `session info ` | zmx sessions as the daemon reports them — including orphans from crashed/other instances — with attached-pane mapping. | +| `session kill ` | Kill a zmx session. An attached pane's shell exits; an orphan is reaped. | +| `layout apply [--project P] [--force]` | Reconcile the workspace to the project's central layout file. A reconcile that would close panes returns `busy` unless forced. | +| `layout save [--project P]` | Write the live workspace to `~/.config/macterm/projects/.yaml`. | + +## Targeting + +Projects and tabs accept a **name/title**, a **UUID**, or the **1-based +index** shown in list output (bare `3` or ref-style `tab:3`). Duplicate names +are an explicit `ambiguous` error, never a silent first-match. + +Pane verbs resolve their target in this order: + +1. `--session ` — the zmx session name (`macterm--`). + This is the **restart-stable** address: pane UUIDs are regenerated on + every launch, session names are persisted verbatim. +2. `--pane ` — a pane UUID (searched project-wide) or an index + within the tab scope. +3. `MACTERM_SESSION` — inside a Macterm pane, the app injects this env var, + so a bare `macterm pane split` splits *the pane you're running in* + (self-targeting). An explicit `--tab` disables this fallback. +4. Otherwise: the focused pane of the active tab. + +`--session` and `--pane` together are an error. `pane close` never uses the +env fallback — destroying "whatever pane I happen to be in" because no target +was given is a footgun; it demands an explicit target. + +## Environment + +The app exports into every spawned shell: + +- `MACTERM_SOCKET` — the control socket path. A discovery *hint*, not a pin: + if the hinted socket doesn't answer (the app restarted since this shell + spawned), the CLI falls back to the well-known locations. Only `--socket` + pins hard. +- `MACTERM_SESSION` — the pane's own session name, for self-targeting. +- `PATH` — prepended with the bundle's `Resources/bin`. + +## Exit codes and output contract + +- `0` — success. stdout carries the result (and *only* then). +- `1` — the app returned an error. stderr gets the message plus, when the + app can suggest one, a recovery hint. +- `2` — no running Macterm reachable. stderr lists every socket path tried. + +This safe-fail contract makes the CLI pipeline-safe: anything captured from +stdout is real output. + +## Wire protocol (for non-CLI clients) + +Any same-user process can speak the protocol directly; the CLI is just a +convenience. One request per connection to the Unix socket at +`~/Library/Application Support/Macterm[ Debug]/control.sock`: + +1. Connect, write a single newline-terminated JSON line, then half-close + your write end (`shutdown(fd, SHUT_WR)`): + + ```json + {"v":1,"id":"","command":"pane.split","args":{"direction":"down","run":"btop"}} + ``` + +2. Read one newline-terminated JSON line back; the server closes: + + ```json + {"v":1,"id":"","ok":true,"data":{"panes":[{"id":"…","session":"macterm-api-1a2b3c4d5e6f","index":2,…}]}} + ``` + + Failures are `{"ok":false,"error":{"code":"busy","message":"…","action":"…"}}` + with snake_case codes: `starting`, `unknown_command`, `bad_request`, + `not_found`, `ambiguous`, `busy`, `no_surface`, `internal`. + +Commands are `noun.verb` (`project.list`, `tab.new`, `pane.run`, `grid`, +`session.kill`, `layout.apply`); args is a flat object of optional fields +(see `Macterm/Control/ControlProtocol.swift`, the single source of truth +compiled into both the app and the CLI). Unknown fields are ignored on both +sides, so additive evolution never breaks a client. Debuggable by hand: +`echo '{"v":1,"id":"x","command":"status"}' | nc -U `. + +## Security + +The boundary is filesystem permissions, same-user only: the socket is mode +0600 in a 0700 directory, and the CLI refuses to connect to a socket owned by +another user. There is deliberately no token handshake — this matches the +posture of the zmx session daemon the panes already run under. If +mutually-untrusted local agents ever need scoping, the request envelope has +room for an `auth` field (Zentty's HMAC pane tokens are the model), without +breaking existing clients. + +Sessions listed by `session list` may belong to *other* Macterm instances +(debug + release share the zmx daemon); such sessions show as `orphan` here +because no pane of *this* instance is bound to them. Killing one kills it for +its real owner too — check before you reap.