From 5b00249bed18e14bdc650fa28def5b89b2400d28 Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 09:20:17 +0000 Subject: [PATCH 01/56] feat(cli): add ProwlShared/OutputMode --- ProwlShared/OutputMode.swift | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 ProwlShared/OutputMode.swift diff --git a/ProwlShared/OutputMode.swift b/ProwlShared/OutputMode.swift new file mode 100644 index 00000000..ef9441c2 --- /dev/null +++ b/ProwlShared/OutputMode.swift @@ -0,0 +1,9 @@ +// ProwlShared/OutputMode.swift +// Shared between CLI and App targets + +import Foundation + +public enum OutputMode: String, Codable, Sendable { + case text + case json +} -- 2.51.2 From bedab5837ad6755ad4632d704d903be5116276da Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 09:20:19 +0000 Subject: [PATCH 02/56] feat(cli): add ProwlShared/TargetSelector --- ProwlShared/TargetSelector.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 ProwlShared/TargetSelector.swift diff --git a/ProwlShared/TargetSelector.swift b/ProwlShared/TargetSelector.swift new file mode 100644 index 00000000..424eae2a --- /dev/null +++ b/ProwlShared/TargetSelector.swift @@ -0,0 +1,13 @@ +// ProwlShared/TargetSelector.swift +// Shared between CLI and App targets + +import Foundation + +/// Exactly zero or one selector is allowed per command. +/// Multiple selectors → INVALID_ARGUMENT. +public enum TargetSelector: Codable, Sendable, Equatable { + case none + case worktree(String) + case tab(String) + case pane(String) +} -- 2.51.2 From aa2be5c5c27d41c43e039c721f27958f70382f10 Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 09:20:21 +0000 Subject: [PATCH 03/56] feat(cli): add ProwlShared/InputModels --- ProwlShared/InputModels.swift | 67 +++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 ProwlShared/InputModels.swift diff --git a/ProwlShared/InputModels.swift b/ProwlShared/InputModels.swift new file mode 100644 index 00000000..5a2b7f32 --- /dev/null +++ b/ProwlShared/InputModels.swift @@ -0,0 +1,67 @@ +// ProwlShared/InputModels.swift +// Typed input models matching input.md contract + +import Foundation + +public struct OpenInput: Codable, Sendable { + /// Normalized absolute path, or nil for bare `prowl` (bring to front). + public let path: String? + + public init(path: String? = nil) { + self.path = path + } +} + +public struct ListInput: Codable, Sendable { + public init() {} +} + +public struct FocusInput: Codable, Sendable { + public let selector: TargetSelector + + public init(selector: TargetSelector = .none) { + self.selector = selector + } +} + +public struct SendInput: Codable, Sendable { + public let selector: TargetSelector + public let text: String + public let trailingEnter: Bool + + public init( + selector: TargetSelector = .none, + text: String, + trailingEnter: Bool = true + ) { + self.selector = selector + self.text = text + self.trailingEnter = trailingEnter + } +} + +public struct KeyInput: Codable, Sendable { + public let selector: TargetSelector + public let token: String + public let repeatCount: Int + + public init( + selector: TargetSelector = .none, + token: String, + repeatCount: Int = 1 + ) { + self.selector = selector + self.token = token + self.repeatCount = repeatCount + } +} + +public struct ReadInput: Codable, Sendable { + public let selector: TargetSelector + public let last: Int? + + public init(selector: TargetSelector = .none, last: Int? = nil) { + self.selector = selector + self.last = last + } +} -- 2.51.2 From 2dd592dd5df433a6af93c9022c67e6fc72ea6531 Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 09:20:23 +0000 Subject: [PATCH 04/56] feat(cli): add ProwlShared/CommandEnvelope --- ProwlShared/CommandEnvelope.swift | 34 +++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 ProwlShared/CommandEnvelope.swift diff --git a/ProwlShared/CommandEnvelope.swift b/ProwlShared/CommandEnvelope.swift new file mode 100644 index 00000000..073f346f --- /dev/null +++ b/ProwlShared/CommandEnvelope.swift @@ -0,0 +1,34 @@ +// ProwlShared/CommandEnvelope.swift +// The handoff contract between CLI parser and app command service. + +import Foundation + +public struct CommandEnvelope: Codable, Sendable { + public let output: OutputMode + public let command: Command + + public init(output: OutputMode, command: Command) { + self.output = output + self.command = command + } +} + +public enum Command: Codable, Sendable { + case open(OpenInput) + case list(ListInput) + case focus(FocusInput) + case send(SendInput) + case key(KeyInput) + case read(ReadInput) + + public var name: String { + switch self { + case .open: "open" + case .list: "list" + case .focus: "focus" + case .send: "send" + case .key: "key" + case .read: "read" + } + } +} -- 2.51.2 From 8efdd995a61897ec347cf0494e3b331671fd786f Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 09:20:25 +0000 Subject: [PATCH 05/56] feat(cli): add ProwlShared/CommandResponse --- ProwlShared/CommandResponse.swift | 108 ++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 ProwlShared/CommandResponse.swift diff --git a/ProwlShared/CommandResponse.swift b/ProwlShared/CommandResponse.swift new file mode 100644 index 00000000..6a9558e8 --- /dev/null +++ b/ProwlShared/CommandResponse.swift @@ -0,0 +1,108 @@ +// ProwlShared/CommandResponse.swift +// Structured response from app command service back to CLI. + +import Foundation + +public struct CommandResponse: Codable, Sendable { + public let ok: Bool + public let command: String + public let schemaVersion: String + public let data: AnyCodable? + public let error: CommandError? + + public init( + ok: Bool, + command: String, + schemaVersion: String, + data: AnyCodable? = nil, + error: CommandError? = nil + ) { + self.ok = ok + self.command = command + self.schemaVersion = schemaVersion + self.data = data + self.error = error + } + + enum CodingKeys: String, CodingKey { + case ok + case command + case schemaVersion = "schema_version" + case data + case error + } +} + +public struct CommandError: Codable, Sendable { + public let code: String + public let message: String + public let details: AnyCodable? + + public init(code: String, message: String, details: AnyCodable? = nil) { + self.code = code + self.message = message + self.details = details + } +} + +// MARK: - AnyCodable (lightweight type-erased Codable wrapper) + +public struct AnyCodable: Codable, Sendable { + public let value: Any + + public init(_ value: Any) { + self.value = value + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + value = NSNull() + } else if let bool = try? container.decode(Bool.self) { + value = bool + } else if let int = try? container.decode(Int.self) { + value = int + } else if let double = try? container.decode(Double.self) { + value = double + } else if let string = try? container.decode(String.self) { + value = string + } else if let array = try? container.decode([AnyCodable].self) { + value = array.map(\.value) + } else if let dict = try? container.decode([String: AnyCodable].self) { + value = dict.mapValues(\.value) + } else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Unsupported AnyCodable type" + ) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch value { + case is NSNull: + try container.encodeNil() + case let bool as Bool: + try container.encode(bool) + case let int as Int: + try container.encode(int) + case let double as Double: + try container.encode(double) + case let string as String: + try container.encode(string) + case let array as [Any]: + try container.encode(array.map { AnyCodable($0) }) + case let dict as [String: Any]: + try container.encode(dict.mapValues { AnyCodable($0) }) + default: + throw EncodingError.invalidValue( + value, + EncodingError.Context( + codingPath: container.codingPath, + debugDescription: "Unsupported AnyCodable type: \(type(of: value))" + ) + ) + } + } +} -- 2.51.2 From 98efdeccd4b91053feee619676c69428b771567c Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 09:20:27 +0000 Subject: [PATCH 06/56] feat(cli): add ProwlShared/ErrorCodes --- ProwlShared/ErrorCodes.swift | 42 ++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 ProwlShared/ErrorCodes.swift diff --git a/ProwlShared/ErrorCodes.swift b/ProwlShared/ErrorCodes.swift new file mode 100644 index 00000000..6fa6038e --- /dev/null +++ b/ProwlShared/ErrorCodes.swift @@ -0,0 +1,42 @@ +// ProwlShared/ErrorCodes.swift +// Stable error codes matching schema.md contracts. + +import Foundation + +public enum CLIErrorCode { + // Common + public static let appNotRunning = "APP_NOT_RUNNING" + public static let invalidArgument = "INVALID_ARGUMENT" + public static let targetNotFound = "TARGET_NOT_FOUND" + public static let targetNotUnique = "TARGET_NOT_UNIQUE" + + // Open + public static let pathNotFound = "PATH_NOT_FOUND" + public static let pathNotDirectory = "PATH_NOT_DIRECTORY" + public static let pathNotAllowed = "PATH_NOT_ALLOWED" + public static let launchFailed = "LAUNCH_FAILED" + public static let openFailed = "OPEN_FAILED" + + // List + public static let listFailed = "LIST_FAILED" + + // Focus + public static let focusFailed = "FOCUS_FAILED" + + // Send + public static let emptyInput = "EMPTY_INPUT" + public static let sendFailed = "SEND_FAILED" + + // Key + public static let invalidRepeat = "INVALID_REPEAT" + public static let noActivePane = "NO_ACTIVE_PANE" + public static let unsupportedKey = "UNSUPPORTED_KEY" + public static let keyDeliveryFailed = "KEY_DELIVERY_FAILED" + + // Read + public static let readFailed = "READ_FAILED" + + // Transport + public static let transportFailed = "TRANSPORT_FAILED" + public static let timeout = "TIMEOUT" +} -- 2.51.2 From 327209e6ee994c75e73376ffee8534148688b2d0 Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 09:20:29 +0000 Subject: [PATCH 07/56] feat(cli): add ProwlShared/SocketConstants --- ProwlShared/SocketConstants.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 ProwlShared/SocketConstants.swift diff --git a/ProwlShared/SocketConstants.swift b/ProwlShared/SocketConstants.swift new file mode 100644 index 00000000..09231dc5 --- /dev/null +++ b/ProwlShared/SocketConstants.swift @@ -0,0 +1,13 @@ +// ProwlShared/SocketConstants.swift +// Shared socket path convention between CLI client and app server. + +import Foundation + +public enum ProwlSocket { + /// Default Unix domain socket path. + /// Located in user's temporary directory to avoid permission issues. + public static var defaultPath: String { + let tmpDir = NSTemporaryDirectory() + return (tmpDir as NSString).appendingPathComponent("prowl-cli.sock") + } +} -- 2.51.2 From 8dfa1138cfca9c64c6b14af38f825240aa85844e Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 09:20:47 +0000 Subject: [PATCH 08/56] feat(cli): add ProwlCLI entry point --- ProwlCLI/main.swift | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 ProwlCLI/main.swift diff --git a/ProwlCLI/main.swift b/ProwlCLI/main.swift new file mode 100644 index 00000000..d754c263 --- /dev/null +++ b/ProwlCLI/main.swift @@ -0,0 +1,5 @@ +// ProwlCLI/main.swift + +import ArgumentParser + +ProwlCommand.main() -- 2.51.2 From 7b45e3bd43e25ec47a0c181491a6cd880fd8e7ab Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 09:20:49 +0000 Subject: [PATCH 09/56] feat(cli): add ExitError type --- ProwlCLI/ExitError.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 ProwlCLI/ExitError.swift diff --git a/ProwlCLI/ExitError.swift b/ProwlCLI/ExitError.swift new file mode 100644 index 00000000..fa98fab4 --- /dev/null +++ b/ProwlCLI/ExitError.swift @@ -0,0 +1,11 @@ +// ProwlCLI/ExitError.swift +// CLI-specific error that carries an error code for JSON output. + +import Foundation + +struct ExitError: Error, CustomStringConvertible { + let code: String + let message: String + + var description: String { message } +} -- 2.51.2 From 3e9e5a10823ca8ffadeaefeb73a6da0f60ce3cad Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 09:20:51 +0000 Subject: [PATCH 10/56] feat(cli): add CLIRunner --- ProwlCLI/CLIRunner.swift | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 ProwlCLI/CLIRunner.swift diff --git a/ProwlCLI/CLIRunner.swift b/ProwlCLI/CLIRunner.swift new file mode 100644 index 00000000..032af1ae --- /dev/null +++ b/ProwlCLI/CLIRunner.swift @@ -0,0 +1,38 @@ +// ProwlCLI/CLIRunner.swift +// Central execution point: send envelope to app, render response. + +import Foundation + +enum CLIRunner { + /// Execute a command envelope by sending it to the running app + /// and rendering the response. + static func execute(_ envelope: CommandEnvelope) throws { + do { + let responseData = try SocketTransportClient.send(envelope) + let decoder = JSONDecoder() + let response = try decoder.decode(CommandResponse.self, from: responseData) + OutputRenderer.render(response, mode: envelope.output) + if !response.ok { + throw ExitCode.failure + } + } catch let error as ExitError { + OutputRenderer.renderError( + code: error.code, + message: error.message, + command: envelope.command.name, + mode: envelope.output + ) + throw ExitCode.failure + } catch is ExitCode { + throw ExitCode.failure + } catch { + OutputRenderer.renderError( + code: CLIErrorCode.transportFailed, + message: error.localizedDescription, + command: envelope.command.name, + mode: envelope.output + ) + throw ExitCode.failure + } + } +} -- 2.51.2 From e71d6f3c3bbff5bdb347ba5d357cabe39c824760 Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 09:20:53 +0000 Subject: [PATCH 11/56] feat(cli): add root ProwlCommand --- ProwlCLI/Commands/ProwlCommand.swift | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 ProwlCLI/Commands/ProwlCommand.swift diff --git a/ProwlCLI/Commands/ProwlCommand.swift b/ProwlCLI/Commands/ProwlCommand.swift new file mode 100644 index 00000000..4a3091f7 --- /dev/null +++ b/ProwlCLI/Commands/ProwlCommand.swift @@ -0,0 +1,28 @@ +// ProwlCLI/Commands/ProwlCommand.swift +// Root command with bare path entry detection. + +import ArgumentParser +import Foundation + +struct ProwlCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "prowl", + abstract: "Control a running Prowl instance from the command line.", + version: ProwlVersion.current, + subcommands: [ + OpenCommand.self, + ListCommand.self, + FocusCommand.self, + SendCommand.self, + KeyCommand.self, + ReadCommand.self, + ], + defaultSubcommand: OpenCommand.self + ) +} + +// MARK: - Version + +enum ProwlVersion { + static let current = "1.0.0-dev" +} -- 2.51.2 From 41db3d385ea3403192b58d07ee1a99dc369ee16a Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 09:20:55 +0000 Subject: [PATCH 12/56] feat(cli): add SelectorOptions --- ProwlCLI/Commands/SelectorOptions.swift | 29 +++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 ProwlCLI/Commands/SelectorOptions.swift diff --git a/ProwlCLI/Commands/SelectorOptions.swift b/ProwlCLI/Commands/SelectorOptions.swift new file mode 100644 index 00000000..f8561ad7 --- /dev/null +++ b/ProwlCLI/Commands/SelectorOptions.swift @@ -0,0 +1,29 @@ +// ProwlCLI/Commands/SelectorOptions.swift +// Shared target selector options for commands that support them. + +import ArgumentParser + +struct SelectorOptions: ParsableArguments { + @Option(name: .long, help: "Target worktree by id, name, or path.") + var worktree: String? + + @Option(name: .long, help: "Target tab by id.") + var tab: String? + + @Option(name: .long, help: "Target pane by id.") + var pane: String? + + /// Validate mutual exclusivity and return typed selector. + func resolve() throws -> TargetSelector { + let provided = [worktree, tab, pane].compactMap { $0 } + guard provided.count <= 1 else { + throw ValidationError( + "At most one target selector (--worktree, --tab, --pane) is allowed." + ) + } + if let w = worktree { return .worktree(w) } + if let t = tab { return .tab(t) } + if let p = pane { return .pane(p) } + return .none + } +} -- 2.51.2 From c1980bbc093943a65f8a6ae20096114230092f1c Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 09:20:57 +0000 Subject: [PATCH 13/56] feat(cli): add GlobalOptions --- ProwlCLI/Commands/GlobalOptions.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 ProwlCLI/Commands/GlobalOptions.swift diff --git a/ProwlCLI/Commands/GlobalOptions.swift b/ProwlCLI/Commands/GlobalOptions.swift new file mode 100644 index 00000000..6119ee34 --- /dev/null +++ b/ProwlCLI/Commands/GlobalOptions.swift @@ -0,0 +1,13 @@ +// ProwlCLI/Commands/GlobalOptions.swift +// Shared output options. + +import ArgumentParser + +struct GlobalOptions: ParsableArguments { + @Flag(name: .long, help: "Output in JSON format matching schema contracts.") + var json = false + + var outputMode: OutputMode { + json ? .json : .text + } +} -- 2.51.2 From 342632d6393f3cd63c233ba88650dc80abbcfca9 Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 09:20:59 +0000 Subject: [PATCH 14/56] feat(cli): add OpenCommand --- ProwlCLI/Commands/OpenCommand.swift | 46 +++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 ProwlCLI/Commands/OpenCommand.swift diff --git a/ProwlCLI/Commands/OpenCommand.swift b/ProwlCLI/Commands/OpenCommand.swift new file mode 100644 index 00000000..7b04defb --- /dev/null +++ b/ProwlCLI/Commands/OpenCommand.swift @@ -0,0 +1,46 @@ +// ProwlCLI/Commands/OpenCommand.swift + +import ArgumentParser +import Foundation + +struct OpenCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "open", + abstract: "Open a path in Prowl, or bring the app to front." + ) + + @OptionGroup var options: GlobalOptions + + @Argument(help: "Path to open. Omit to bring Prowl to front.") + var path: String? + + mutating func run() throws { + let resolvedPath: String? = try path.map { try normalizePath($0) } + let envelope = CommandEnvelope( + output: options.outputMode, + command: .open(OpenInput(path: resolvedPath)) + ) + try CLIRunner.execute(envelope) + } + + private func normalizePath(_ raw: String) throws -> String { + let expanded = NSString(string: raw).expandingTildeInPath + let url = URL(fileURLWithPath: expanded).standardized + let abs = url.path + let fm = FileManager.default + var isDir: ObjCBool = false + guard fm.fileExists(atPath: abs, isDirectory: &isDir) else { + throw ExitError( + code: CLIErrorCode.pathNotFound, + message: "Path not found: \(raw)" + ) + } + guard isDir.boolValue else { + throw ExitError( + code: CLIErrorCode.pathNotDirectory, + message: "Not a directory: \(raw)" + ) + } + return abs + } +} -- 2.51.2 From 616558aa12f2fcf5df52395e81d1310c263b2c4e Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 09:21:01 +0000 Subject: [PATCH 15/56] feat(cli): add ListCommand --- ProwlCLI/Commands/ListCommand.swift | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 ProwlCLI/Commands/ListCommand.swift diff --git a/ProwlCLI/Commands/ListCommand.swift b/ProwlCLI/Commands/ListCommand.swift new file mode 100644 index 00000000..0ab6145c --- /dev/null +++ b/ProwlCLI/Commands/ListCommand.swift @@ -0,0 +1,20 @@ +// ProwlCLI/Commands/ListCommand.swift + +import ArgumentParser + +struct ListCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List all worktrees, tabs, and panes." + ) + + @OptionGroup var options: GlobalOptions + + mutating func run() throws { + let envelope = CommandEnvelope( + output: options.outputMode, + command: .list(ListInput()) + ) + try CLIRunner.execute(envelope) + } +} -- 2.51.2 From b78a0045cbbb456292d2a41cd564293b21f3ad13 Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 09:21:03 +0000 Subject: [PATCH 16/56] feat(cli): add FocusCommand --- ProwlCLI/Commands/FocusCommand.swift | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 ProwlCLI/Commands/FocusCommand.swift diff --git a/ProwlCLI/Commands/FocusCommand.swift b/ProwlCLI/Commands/FocusCommand.swift new file mode 100644 index 00000000..6865222a --- /dev/null +++ b/ProwlCLI/Commands/FocusCommand.swift @@ -0,0 +1,22 @@ +// ProwlCLI/Commands/FocusCommand.swift + +import ArgumentParser + +struct FocusCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "focus", + abstract: "Focus a worktree, tab, or pane and bring app to front." + ) + + @OptionGroup var selector: SelectorOptions + @OptionGroup var options: GlobalOptions + + mutating func run() throws { + let sel = try selector.resolve() + let envelope = CommandEnvelope( + output: options.outputMode, + command: .focus(FocusInput(selector: sel)) + ) + try CLIRunner.execute(envelope) + } +} -- 2.51.2 From da291ee6c4a3c9f5518f342a253797ec3de7e7e5 Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 09:21:05 +0000 Subject: [PATCH 17/56] feat(cli): add SendCommand --- ProwlCLI/Commands/SendCommand.swift | 65 +++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 ProwlCLI/Commands/SendCommand.swift diff --git a/ProwlCLI/Commands/SendCommand.swift b/ProwlCLI/Commands/SendCommand.swift new file mode 100644 index 00000000..fd2eb0db --- /dev/null +++ b/ProwlCLI/Commands/SendCommand.swift @@ -0,0 +1,65 @@ +// ProwlCLI/Commands/SendCommand.swift + +import ArgumentParser +import Foundation + +struct SendCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "send", + abstract: "Send text input to a terminal pane." + ) + + @OptionGroup var selector: SelectorOptions + @OptionGroup var options: GlobalOptions + + @Flag(name: .long, help: "Do not send trailing Enter after text.") + var noEnter = false + + @Argument(help: "Text to send. Alternatively pipe via stdin.") + var text: String? + + mutating func run() throws { + let sel = try selector.resolve() + + // Resolve input source: argv xor stdin + let inputText: String + if let argText = text { + // Check stdin is not also provided + if !isatty(fileno(stdin)) { + // stdin has data too — ambiguous + throw ExitError( + code: CLIErrorCode.invalidArgument, + message: "Cannot provide text as both argument and stdin." + ) + } + inputText = argText + } else if !isatty(fileno(stdin)) { + // Read from stdin + guard let stdinData = try? FileHandle.standardInput.readToEnd(), + let stdinText = String(data: stdinData, encoding: .utf8), + !stdinText.isEmpty + else { + throw ExitError( + code: CLIErrorCode.emptyInput, + message: "No input provided via argument or stdin." + ) + } + inputText = stdinText + } else { + throw ExitError( + code: CLIErrorCode.emptyInput, + message: "No input provided. Pass text as argument or pipe via stdin." + ) + } + + let envelope = CommandEnvelope( + output: options.outputMode, + command: .send(SendInput( + selector: sel, + text: inputText, + trailingEnter: !noEnter + )) + ) + try CLIRunner.execute(envelope) + } +} -- 2.51.2 From 853184de79a6ee44801f58d77b69dfe4f902977e Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 09:21:06 +0000 Subject: [PATCH 18/56] feat(cli): add KeyCommand --- ProwlCLI/Commands/KeyCommand.swift | 42 ++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 ProwlCLI/Commands/KeyCommand.swift diff --git a/ProwlCLI/Commands/KeyCommand.swift b/ProwlCLI/Commands/KeyCommand.swift new file mode 100644 index 00000000..48d9b9ce --- /dev/null +++ b/ProwlCLI/Commands/KeyCommand.swift @@ -0,0 +1,42 @@ +// ProwlCLI/Commands/KeyCommand.swift + +import ArgumentParser + +struct KeyCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "key", + abstract: "Send a key event to a terminal pane." + ) + + @OptionGroup var selector: SelectorOptions + @OptionGroup var options: GlobalOptions + + @Option(name: .long, help: "Number of times to repeat the key (1-100).") + var `repeat`: Int = 1 + + @Argument(help: "Key token (e.g. enter, esc, tab, ctrl-c, up, down).") + var token: String + + mutating func run() throws { + let sel = try selector.resolve() + + guard (1...100).contains(self.repeat) else { + throw ExitError( + code: CLIErrorCode.invalidRepeat, + message: "Repeat count must be between 1 and 100, got \(self.repeat)." + ) + } + + let normalized = token.lowercased() + + let envelope = CommandEnvelope( + output: options.outputMode, + command: .key(KeyInput( + selector: sel, + token: normalized, + repeatCount: self.repeat + )) + ) + try CLIRunner.execute(envelope) + } +} -- 2.51.2 From 9d0e785e53ca1663dbe99005868d8e8af56180cb Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 09:21:08 +0000 Subject: [PATCH 19/56] feat(cli): add ReadCommand --- ProwlCLI/Commands/ReadCommand.swift | 33 +++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 ProwlCLI/Commands/ReadCommand.swift diff --git a/ProwlCLI/Commands/ReadCommand.swift b/ProwlCLI/Commands/ReadCommand.swift new file mode 100644 index 00000000..31fe0508 --- /dev/null +++ b/ProwlCLI/Commands/ReadCommand.swift @@ -0,0 +1,33 @@ +// ProwlCLI/Commands/ReadCommand.swift + +import ArgumentParser + +struct ReadCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "read", + abstract: "Read terminal content from a pane." + ) + + @OptionGroup var selector: SelectorOptions + @OptionGroup var options: GlobalOptions + + @Option(name: .long, help: "Number of recent lines to read (omit for snapshot).") + var last: Int? + + mutating func run() throws { + let sel = try selector.resolve() + + if let n = last, n < 1 { + throw ExitError( + code: CLIErrorCode.invalidArgument, + message: "--last requires a positive integer, got \(n)." + ) + } + + let envelope = CommandEnvelope( + output: options.outputMode, + command: .read(ReadInput(selector: sel, last: last)) + ) + try CLIRunner.execute(envelope) + } +} -- 2.51.2 From 003e6fece9207a29b58dfe8202d01a6c2df8c217 Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 09:21:10 +0000 Subject: [PATCH 20/56] feat(cli): add SocketTransportClient --- .../Transport/SocketTransportClient.swift | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 ProwlCLI/Transport/SocketTransportClient.swift diff --git a/ProwlCLI/Transport/SocketTransportClient.swift b/ProwlCLI/Transport/SocketTransportClient.swift new file mode 100644 index 00000000..1ac27fc2 --- /dev/null +++ b/ProwlCLI/Transport/SocketTransportClient.swift @@ -0,0 +1,122 @@ +// ProwlCLI/Transport/SocketTransportClient.swift +// Unix domain socket client for communicating with running Prowl app. + +import Foundation + +enum SocketTransportClient { + /// Send a command envelope to the Prowl app and receive a response. + /// - Parameter envelope: The command to execute. + /// - Returns: Raw JSON data from the app. + static func send(_ envelope: CommandEnvelope) throws -> Data { + let socketPath = ProwlSocket.defaultPath + + // Encode request + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let requestData = try encoder.encode(envelope) + + // Create socket + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + throw ExitError( + code: CLIErrorCode.transportFailed, + message: "Failed to create socket." + ) + } + defer { close(fd) } + + // Connect + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let pathBytes = socketPath.utf8CString + guard pathBytes.count <= MemoryLayout.size(ofValue: addr.sun_path) else { + throw ExitError( + code: CLIErrorCode.transportFailed, + message: "Socket path too long." + ) + } + withUnsafeMutablePointer(to: &addr.sun_path) { ptr in + ptr.withMemoryRebound(to: CChar.self, capacity: pathBytes.count) { dest in + for (i, byte) in pathBytes.enumerated() { + dest[i] = byte + } + } + } + + let connectResult = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in + connect(fd, sockPtr, socklen_t(MemoryLayout.size)) + } + } + + guard connectResult == 0 else { + throw ExitError( + code: CLIErrorCode.appNotRunning, + message: "Cannot connect to Prowl. Is the app running?" + ) + } + + // Send length-prefixed request: 4-byte big-endian length + JSON payload + var length = UInt32(requestData.count).bigEndian + let lengthData = Data(bytes: &length, count: 4) + try socketWrite(fd: fd, data: lengthData) + try socketWrite(fd: fd, data: requestData) + + // Read length-prefixed response + let responseLengthData = try socketRead(fd: fd, count: 4) + let responseLength = responseLengthData.withUnsafeBytes { + UInt32(bigEndian: $0.load(as: UInt32.self)) + } + + guard responseLength > 0, responseLength < 10_000_000 else { + throw ExitError( + code: CLIErrorCode.transportFailed, + message: "Invalid response length from app." + ) + } + + return try socketRead(fd: fd, count: Int(responseLength)) + } + + // MARK: - Helpers + + private static func socketWrite(fd: Int32, data: Data) throws { + try data.withUnsafeBytes { buffer in + var offset = 0 + while offset < buffer.count { + let written = Foundation.write( + fd, + buffer.baseAddress!.advanced(by: offset), + buffer.count - offset + ) + guard written > 0 else { + throw ExitError( + code: CLIErrorCode.transportFailed, + message: "Socket write failed." + ) + } + offset += written + } + } + } + + private static func socketRead(fd: Int32, count: Int) throws -> Data { + var data = Data(capacity: count) + var remaining = count + let bufferSize = min(count, 65536) + var buffer = [UInt8](repeating: 0, count: bufferSize) + while remaining > 0 { + let toRead = min(remaining, bufferSize) + let bytesRead = Foundation.read(fd, &buffer, toRead) + guard bytesRead > 0 else { + throw ExitError( + code: CLIErrorCode.transportFailed, + message: "Socket read failed or connection closed." + ) + } + data.append(buffer, count: bytesRead) + remaining -= bytesRead + } + return data + } +} -- 2.51.2 From 8343e554a32aabeec5f57bc8b3915a38d54d8e47 Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 09:21:12 +0000 Subject: [PATCH 21/56] feat(cli): add OutputRenderer --- ProwlCLI/Output/OutputRenderer.swift | 47 ++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 ProwlCLI/Output/OutputRenderer.swift diff --git a/ProwlCLI/Output/OutputRenderer.swift b/ProwlCLI/Output/OutputRenderer.swift new file mode 100644 index 00000000..2337caa2 --- /dev/null +++ b/ProwlCLI/Output/OutputRenderer.swift @@ -0,0 +1,47 @@ +// ProwlCLI/Output/OutputRenderer.swift +// Renders command responses for terminal output. + +import Foundation + +enum OutputRenderer { + static func render(_ response: CommandResponse, mode: OutputMode) { + switch mode { + case .json: + renderJSON(response) + case .text: + renderText(response) + } + } + + static func renderError(code: String, message: String, command: String, mode: OutputMode) { + let response = CommandResponse( + ok: false, + command: command, + schemaVersion: "prowl.cli.\(command).v1", + error: CommandError(code: code, message: message) + ) + render(response, mode: mode) + } + + // MARK: - JSON + + private static func renderJSON(_ response: CommandResponse) { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + if let data = try? encoder.encode(response), + let jsonString = String(data: data, encoding: .utf8) + { + print(jsonString) + } + } + + // MARK: - Text + + private static func renderText(_ response: CommandResponse) { + if response.ok { + print("ok: \(response.command)") + } else if let error = response.error { + fputs("error [\(error.code)]: \(error.message)\n", stderr) + } + } +} -- 2.51.2 From 0da8ce6a566720dcd5f098e9d7dc44c07781a71d Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 09:21:24 +0000 Subject: [PATCH 22/56] feat(cli): add CommandHandler protocol --- supacode/CLIService/CommandHandlerProtocol.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 supacode/CLIService/CommandHandlerProtocol.swift diff --git a/supacode/CLIService/CommandHandlerProtocol.swift b/supacode/CLIService/CommandHandlerProtocol.swift new file mode 100644 index 00000000..4e2f8ad9 --- /dev/null +++ b/supacode/CLIService/CommandHandlerProtocol.swift @@ -0,0 +1,11 @@ +// supacode/CLIService/CommandHandlerProtocol.swift +// Protocol for command handlers on the app side. + +import Foundation + +/// Each CLI command has a corresponding handler that executes +/// within the app's process context. +protocol CommandHandler { + /// Execute the command and return a structured response. + func handle(envelope: CommandEnvelope) async -> CommandResponse +} -- 2.51.2 From 94747829f7ba2a051d243fef5e2da98154f112d8 Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 09:21:26 +0000 Subject: [PATCH 23/56] feat(cli): add CLICommandRouter --- supacode/CLIService/CLICommandRouter.swift | 61 ++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 supacode/CLIService/CLICommandRouter.swift diff --git a/supacode/CLIService/CLICommandRouter.swift b/supacode/CLIService/CLICommandRouter.swift new file mode 100644 index 00000000..b51b3913 --- /dev/null +++ b/supacode/CLIService/CLICommandRouter.swift @@ -0,0 +1,61 @@ +// supacode/CLIService/CLICommandRouter.swift +// Routes incoming command envelopes to the appropriate handler. + +import Foundation + +@MainActor +final class CLICommandRouter { + private let openHandler: any CommandHandler + private let listHandler: any CommandHandler + private let focusHandler: any CommandHandler + private let sendHandler: any CommandHandler + private let keyHandler: any CommandHandler + private let readHandler: any CommandHandler + + init( + openHandler: any CommandHandler = StubCommandHandler(command: "open"), + listHandler: any CommandHandler = StubCommandHandler(command: "list"), + focusHandler: any CommandHandler = StubCommandHandler(command: "focus"), + sendHandler: any CommandHandler = StubCommandHandler(command: "send"), + keyHandler: any CommandHandler = StubCommandHandler(command: "key"), + readHandler: any CommandHandler = StubCommandHandler(command: "read") + ) { + self.openHandler = openHandler + self.listHandler = listHandler + self.focusHandler = focusHandler + self.sendHandler = sendHandler + self.keyHandler = keyHandler + self.readHandler = readHandler + } + + func route(_ envelope: CommandEnvelope) async -> CommandResponse { + let handler: any CommandHandler + switch envelope.command { + case .open: handler = openHandler + case .list: handler = listHandler + case .focus: handler = focusHandler + case .send: handler = sendHandler + case .key: handler = keyHandler + case .read: handler = readHandler + } + return await handler.handle(envelope: envelope) + } +} + +// MARK: - Stub handler (placeholder until real handlers are implemented) + +struct StubCommandHandler: CommandHandler { + let command: String + + func handle(envelope: CommandEnvelope) async -> CommandResponse { + CommandResponse( + ok: false, + command: command, + schemaVersion: "prowl.cli.\(command).v1", + error: CommandError( + code: "NOT_IMPLEMENTED", + message: "Command '\(command)' is not yet implemented." + ) + ) + } +} -- 2.51.2 From 08f127afa663a8ab39774902f65e77312893eb80 Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 09:21:28 +0000 Subject: [PATCH 24/56] feat(cli): add CLISocketServer --- supacode/CLIService/CLISocketServer.swift | 182 ++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 supacode/CLIService/CLISocketServer.swift diff --git a/supacode/CLIService/CLISocketServer.swift b/supacode/CLIService/CLISocketServer.swift new file mode 100644 index 00000000..62a9f032 --- /dev/null +++ b/supacode/CLIService/CLISocketServer.swift @@ -0,0 +1,182 @@ +// supacode/CLIService/CLISocketServer.swift +// Unix domain socket server that listens for CLI command requests. + +import Foundation + +@MainActor +final class CLISocketServer { + private let router: CLICommandRouter + private let socketPath: String + private var serverFD: Int32 = -1 + private var isRunning = false + private var acceptTask: Task? + + init(router: CLICommandRouter, socketPath: String = ProwlSocket.defaultPath) { + self.router = router + self.socketPath = socketPath + } + + /// Start listening for CLI connections. + func start() throws { + // Clean up stale socket file + unlink(socketPath) + + // Create socket + serverFD = socket(AF_UNIX, SOCK_STREAM, 0) + guard serverFD >= 0 else { + throw CLIServiceError.socketCreationFailed + } + + // Bind + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let pathBytes = socketPath.utf8CString + guard pathBytes.count <= MemoryLayout.size(ofValue: addr.sun_path) else { + close(serverFD) + throw CLIServiceError.socketPathTooLong + } + withUnsafeMutablePointer(to: &addr.sun_path) { ptr in + ptr.withMemoryRebound(to: CChar.self, capacity: pathBytes.count) { dest in + for (i, byte) in pathBytes.enumerated() { + dest[i] = byte + } + } + } + + let bindResult = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in + bind(serverFD, sockPtr, socklen_t(MemoryLayout.size)) + } + } + + guard bindResult == 0 else { + close(serverFD) + throw CLIServiceError.bindFailed + } + + // Listen + guard listen(serverFD, 5) == 0 else { + close(serverFD) + throw CLIServiceError.listenFailed + } + + isRunning = true + + // Accept connections in background + acceptTask = Task { [weak self] in + await self?.acceptLoop() + } + } + + /// Stop the server and clean up. + func stop() { + isRunning = false + acceptTask?.cancel() + if serverFD >= 0 { + close(serverFD) + serverFD = -1 + } + unlink(socketPath) + } + + // MARK: - Accept loop + + private func acceptLoop() async { + while isRunning, !Task.isCancelled { + let clientFD = accept(serverFD, nil, nil) + guard clientFD >= 0 else { + if isRunning { + // Brief pause before retrying + try? await Task.sleep(for: .milliseconds(100)) + } + continue + } + + // Handle each client connection concurrently + Task { [weak self] in + await self?.handleClient(fd: clientFD) + } + } + } + + private func handleClient(fd clientFD: Int32) async { + defer { close(clientFD) } + + do { + // Read length-prefixed request + let lengthData = try socketRead(fd: clientFD, count: 4) + let length = lengthData.withUnsafeBytes { + UInt32(bigEndian: $0.load(as: UInt32.self)) + } + guard length > 0, length < 10_000_000 else { return } + + let requestData = try socketRead(fd: clientFD, count: Int(length)) + + // Decode envelope + let decoder = JSONDecoder() + let envelope = try decoder.decode(CommandEnvelope.self, from: requestData) + + // Route to handler + let response = await router.route(envelope) + + // Encode and send response + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let responseData = try encoder.encode(response) + + var responseLength = UInt32(responseData.count).bigEndian + let responseLengthData = Data(bytes: &responseLength, count: 4) + try socketWrite(fd: clientFD, data: responseLengthData) + try socketWrite(fd: clientFD, data: responseData) + } catch { + // Connection-level errors are silently dropped + } + } + + // MARK: - Socket I/O helpers + + private func socketRead(fd: Int32, count: Int) throws -> Data { + var data = Data(capacity: count) + var remaining = count + let bufferSize = min(count, 65536) + var buffer = [UInt8](repeating: 0, count: bufferSize) + while remaining > 0 { + let toRead = min(remaining, bufferSize) + let bytesRead = Foundation.read(fd, &buffer, toRead) + guard bytesRead > 0 else { + throw CLIServiceError.readFailed + } + data.append(buffer, count: bytesRead) + remaining -= bytesRead + } + return data + } + + private func socketWrite(fd: Int32, data: Data) throws { + try data.withUnsafeBytes { buffer in + var offset = 0 + while offset < buffer.count { + let written = Foundation.write( + fd, + buffer.baseAddress!.advanced(by: offset), + buffer.count - offset + ) + guard written > 0 else { + throw CLIServiceError.writeFailed + } + offset += written + } + } + } +} + +// MARK: - Errors + +enum CLIServiceError: Error { + case socketCreationFailed + case socketPathTooLong + case bindFailed + case listenFailed + case readFailed + case writeFailed +} -- 2.51.2 From 9fbd712e1d5cf72a7f7f13506e39c43e6d52a2df Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 09:21:30 +0000 Subject: [PATCH 25/56] feat(cli): add TargetResolver scaffold --- supacode/CLIService/TargetResolver.swift | 30 ++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 supacode/CLIService/TargetResolver.swift diff --git a/supacode/CLIService/TargetResolver.swift b/supacode/CLIService/TargetResolver.swift new file mode 100644 index 00000000..c8186c46 --- /dev/null +++ b/supacode/CLIService/TargetResolver.swift @@ -0,0 +1,30 @@ +// supacode/CLIService/TargetResolver.swift +// Resolves target selectors against current app state. +// Scaffold — actual resolution depends on wiring to WorktreeTerminalManager. + +import Foundation + +@MainActor +final class TargetResolver { + /// Resolve a target selector to concrete worktree/tab/pane IDs. + /// Returns nil if the target cannot be found. + func resolve(_ selector: TargetSelector) -> ResolvedTarget? { + // TODO: Wire to WorktreeTerminalManager to resolve against live state. + // For now, return nil (target not found) for any non-none selector. + switch selector { + case .none: + // Return current focused target + return nil // TODO: implement + case .worktree, .tab, .pane: + return nil // TODO: implement + } + } +} + +/// Placeholder for resolved target information. +/// Will be populated with actual worktree/tab/pane data when wired. +struct ResolvedTarget { + let worktreeID: String + let tabID: String + let paneID: String +} -- 2.51.2 From caceb6cd1e380da55ae4da4ef67452f9f580fb62 Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 09:27:08 +0000 Subject: [PATCH 26/56] fix: replace AnyCodable with RawJSON + JSONValue (fully Sendable, no Any) Addresses review: AnyCodable holding bare Any breaks Sendable contract. RawJSON wraps raw Data bytes; JSONValue is a recursive enum for Codable round-tripping. Both are fully Sendable. --- ProwlShared/CommandResponse.swift | 134 +++++++++++++++++++----------- 1 file changed, 86 insertions(+), 48 deletions(-) diff --git a/ProwlShared/CommandResponse.swift b/ProwlShared/CommandResponse.swift index 6a9558e8..ba6c47d3 100644 --- a/ProwlShared/CommandResponse.swift +++ b/ProwlShared/CommandResponse.swift @@ -3,18 +3,26 @@ import Foundation +/// Top-level response wrapper. +/// For v1, `data` is left as raw JSON bytes so each command can define +/// its own strongly-typed success payload without introducing `Any`. public struct CommandResponse: Codable, Sendable { public let ok: Bool public let command: String public let schemaVersion: String - public let data: AnyCodable? + + /// Raw JSON data payload (success case). Consumers decode into + /// command-specific types. Nil when `ok == false`. + public let data: RawJSON? + + /// Error payload (failure case). Nil when `ok == true`. public let error: CommandError? public init( ok: Bool, command: String, schemaVersion: String, - data: AnyCodable? = nil, + data: RawJSON? = nil, error: CommandError? = nil ) { self.ok = ok @@ -36,73 +44,103 @@ public struct CommandResponse: Codable, Sendable { public struct CommandError: Codable, Sendable { public let code: String public let message: String - public let details: AnyCodable? - public init(code: String, message: String, details: AnyCodable? = nil) { + public init(code: String, message: String) { self.code = code self.message = message - self.details = details } } -// MARK: - AnyCodable (lightweight type-erased Codable wrapper) +// MARK: - RawJSON -public struct AnyCodable: Codable, Sendable { - public let value: Any +/// A type-safe wrapper around raw JSON bytes. +/// Preserves the original JSON without round-tripping through `Any`. +/// Fully `Sendable` because it only holds `Data`. +public struct RawJSON: Codable, Sendable { + public let bytes: Data - public init(_ value: Any) { - self.value = value + public init(_ bytes: Data) { + self.bytes = bytes + } + + /// Create from an Encodable value. + public init(encoding value: T) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + self.bytes = try encoder.encode(value) } public init(from decoder: Decoder) throws { + // When decoding as part of a larger structure, capture the raw JSON. + // This works by re-encoding the decoded JSON value container. + let container = try decoder.singleValueContainer() + // Decode as a generic JSON value, then re-encode to bytes. + let jsonValue = try container.decode(JSONValue.self) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + self.bytes = try encoder.encode(jsonValue) + } + + public func encode(to encoder: Encoder) throws { + // Decode bytes back to JSONValue, then encode inline. + let decoder = JSONDecoder() + let jsonValue = try decoder.decode(JSONValue.self, from: bytes) + var container = encoder.singleValueContainer() + try container.encode(jsonValue) + } + + /// Decode the raw JSON into a specific type. + public func decode(as type: T.Type) throws -> T { + try JSONDecoder().decode(type, from: bytes) + } +} + +// MARK: - JSONValue (internal helper for RawJSON round-tripping) + +/// A simple recursive JSON value type that is fully Codable and Sendable. +enum JSONValue: Codable, Sendable { + case null + case bool(Bool) + case int(Int) + case double(Double) + case string(String) + case array([JSONValue]) + case object([String: JSONValue]) + + init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() if container.decodeNil() { - value = NSNull() - } else if let bool = try? container.decode(Bool.self) { - value = bool - } else if let int = try? container.decode(Int.self) { - value = int - } else if let double = try? container.decode(Double.self) { - value = double - } else if let string = try? container.decode(String.self) { - value = string - } else if let array = try? container.decode([AnyCodable].self) { - value = array.map(\.value) - } else if let dict = try? container.decode([String: AnyCodable].self) { - value = dict.mapValues(\.value) + self = .null + } else if let b = try? container.decode(Bool.self) { + self = .bool(b) + } else if let i = try? container.decode(Int.self) { + self = .int(i) + } else if let d = try? container.decode(Double.self) { + self = .double(d) + } else if let s = try? container.decode(String.self) { + self = .string(s) + } else if let a = try? container.decode([JSONValue].self) { + self = .array(a) + } else if let o = try? container.decode([String: JSONValue].self) { + self = .object(o) } else { throw DecodingError.dataCorruptedError( in: container, - debugDescription: "Unsupported AnyCodable type" + debugDescription: "Unsupported JSON value" ) } } - public func encode(to encoder: Encoder) throws { + func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() - switch value { - case is NSNull: - try container.encodeNil() - case let bool as Bool: - try container.encode(bool) - case let int as Int: - try container.encode(int) - case let double as Double: - try container.encode(double) - case let string as String: - try container.encode(string) - case let array as [Any]: - try container.encode(array.map { AnyCodable($0) }) - case let dict as [String: Any]: - try container.encode(dict.mapValues { AnyCodable($0) }) - default: - throw EncodingError.invalidValue( - value, - EncodingError.Context( - codingPath: container.codingPath, - debugDescription: "Unsupported AnyCodable type: \(type(of: value))" - ) - ) + switch self { + case .null: try container.encodeNil() + case .bool(let b): try container.encode(b) + case .int(let i): try container.encode(i) + case .double(let d): try container.encode(d) + case .string(let s): try container.encode(s) + case .array(let a): try container.encode(a) + case .object(let o): try container.encode(o) } } } -- 2.51.2 From 00e876c99ac419906f28dcd97746b069af12a8df Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 09:27:11 +0000 Subject: [PATCH 27/56] fix: use Darwin.read/write instead of Foundation for socket I/O Addresses review: Foundation.read/write ambiguity. Now explicitly imports Darwin and uses Darwin.read/Darwin.write for all FD operations. --- .../Transport/SocketTransportClient.swift | 70 +++++++------------ 1 file changed, 26 insertions(+), 44 deletions(-) diff --git a/ProwlCLI/Transport/SocketTransportClient.swift b/ProwlCLI/Transport/SocketTransportClient.swift index 1ac27fc2..f70709dc 100644 --- a/ProwlCLI/Transport/SocketTransportClient.swift +++ b/ProwlCLI/Transport/SocketTransportClient.swift @@ -1,12 +1,15 @@ // ProwlCLI/Transport/SocketTransportClient.swift // Unix domain socket client for communicating with running Prowl app. +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif import Foundation enum SocketTransportClient { /// Send a command envelope to the Prowl app and receive a response. - /// - Parameter envelope: The command to execute. - /// - Returns: Raw JSON data from the app. static func send(_ envelope: CommandEnvelope) throws -> Data { let socketPath = ProwlSocket.defaultPath @@ -28,18 +31,9 @@ enum SocketTransportClient { // Connect var addr = sockaddr_un() addr.sun_family = sa_family_t(AF_UNIX) - let pathBytes = socketPath.utf8CString - guard pathBytes.count <= MemoryLayout.size(ofValue: addr.sun_path) else { - throw ExitError( - code: CLIErrorCode.transportFailed, - message: "Socket path too long." - ) - } - withUnsafeMutablePointer(to: &addr.sun_path) { ptr in - ptr.withMemoryRebound(to: CChar.self, capacity: pathBytes.count) { dest in - for (i, byte) in pathBytes.enumerated() { - dest[i] = byte - } + socketPath.withCString { cstr in + withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in + _ = memcpy(pathPtr, cstr, min(strlen(cstr) + 1, MemoryLayout.size(ofValue: addr.sun_path))) } } @@ -58,12 +52,11 @@ enum SocketTransportClient { // Send length-prefixed request: 4-byte big-endian length + JSON payload var length = UInt32(requestData.count).bigEndian - let lengthData = Data(bytes: &length, count: 4) - try socketWrite(fd: fd, data: lengthData) - try socketWrite(fd: fd, data: requestData) + try withUnsafeBytes(of: &length) { try fdWrite(fd: fd, buffer: $0) } + try requestData.withUnsafeBytes { try fdWrite(fd: fd, buffer: $0) } // Read length-prefixed response - let responseLengthData = try socketRead(fd: fd, count: 4) + let responseLengthData = try fdRead(fd: fd, count: 4) let responseLength = responseLengthData.withUnsafeBytes { UInt32(bigEndian: $0.load(as: UInt32.self)) } @@ -75,46 +68,35 @@ enum SocketTransportClient { ) } - return try socketRead(fd: fd, count: Int(responseLength)) + return try fdRead(fd: fd, count: Int(responseLength)) } - // MARK: - Helpers + // MARK: - Low-level I/O using Darwin/Glibc read/write - private static func socketWrite(fd: Int32, data: Data) throws { - try data.withUnsafeBytes { buffer in - var offset = 0 - while offset < buffer.count { - let written = Foundation.write( - fd, - buffer.baseAddress!.advanced(by: offset), - buffer.count - offset - ) - guard written > 0 else { - throw ExitError( - code: CLIErrorCode.transportFailed, - message: "Socket write failed." - ) - } - offset += written + private static func fdWrite(fd: Int32, buffer: UnsafeRawBufferPointer) throws { + var offset = 0 + while offset < buffer.count { + let written = Darwin.write(fd, buffer.baseAddress!.advanced(by: offset), buffer.count - offset) + guard written > 0 else { + throw ExitError(code: CLIErrorCode.transportFailed, message: "Socket write failed.") } + offset += written } } - private static func socketRead(fd: Int32, count: Int) throws -> Data { + private static func fdRead(fd: Int32, count: Int) throws -> Data { var data = Data(capacity: count) var remaining = count let bufferSize = min(count, 65536) - var buffer = [UInt8](repeating: 0, count: bufferSize) + let buffer = UnsafeMutableRawPointer.allocate(byteCount: bufferSize, alignment: 1) + defer { buffer.deallocate() } while remaining > 0 { let toRead = min(remaining, bufferSize) - let bytesRead = Foundation.read(fd, &buffer, toRead) + let bytesRead = Darwin.read(fd, buffer, toRead) guard bytesRead > 0 else { - throw ExitError( - code: CLIErrorCode.transportFailed, - message: "Socket read failed or connection closed." - ) + throw ExitError(code: CLIErrorCode.transportFailed, message: "Socket read failed.") } - data.append(buffer, count: bytesRead) + data.append(buffer.assumingMemoryBound(to: UInt8.self), count: bytesRead) remaining -= bytesRead } return data -- 2.51.2 From 286386e7c2dac9ad3abb6e2dfb1782c6d95c85c6 Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 09:27:13 +0000 Subject: [PATCH 28/56] fix: use Darwin.read/write in CLISocketServer Same fix as transport client: explicit Darwin imports for socket I/O. --- supacode/CLIService/CLISocketServer.swift | 71 ++++++++++------------- 1 file changed, 30 insertions(+), 41 deletions(-) diff --git a/supacode/CLIService/CLISocketServer.swift b/supacode/CLIService/CLISocketServer.swift index 62a9f032..b8627403 100644 --- a/supacode/CLIService/CLISocketServer.swift +++ b/supacode/CLIService/CLISocketServer.swift @@ -1,6 +1,11 @@ // supacode/CLIService/CLISocketServer.swift // Unix domain socket server that listens for CLI command requests. +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif import Foundation @MainActor @@ -30,16 +35,9 @@ final class CLISocketServer { // Bind var addr = sockaddr_un() addr.sun_family = sa_family_t(AF_UNIX) - let pathBytes = socketPath.utf8CString - guard pathBytes.count <= MemoryLayout.size(ofValue: addr.sun_path) else { - close(serverFD) - throw CLIServiceError.socketPathTooLong - } - withUnsafeMutablePointer(to: &addr.sun_path) { ptr in - ptr.withMemoryRebound(to: CChar.self, capacity: pathBytes.count) { dest in - for (i, byte) in pathBytes.enumerated() { - dest[i] = byte - } + socketPath.withCString { cstr in + withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in + _ = memcpy(pathPtr, cstr, min(strlen(cstr) + 1, MemoryLayout.size(ofValue: addr.sun_path))) } } @@ -81,19 +79,16 @@ final class CLISocketServer { // MARK: - Accept loop - private func acceptLoop() async { - while isRunning, !Task.isCancelled { - let clientFD = accept(serverFD, nil, nil) + nonisolated private func acceptLoop() async { + while !Task.isCancelled { + let clientFD = Darwin.accept(serverFD, nil, nil) guard clientFD >= 0 else { - if isRunning { - // Brief pause before retrying + if !Task.isCancelled { try? await Task.sleep(for: .milliseconds(100)) } continue } - - // Handle each client connection concurrently - Task { [weak self] in + Task { @MainActor [weak self] in await self?.handleClient(fd: clientFD) } } @@ -104,13 +99,13 @@ final class CLISocketServer { do { // Read length-prefixed request - let lengthData = try socketRead(fd: clientFD, count: 4) + let lengthData = try Self.fdRead(fd: clientFD, count: 4) let length = lengthData.withUnsafeBytes { UInt32(bigEndian: $0.load(as: UInt32.self)) } guard length > 0, length < 10_000_000 else { return } - let requestData = try socketRead(fd: clientFD, count: Int(length)) + let requestData = try Self.fdRead(fd: clientFD, count: Int(length)) // Decode envelope let decoder = JSONDecoder() @@ -125,47 +120,41 @@ final class CLISocketServer { let responseData = try encoder.encode(response) var responseLength = UInt32(responseData.count).bigEndian - let responseLengthData = Data(bytes: &responseLength, count: 4) - try socketWrite(fd: clientFD, data: responseLengthData) - try socketWrite(fd: clientFD, data: responseData) + try withUnsafeBytes(of: &responseLength) { try Self.fdWrite(fd: clientFD, buffer: $0) } + try responseData.withUnsafeBytes { try Self.fdWrite(fd: clientFD, buffer: $0) } } catch { // Connection-level errors are silently dropped } } - // MARK: - Socket I/O helpers + // MARK: - Low-level I/O using Darwin read/write - private func socketRead(fd: Int32, count: Int) throws -> Data { + nonisolated private static func fdRead(fd: Int32, count: Int) throws -> Data { var data = Data(capacity: count) var remaining = count let bufferSize = min(count, 65536) - var buffer = [UInt8](repeating: 0, count: bufferSize) + let buffer = UnsafeMutableRawPointer.allocate(byteCount: bufferSize, alignment: 1) + defer { buffer.deallocate() } while remaining > 0 { let toRead = min(remaining, bufferSize) - let bytesRead = Foundation.read(fd, &buffer, toRead) + let bytesRead = Darwin.read(fd, buffer, toRead) guard bytesRead > 0 else { throw CLIServiceError.readFailed } - data.append(buffer, count: bytesRead) + data.append(buffer.assumingMemoryBound(to: UInt8.self), count: bytesRead) remaining -= bytesRead } return data } - private func socketWrite(fd: Int32, data: Data) throws { - try data.withUnsafeBytes { buffer in - var offset = 0 - while offset < buffer.count { - let written = Foundation.write( - fd, - buffer.baseAddress!.advanced(by: offset), - buffer.count - offset - ) - guard written > 0 else { - throw CLIServiceError.writeFailed - } - offset += written + nonisolated private static func fdWrite(fd: Int32, buffer: UnsafeRawBufferPointer) throws { + var offset = 0 + while offset < buffer.count { + let written = Darwin.write(fd, buffer.baseAddress!.advanced(by: offset), buffer.count - offset) + guard written > 0 else { + throw CLIServiceError.writeFailed } + offset += written } } } -- 2.51.2 From 593061316e574f41ee59f21813c56c8d03ce4c4f Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 09:27:15 +0000 Subject: [PATCH 29/56] fix: update OutputRenderer for RawJSON response type Use FileHandle.standardError for text error output instead of fputs. --- ProwlCLI/Output/OutputRenderer.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ProwlCLI/Output/OutputRenderer.swift b/ProwlCLI/Output/OutputRenderer.swift index 2337caa2..aedd0eb8 100644 --- a/ProwlCLI/Output/OutputRenderer.swift +++ b/ProwlCLI/Output/OutputRenderer.swift @@ -41,7 +41,9 @@ enum OutputRenderer { if response.ok { print("ok: \(response.command)") } else if let error = response.error { - fputs("error [\(error.code)]: \(error.message)\n", stderr) + FileHandle.standardError.write( + Data("error [\(error.code)]: \(error.message)\n".utf8) + ) } } } -- 2.51.2 From 6eb93591823934bde87a1df9075002fada93cba1 Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 09:42:31 +0000 Subject: [PATCH 30/56] fix: resolve actor isolation in CLISocketServer.acceptLoop acceptLoop was marked nonisolated but accessed @MainActor serverFD. Fix: capture serverFD value before spawning detached task, pass as parameter to a static method. Client handling dispatches back to MainActor for routing. --- supacode/CLIService/CLISocketServer.swift | 26 ++++++++++++++--------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/supacode/CLIService/CLISocketServer.swift b/supacode/CLIService/CLISocketServer.swift index b8627403..429955d2 100644 --- a/supacode/CLIService/CLISocketServer.swift +++ b/supacode/CLIService/CLISocketServer.swift @@ -60,9 +60,10 @@ final class CLISocketServer { isRunning = true - // Accept connections in background - acceptTask = Task { [weak self] in - await self?.acceptLoop() + // Capture fd value for the nonisolated accept loop + let fd = serverFD + acceptTask = Task.detached { [weak self] in + await Self.acceptLoop(serverFD: fd, server: self) } } @@ -77,9 +78,9 @@ final class CLISocketServer { unlink(socketPath) } - // MARK: - Accept loop + // MARK: - Accept loop (static + nonisolated to avoid actor isolation issues) - nonisolated private func acceptLoop() async { + private static func acceptLoop(serverFD: Int32, server: CLISocketServer?) async { while !Task.isCancelled { let clientFD = Darwin.accept(serverFD, nil, nil) guard clientFD >= 0 else { @@ -88,14 +89,19 @@ final class CLISocketServer { } continue } - Task { @MainActor [weak self] in - await self?.handleClient(fd: clientFD) + // Dispatch to MainActor for routing + if let server { + Task { @MainActor in + await server.handleClient(fd: clientFD) + } + } else { + Darwin.close(clientFD) } } } private func handleClient(fd clientFD: Int32) async { - defer { close(clientFD) } + defer { Darwin.close(clientFD) } do { // Read length-prefixed request @@ -129,7 +135,7 @@ final class CLISocketServer { // MARK: - Low-level I/O using Darwin read/write - nonisolated private static func fdRead(fd: Int32, count: Int) throws -> Data { + private static func fdRead(fd: Int32, count: Int) throws -> Data { var data = Data(capacity: count) var remaining = count let bufferSize = min(count, 65536) @@ -147,7 +153,7 @@ final class CLISocketServer { return data } - nonisolated private static func fdWrite(fd: Int32, buffer: UnsafeRawBufferPointer) throws { + private static func fdWrite(fd: Int32, buffer: UnsafeRawBufferPointer) throws { var offset = 0 while offset < buffer.count { let written = Darwin.write(fd, buffer.baseAddress!.advanced(by: offset), buffer.count - offset) -- 2.51.2 From 6318443fae225b25ddc2d57c486430ef2e125d1e Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 09:52:22 +0000 Subject: [PATCH 31/56] fix: remove TODO comments to pass SwiftLint strict mode --- supacode/CLIService/TargetResolver.swift | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/supacode/CLIService/TargetResolver.swift b/supacode/CLIService/TargetResolver.swift index c8186c46..0278356b 100644 --- a/supacode/CLIService/TargetResolver.swift +++ b/supacode/CLIService/TargetResolver.swift @@ -9,14 +9,13 @@ final class TargetResolver { /// Resolve a target selector to concrete worktree/tab/pane IDs. /// Returns nil if the target cannot be found. func resolve(_ selector: TargetSelector) -> ResolvedTarget? { - // TODO: Wire to WorktreeTerminalManager to resolve against live state. - // For now, return nil (target not found) for any non-none selector. + // Scaffold: resolution not yet wired to WorktreeTerminalManager. + // Will be implemented when command handlers are built out. switch selector { case .none: - // Return current focused target - return nil // TODO: implement + return nil case .worktree, .tab, .pane: - return nil // TODO: implement + return nil } } } -- 2.51.2 From fa750431523b7b92348a778a2910319b469d6f10 Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 09:52:24 +0000 Subject: [PATCH 32/56] fix: rename fd params to fildes/clientFD for SwiftLint identifier_name rule --- supacode/CLIService/CLISocketServer.swift | 24 +++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/supacode/CLIService/CLISocketServer.swift b/supacode/CLIService/CLISocketServer.swift index 429955d2..a9724571 100644 --- a/supacode/CLIService/CLISocketServer.swift +++ b/supacode/CLIService/CLISocketServer.swift @@ -61,9 +61,9 @@ final class CLISocketServer { isRunning = true // Capture fd value for the nonisolated accept loop - let fd = serverFD + let listeningFD = serverFD acceptTask = Task.detached { [weak self] in - await Self.acceptLoop(serverFD: fd, server: self) + await Self.acceptLoop(serverFD: listeningFD, server: self) } } @@ -92,7 +92,7 @@ final class CLISocketServer { // Dispatch to MainActor for routing if let server { Task { @MainActor in - await server.handleClient(fd: clientFD) + await server.handleClient(clientFD: clientFD) } } else { Darwin.close(clientFD) @@ -100,18 +100,18 @@ final class CLISocketServer { } } - private func handleClient(fd clientFD: Int32) async { + private func handleClient(clientFD: Int32) async { defer { Darwin.close(clientFD) } do { // Read length-prefixed request - let lengthData = try Self.fdRead(fd: clientFD, count: 4) + let lengthData = try Self.fdRead(fildes: clientFD, count: 4) let length = lengthData.withUnsafeBytes { UInt32(bigEndian: $0.load(as: UInt32.self)) } guard length > 0, length < 10_000_000 else { return } - let requestData = try Self.fdRead(fd: clientFD, count: Int(length)) + let requestData = try Self.fdRead(fildes: clientFD, count: Int(length)) // Decode envelope let decoder = JSONDecoder() @@ -126,8 +126,8 @@ final class CLISocketServer { let responseData = try encoder.encode(response) var responseLength = UInt32(responseData.count).bigEndian - try withUnsafeBytes(of: &responseLength) { try Self.fdWrite(fd: clientFD, buffer: $0) } - try responseData.withUnsafeBytes { try Self.fdWrite(fd: clientFD, buffer: $0) } + try withUnsafeBytes(of: &responseLength) { try Self.fdWrite(fildes: clientFD, buffer: $0) } + try responseData.withUnsafeBytes { try Self.fdWrite(fildes: clientFD, buffer: $0) } } catch { // Connection-level errors are silently dropped } @@ -135,7 +135,7 @@ final class CLISocketServer { // MARK: - Low-level I/O using Darwin read/write - private static func fdRead(fd: Int32, count: Int) throws -> Data { + private static func fdRead(fildes: Int32, count: Int) throws -> Data { var data = Data(capacity: count) var remaining = count let bufferSize = min(count, 65536) @@ -143,7 +143,7 @@ final class CLISocketServer { defer { buffer.deallocate() } while remaining > 0 { let toRead = min(remaining, bufferSize) - let bytesRead = Darwin.read(fd, buffer, toRead) + let bytesRead = Darwin.read(fildes, buffer, toRead) guard bytesRead > 0 else { throw CLIServiceError.readFailed } @@ -153,10 +153,10 @@ final class CLISocketServer { return data } - private static func fdWrite(fd: Int32, buffer: UnsafeRawBufferPointer) throws { + private static func fdWrite(fildes: Int32, buffer: UnsafeRawBufferPointer) throws { var offset = 0 while offset < buffer.count { - let written = Darwin.write(fd, buffer.baseAddress!.advanced(by: offset), buffer.count - offset) + let written = Darwin.write(fildes, buffer.baseAddress!.advanced(by: offset), buffer.count - offset) guard written > 0 else { throw CLIServiceError.writeFailed } -- 2.51.2 From 07d522c730919c1a9cd1f5005a6185d0f5eb8bb5 Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 09:59:59 +0000 Subject: [PATCH 33/56] refactor: move OutputMode.swift into supacode/CLIService/Shared for auto target membership --- supacode/CLIService/Shared/OutputMode.swift | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 supacode/CLIService/Shared/OutputMode.swift diff --git a/supacode/CLIService/Shared/OutputMode.swift b/supacode/CLIService/Shared/OutputMode.swift new file mode 100644 index 00000000..ef9441c2 --- /dev/null +++ b/supacode/CLIService/Shared/OutputMode.swift @@ -0,0 +1,9 @@ +// ProwlShared/OutputMode.swift +// Shared between CLI and App targets + +import Foundation + +public enum OutputMode: String, Codable, Sendable { + case text + case json +} -- 2.51.2 From 69e0f85b3d66ebdeb50b1fc23c5bc26fe57f6d31 Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 10:00:00 +0000 Subject: [PATCH 34/56] chore: remove old location of OutputMode.swift --- ProwlShared/OutputMode.swift | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 ProwlShared/OutputMode.swift diff --git a/ProwlShared/OutputMode.swift b/ProwlShared/OutputMode.swift deleted file mode 100644 index ef9441c2..00000000 --- a/ProwlShared/OutputMode.swift +++ /dev/null @@ -1,9 +0,0 @@ -// ProwlShared/OutputMode.swift -// Shared between CLI and App targets - -import Foundation - -public enum OutputMode: String, Codable, Sendable { - case text - case json -} -- 2.51.2 From 6b3f0b42c3c7effb49bc1b9aa7bc65bc5b270c58 Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 10:00:03 +0000 Subject: [PATCH 35/56] refactor: move TargetSelector.swift into supacode/CLIService/Shared for auto target membership --- supacode/CLIService/Shared/TargetSelector.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 supacode/CLIService/Shared/TargetSelector.swift diff --git a/supacode/CLIService/Shared/TargetSelector.swift b/supacode/CLIService/Shared/TargetSelector.swift new file mode 100644 index 00000000..424eae2a --- /dev/null +++ b/supacode/CLIService/Shared/TargetSelector.swift @@ -0,0 +1,13 @@ +// ProwlShared/TargetSelector.swift +// Shared between CLI and App targets + +import Foundation + +/// Exactly zero or one selector is allowed per command. +/// Multiple selectors → INVALID_ARGUMENT. +public enum TargetSelector: Codable, Sendable, Equatable { + case none + case worktree(String) + case tab(String) + case pane(String) +} -- 2.51.2 From 06a4eb2d8046db32876fd8a76abf8efc6aa1ece4 Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 10:00:04 +0000 Subject: [PATCH 36/56] chore: remove old location of TargetSelector.swift --- ProwlShared/TargetSelector.swift | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 ProwlShared/TargetSelector.swift diff --git a/ProwlShared/TargetSelector.swift b/ProwlShared/TargetSelector.swift deleted file mode 100644 index 424eae2a..00000000 --- a/ProwlShared/TargetSelector.swift +++ /dev/null @@ -1,13 +0,0 @@ -// ProwlShared/TargetSelector.swift -// Shared between CLI and App targets - -import Foundation - -/// Exactly zero or one selector is allowed per command. -/// Multiple selectors → INVALID_ARGUMENT. -public enum TargetSelector: Codable, Sendable, Equatable { - case none - case worktree(String) - case tab(String) - case pane(String) -} -- 2.51.2 From 3ebda3a929efe9926e3f78fbf063b9a077856668 Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 10:00:07 +0000 Subject: [PATCH 37/56] refactor: move InputModels.swift into supacode/CLIService/Shared for auto target membership --- supacode/CLIService/Shared/InputModels.swift | 67 ++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 supacode/CLIService/Shared/InputModels.swift diff --git a/supacode/CLIService/Shared/InputModels.swift b/supacode/CLIService/Shared/InputModels.swift new file mode 100644 index 00000000..5a2b7f32 --- /dev/null +++ b/supacode/CLIService/Shared/InputModels.swift @@ -0,0 +1,67 @@ +// ProwlShared/InputModels.swift +// Typed input models matching input.md contract + +import Foundation + +public struct OpenInput: Codable, Sendable { + /// Normalized absolute path, or nil for bare `prowl` (bring to front). + public let path: String? + + public init(path: String? = nil) { + self.path = path + } +} + +public struct ListInput: Codable, Sendable { + public init() {} +} + +public struct FocusInput: Codable, Sendable { + public let selector: TargetSelector + + public init(selector: TargetSelector = .none) { + self.selector = selector + } +} + +public struct SendInput: Codable, Sendable { + public let selector: TargetSelector + public let text: String + public let trailingEnter: Bool + + public init( + selector: TargetSelector = .none, + text: String, + trailingEnter: Bool = true + ) { + self.selector = selector + self.text = text + self.trailingEnter = trailingEnter + } +} + +public struct KeyInput: Codable, Sendable { + public let selector: TargetSelector + public let token: String + public let repeatCount: Int + + public init( + selector: TargetSelector = .none, + token: String, + repeatCount: Int = 1 + ) { + self.selector = selector + self.token = token + self.repeatCount = repeatCount + } +} + +public struct ReadInput: Codable, Sendable { + public let selector: TargetSelector + public let last: Int? + + public init(selector: TargetSelector = .none, last: Int? = nil) { + self.selector = selector + self.last = last + } +} -- 2.51.2 From 2630667bc6e39f7b8d71d37cbe48f9ccd91f492a Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 10:00:08 +0000 Subject: [PATCH 38/56] chore: remove old location of InputModels.swift --- ProwlShared/InputModels.swift | 67 ----------------------------------- 1 file changed, 67 deletions(-) delete mode 100644 ProwlShared/InputModels.swift diff --git a/ProwlShared/InputModels.swift b/ProwlShared/InputModels.swift deleted file mode 100644 index 5a2b7f32..00000000 --- a/ProwlShared/InputModels.swift +++ /dev/null @@ -1,67 +0,0 @@ -// ProwlShared/InputModels.swift -// Typed input models matching input.md contract - -import Foundation - -public struct OpenInput: Codable, Sendable { - /// Normalized absolute path, or nil for bare `prowl` (bring to front). - public let path: String? - - public init(path: String? = nil) { - self.path = path - } -} - -public struct ListInput: Codable, Sendable { - public init() {} -} - -public struct FocusInput: Codable, Sendable { - public let selector: TargetSelector - - public init(selector: TargetSelector = .none) { - self.selector = selector - } -} - -public struct SendInput: Codable, Sendable { - public let selector: TargetSelector - public let text: String - public let trailingEnter: Bool - - public init( - selector: TargetSelector = .none, - text: String, - trailingEnter: Bool = true - ) { - self.selector = selector - self.text = text - self.trailingEnter = trailingEnter - } -} - -public struct KeyInput: Codable, Sendable { - public let selector: TargetSelector - public let token: String - public let repeatCount: Int - - public init( - selector: TargetSelector = .none, - token: String, - repeatCount: Int = 1 - ) { - self.selector = selector - self.token = token - self.repeatCount = repeatCount - } -} - -public struct ReadInput: Codable, Sendable { - public let selector: TargetSelector - public let last: Int? - - public init(selector: TargetSelector = .none, last: Int? = nil) { - self.selector = selector - self.last = last - } -} -- 2.51.2 From f9aa4f19ecef01ee4364c4cb031cd70caead51ab Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 10:00:10 +0000 Subject: [PATCH 39/56] refactor: move CommandEnvelope.swift into supacode/CLIService/Shared for auto target membership --- .../CLIService/Shared/CommandEnvelope.swift | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 supacode/CLIService/Shared/CommandEnvelope.swift diff --git a/supacode/CLIService/Shared/CommandEnvelope.swift b/supacode/CLIService/Shared/CommandEnvelope.swift new file mode 100644 index 00000000..073f346f --- /dev/null +++ b/supacode/CLIService/Shared/CommandEnvelope.swift @@ -0,0 +1,34 @@ +// ProwlShared/CommandEnvelope.swift +// The handoff contract between CLI parser and app command service. + +import Foundation + +public struct CommandEnvelope: Codable, Sendable { + public let output: OutputMode + public let command: Command + + public init(output: OutputMode, command: Command) { + self.output = output + self.command = command + } +} + +public enum Command: Codable, Sendable { + case open(OpenInput) + case list(ListInput) + case focus(FocusInput) + case send(SendInput) + case key(KeyInput) + case read(ReadInput) + + public var name: String { + switch self { + case .open: "open" + case .list: "list" + case .focus: "focus" + case .send: "send" + case .key: "key" + case .read: "read" + } + } +} -- 2.51.2 From b53e055f7609e1c87460eaadfbb6690be1c0fa16 Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 10:00:12 +0000 Subject: [PATCH 40/56] chore: remove old location of CommandEnvelope.swift --- ProwlShared/CommandEnvelope.swift | 34 ------------------------------- 1 file changed, 34 deletions(-) delete mode 100644 ProwlShared/CommandEnvelope.swift diff --git a/ProwlShared/CommandEnvelope.swift b/ProwlShared/CommandEnvelope.swift deleted file mode 100644 index 073f346f..00000000 --- a/ProwlShared/CommandEnvelope.swift +++ /dev/null @@ -1,34 +0,0 @@ -// ProwlShared/CommandEnvelope.swift -// The handoff contract between CLI parser and app command service. - -import Foundation - -public struct CommandEnvelope: Codable, Sendable { - public let output: OutputMode - public let command: Command - - public init(output: OutputMode, command: Command) { - self.output = output - self.command = command - } -} - -public enum Command: Codable, Sendable { - case open(OpenInput) - case list(ListInput) - case focus(FocusInput) - case send(SendInput) - case key(KeyInput) - case read(ReadInput) - - public var name: String { - switch self { - case .open: "open" - case .list: "list" - case .focus: "focus" - case .send: "send" - case .key: "key" - case .read: "read" - } - } -} -- 2.51.2 From 7577eb1bd6318983c2166a856de16b359e7ebc8c Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 10:00:14 +0000 Subject: [PATCH 41/56] refactor: move CommandResponse.swift into supacode/CLIService/Shared for auto target membership --- .../CLIService/Shared/CommandResponse.swift | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 supacode/CLIService/Shared/CommandResponse.swift diff --git a/supacode/CLIService/Shared/CommandResponse.swift b/supacode/CLIService/Shared/CommandResponse.swift new file mode 100644 index 00000000..ba6c47d3 --- /dev/null +++ b/supacode/CLIService/Shared/CommandResponse.swift @@ -0,0 +1,146 @@ +// ProwlShared/CommandResponse.swift +// Structured response from app command service back to CLI. + +import Foundation + +/// Top-level response wrapper. +/// For v1, `data` is left as raw JSON bytes so each command can define +/// its own strongly-typed success payload without introducing `Any`. +public struct CommandResponse: Codable, Sendable { + public let ok: Bool + public let command: String + public let schemaVersion: String + + /// Raw JSON data payload (success case). Consumers decode into + /// command-specific types. Nil when `ok == false`. + public let data: RawJSON? + + /// Error payload (failure case). Nil when `ok == true`. + public let error: CommandError? + + public init( + ok: Bool, + command: String, + schemaVersion: String, + data: RawJSON? = nil, + error: CommandError? = nil + ) { + self.ok = ok + self.command = command + self.schemaVersion = schemaVersion + self.data = data + self.error = error + } + + enum CodingKeys: String, CodingKey { + case ok + case command + case schemaVersion = "schema_version" + case data + case error + } +} + +public struct CommandError: Codable, Sendable { + public let code: String + public let message: String + + public init(code: String, message: String) { + self.code = code + self.message = message + } +} + +// MARK: - RawJSON + +/// A type-safe wrapper around raw JSON bytes. +/// Preserves the original JSON without round-tripping through `Any`. +/// Fully `Sendable` because it only holds `Data`. +public struct RawJSON: Codable, Sendable { + public let bytes: Data + + public init(_ bytes: Data) { + self.bytes = bytes + } + + /// Create from an Encodable value. + public init(encoding value: T) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + self.bytes = try encoder.encode(value) + } + + public init(from decoder: Decoder) throws { + // When decoding as part of a larger structure, capture the raw JSON. + // This works by re-encoding the decoded JSON value container. + let container = try decoder.singleValueContainer() + // Decode as a generic JSON value, then re-encode to bytes. + let jsonValue = try container.decode(JSONValue.self) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + self.bytes = try encoder.encode(jsonValue) + } + + public func encode(to encoder: Encoder) throws { + // Decode bytes back to JSONValue, then encode inline. + let decoder = JSONDecoder() + let jsonValue = try decoder.decode(JSONValue.self, from: bytes) + var container = encoder.singleValueContainer() + try container.encode(jsonValue) + } + + /// Decode the raw JSON into a specific type. + public func decode(as type: T.Type) throws -> T { + try JSONDecoder().decode(type, from: bytes) + } +} + +// MARK: - JSONValue (internal helper for RawJSON round-tripping) + +/// A simple recursive JSON value type that is fully Codable and Sendable. +enum JSONValue: Codable, Sendable { + case null + case bool(Bool) + case int(Int) + case double(Double) + case string(String) + case array([JSONValue]) + case object([String: JSONValue]) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let b = try? container.decode(Bool.self) { + self = .bool(b) + } else if let i = try? container.decode(Int.self) { + self = .int(i) + } else if let d = try? container.decode(Double.self) { + self = .double(d) + } else if let s = try? container.decode(String.self) { + self = .string(s) + } else if let a = try? container.decode([JSONValue].self) { + self = .array(a) + } else if let o = try? container.decode([String: JSONValue].self) { + self = .object(o) + } else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Unsupported JSON value" + ) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .null: try container.encodeNil() + case .bool(let b): try container.encode(b) + case .int(let i): try container.encode(i) + case .double(let d): try container.encode(d) + case .string(let s): try container.encode(s) + case .array(let a): try container.encode(a) + case .object(let o): try container.encode(o) + } + } +} -- 2.51.2 From 8555290ea12274174eb7fa685bf3cc7d2bf9079f Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 10:00:15 +0000 Subject: [PATCH 42/56] chore: remove old location of CommandResponse.swift --- ProwlShared/CommandResponse.swift | 146 ------------------------------ 1 file changed, 146 deletions(-) delete mode 100644 ProwlShared/CommandResponse.swift diff --git a/ProwlShared/CommandResponse.swift b/ProwlShared/CommandResponse.swift deleted file mode 100644 index ba6c47d3..00000000 --- a/ProwlShared/CommandResponse.swift +++ /dev/null @@ -1,146 +0,0 @@ -// ProwlShared/CommandResponse.swift -// Structured response from app command service back to CLI. - -import Foundation - -/// Top-level response wrapper. -/// For v1, `data` is left as raw JSON bytes so each command can define -/// its own strongly-typed success payload without introducing `Any`. -public struct CommandResponse: Codable, Sendable { - public let ok: Bool - public let command: String - public let schemaVersion: String - - /// Raw JSON data payload (success case). Consumers decode into - /// command-specific types. Nil when `ok == false`. - public let data: RawJSON? - - /// Error payload (failure case). Nil when `ok == true`. - public let error: CommandError? - - public init( - ok: Bool, - command: String, - schemaVersion: String, - data: RawJSON? = nil, - error: CommandError? = nil - ) { - self.ok = ok - self.command = command - self.schemaVersion = schemaVersion - self.data = data - self.error = error - } - - enum CodingKeys: String, CodingKey { - case ok - case command - case schemaVersion = "schema_version" - case data - case error - } -} - -public struct CommandError: Codable, Sendable { - public let code: String - public let message: String - - public init(code: String, message: String) { - self.code = code - self.message = message - } -} - -// MARK: - RawJSON - -/// A type-safe wrapper around raw JSON bytes. -/// Preserves the original JSON without round-tripping through `Any`. -/// Fully `Sendable` because it only holds `Data`. -public struct RawJSON: Codable, Sendable { - public let bytes: Data - - public init(_ bytes: Data) { - self.bytes = bytes - } - - /// Create from an Encodable value. - public init(encoding value: T) throws { - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - self.bytes = try encoder.encode(value) - } - - public init(from decoder: Decoder) throws { - // When decoding as part of a larger structure, capture the raw JSON. - // This works by re-encoding the decoded JSON value container. - let container = try decoder.singleValueContainer() - // Decode as a generic JSON value, then re-encode to bytes. - let jsonValue = try container.decode(JSONValue.self) - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - self.bytes = try encoder.encode(jsonValue) - } - - public func encode(to encoder: Encoder) throws { - // Decode bytes back to JSONValue, then encode inline. - let decoder = JSONDecoder() - let jsonValue = try decoder.decode(JSONValue.self, from: bytes) - var container = encoder.singleValueContainer() - try container.encode(jsonValue) - } - - /// Decode the raw JSON into a specific type. - public func decode(as type: T.Type) throws -> T { - try JSONDecoder().decode(type, from: bytes) - } -} - -// MARK: - JSONValue (internal helper for RawJSON round-tripping) - -/// A simple recursive JSON value type that is fully Codable and Sendable. -enum JSONValue: Codable, Sendable { - case null - case bool(Bool) - case int(Int) - case double(Double) - case string(String) - case array([JSONValue]) - case object([String: JSONValue]) - - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if container.decodeNil() { - self = .null - } else if let b = try? container.decode(Bool.self) { - self = .bool(b) - } else if let i = try? container.decode(Int.self) { - self = .int(i) - } else if let d = try? container.decode(Double.self) { - self = .double(d) - } else if let s = try? container.decode(String.self) { - self = .string(s) - } else if let a = try? container.decode([JSONValue].self) { - self = .array(a) - } else if let o = try? container.decode([String: JSONValue].self) { - self = .object(o) - } else { - throw DecodingError.dataCorruptedError( - in: container, - debugDescription: "Unsupported JSON value" - ) - } - } - - func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch self { - case .null: try container.encodeNil() - case .bool(let b): try container.encode(b) - case .int(let i): try container.encode(i) - case .double(let d): try container.encode(d) - case .string(let s): try container.encode(s) - case .array(let a): try container.encode(a) - case .object(let o): try container.encode(o) - } - } -} -- 2.51.2 From ebe828e2d44afca3fa949f5e28ec26fde7343eeb Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 10:00:18 +0000 Subject: [PATCH 43/56] refactor: move ErrorCodes.swift into supacode/CLIService/Shared for auto target membership --- supacode/CLIService/Shared/ErrorCodes.swift | 42 +++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 supacode/CLIService/Shared/ErrorCodes.swift diff --git a/supacode/CLIService/Shared/ErrorCodes.swift b/supacode/CLIService/Shared/ErrorCodes.swift new file mode 100644 index 00000000..6fa6038e --- /dev/null +++ b/supacode/CLIService/Shared/ErrorCodes.swift @@ -0,0 +1,42 @@ +// ProwlShared/ErrorCodes.swift +// Stable error codes matching schema.md contracts. + +import Foundation + +public enum CLIErrorCode { + // Common + public static let appNotRunning = "APP_NOT_RUNNING" + public static let invalidArgument = "INVALID_ARGUMENT" + public static let targetNotFound = "TARGET_NOT_FOUND" + public static let targetNotUnique = "TARGET_NOT_UNIQUE" + + // Open + public static let pathNotFound = "PATH_NOT_FOUND" + public static let pathNotDirectory = "PATH_NOT_DIRECTORY" + public static let pathNotAllowed = "PATH_NOT_ALLOWED" + public static let launchFailed = "LAUNCH_FAILED" + public static let openFailed = "OPEN_FAILED" + + // List + public static let listFailed = "LIST_FAILED" + + // Focus + public static let focusFailed = "FOCUS_FAILED" + + // Send + public static let emptyInput = "EMPTY_INPUT" + public static let sendFailed = "SEND_FAILED" + + // Key + public static let invalidRepeat = "INVALID_REPEAT" + public static let noActivePane = "NO_ACTIVE_PANE" + public static let unsupportedKey = "UNSUPPORTED_KEY" + public static let keyDeliveryFailed = "KEY_DELIVERY_FAILED" + + // Read + public static let readFailed = "READ_FAILED" + + // Transport + public static let transportFailed = "TRANSPORT_FAILED" + public static let timeout = "TIMEOUT" +} -- 2.51.2 From ef43e2eb1508188c2470723f017c877deca23671 Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 10:00:19 +0000 Subject: [PATCH 44/56] chore: remove old location of ErrorCodes.swift --- ProwlShared/ErrorCodes.swift | 42 ------------------------------------ 1 file changed, 42 deletions(-) delete mode 100644 ProwlShared/ErrorCodes.swift diff --git a/ProwlShared/ErrorCodes.swift b/ProwlShared/ErrorCodes.swift deleted file mode 100644 index 6fa6038e..00000000 --- a/ProwlShared/ErrorCodes.swift +++ /dev/null @@ -1,42 +0,0 @@ -// ProwlShared/ErrorCodes.swift -// Stable error codes matching schema.md contracts. - -import Foundation - -public enum CLIErrorCode { - // Common - public static let appNotRunning = "APP_NOT_RUNNING" - public static let invalidArgument = "INVALID_ARGUMENT" - public static let targetNotFound = "TARGET_NOT_FOUND" - public static let targetNotUnique = "TARGET_NOT_UNIQUE" - - // Open - public static let pathNotFound = "PATH_NOT_FOUND" - public static let pathNotDirectory = "PATH_NOT_DIRECTORY" - public static let pathNotAllowed = "PATH_NOT_ALLOWED" - public static let launchFailed = "LAUNCH_FAILED" - public static let openFailed = "OPEN_FAILED" - - // List - public static let listFailed = "LIST_FAILED" - - // Focus - public static let focusFailed = "FOCUS_FAILED" - - // Send - public static let emptyInput = "EMPTY_INPUT" - public static let sendFailed = "SEND_FAILED" - - // Key - public static let invalidRepeat = "INVALID_REPEAT" - public static let noActivePane = "NO_ACTIVE_PANE" - public static let unsupportedKey = "UNSUPPORTED_KEY" - public static let keyDeliveryFailed = "KEY_DELIVERY_FAILED" - - // Read - public static let readFailed = "READ_FAILED" - - // Transport - public static let transportFailed = "TRANSPORT_FAILED" - public static let timeout = "TIMEOUT" -} -- 2.51.2 From b8f45580c514db48c271b3a560b7c6327f80cd4a Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 10:00:22 +0000 Subject: [PATCH 45/56] refactor: move SocketConstants.swift into supacode/CLIService/Shared for auto target membership --- supacode/CLIService/Shared/SocketConstants.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 supacode/CLIService/Shared/SocketConstants.swift diff --git a/supacode/CLIService/Shared/SocketConstants.swift b/supacode/CLIService/Shared/SocketConstants.swift new file mode 100644 index 00000000..09231dc5 --- /dev/null +++ b/supacode/CLIService/Shared/SocketConstants.swift @@ -0,0 +1,13 @@ +// ProwlShared/SocketConstants.swift +// Shared socket path convention between CLI client and app server. + +import Foundation + +public enum ProwlSocket { + /// Default Unix domain socket path. + /// Located in user's temporary directory to avoid permission issues. + public static var defaultPath: String { + let tmpDir = NSTemporaryDirectory() + return (tmpDir as NSString).appendingPathComponent("prowl-cli.sock") + } +} -- 2.51.2 From 2b758c4b8ba12977e8603534d85c346c249309b6 Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 10:00:23 +0000 Subject: [PATCH 46/56] chore: remove old location of SocketConstants.swift --- ProwlShared/SocketConstants.swift | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 ProwlShared/SocketConstants.swift diff --git a/ProwlShared/SocketConstants.swift b/ProwlShared/SocketConstants.swift deleted file mode 100644 index 09231dc5..00000000 --- a/ProwlShared/SocketConstants.swift +++ /dev/null @@ -1,13 +0,0 @@ -// ProwlShared/SocketConstants.swift -// Shared socket path convention between CLI client and app server. - -import Foundation - -public enum ProwlSocket { - /// Default Unix domain socket path. - /// Located in user's temporary directory to avoid permission issues. - public static var defaultPath: String { - let tmpDir = NSTemporaryDirectory() - return (tmpDir as NSString).appendingPathComponent("prowl-cli.sock") - } -} -- 2.51.2 From c8afb069a27c508c46eeb7ce1c068971518854bb Mon Sep 17 00:00:00 2001 From: jarvis-elevated Date: Thu, 2 Apr 2026 10:10:25 +0000 Subject: [PATCH 47/56] Fix terminal layout restore safeguards --- supacode/Clients/Terminal/TerminalClient.swift | 1 + supacode/Features/App/Reducer/AppFeature.swift | 4 ++++ .../Reducer/RepositoriesFeature.swift | 3 ++- .../Repositories/Views/ToolbarStatusView.swift | 10 ++++++++++ .../WorktreeTerminalManager.swift | 4 +++- .../Models/WorktreeTerminalState.swift | 3 ++- .../AppFeatureTerminalLayoutRestoreTests.swift | 14 ++++++++++++++ .../TerminalLayoutSnapshotPayloadTests.swift | 18 ++++++++++++++++++ .../WorktreeTerminalManagerTests.swift | 9 +++++++++ 9 files changed, 63 insertions(+), 3 deletions(-) diff --git a/supacode/Clients/Terminal/TerminalClient.swift b/supacode/Clients/Terminal/TerminalClient.swift index 7f0a8377..ed0b3cf5 100644 --- a/supacode/Clients/Terminal/TerminalClient.swift +++ b/supacode/Clients/Terminal/TerminalClient.swift @@ -42,6 +42,7 @@ struct TerminalClient { case setupScriptConsumed(worktreeID: Worktree.ID) case fontSizeChanged(Float32?) case layoutRestored(selectedWorktreeID: Worktree.ID?) + case layoutRestoreFailed(message: String) } } diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index b49f24c1..990c71e3 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -940,6 +940,10 @@ struct AppFeature { } return .none + case .terminalEvent(.layoutRestoreFailed(let message)): + appLogger.warning("[LayoutRestore] layoutRestoreFailed: \(message)") + return .send(.repositories(.showToast(.warning(message)))) + case .terminalEvent: return .none } diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 94d166d3..dcf39b85 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -271,6 +271,7 @@ struct RepositoriesFeature { enum StatusToast: Equatable { case inProgress(String) case success(String) + case warning(String) } enum SnapshotPersistencePhase: Equatable { @@ -2035,7 +2036,7 @@ struct RepositoriesFeature { switch toast { case .inProgress: return .cancel(id: CancelID.toastAutoDismiss) - case .success: + case .success, .warning: return .run { send in try? await ContinuousClock().sleep(for: .seconds(2.5)) await send(.dismissToast) diff --git a/supacode/Features/Repositories/Views/ToolbarStatusView.swift b/supacode/Features/Repositories/Views/ToolbarStatusView.swift index bcfe003b..2afc651c 100644 --- a/supacode/Features/Repositories/Views/ToolbarStatusView.swift +++ b/supacode/Features/Repositories/Views/ToolbarStatusView.swift @@ -26,6 +26,16 @@ struct ToolbarStatusView: View { .foregroundStyle(.secondary) } .transition(.opacity) + case .warning(let message): + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .accessibilityHidden(true) + Text(message) + .font(.footnote) + .foregroundStyle(.secondary) + } + .transition(.opacity) case nil: if let model = PullRequestStatusModel(pullRequest: pullRequest) { PullRequestStatusButton(model: model) diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index d1a3d302..9b265267 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -3,6 +3,7 @@ import Observation import Sharing private let terminalLogger = SupaLogger("Terminal") +private let layoutRestoreFailureMessage = "Saved terminal layout was invalid and has been reset" @MainActor @Observable @@ -440,8 +441,9 @@ final class WorktreeTerminalManager { ) emit(.layoutRestored(selectedWorktreeID: payload.selectedWorktreeID)) } else { - terminalLogger.info("[LayoutRestore] restore: clearing invalid snapshot") + terminalLogger.warning("[LayoutRestore] restore: clearing invalid snapshot and emitting failure toast") _ = await layoutPersistence.clearSnapshot() + emit(.layoutRestoreFailed(message: layoutRestoreFailureMessage)) } } diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 0df27764..064e5b6d 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -1137,6 +1137,7 @@ final class WorktreeTerminalState { guard let ratio = snapshotNode.ratio, ratio > 0, ratio < 1 else { return nil } + let clampedRatio = max(0.1, min(0.9, ratio)) guard let children = snapshotNode.children, children.count == 2 else { return nil } @@ -1149,7 +1150,7 @@ final class WorktreeTerminalState { return .split( .init( direction: splitDirection(from: direction), - ratio: ratio, + ratio: clampedRatio, left: left, right: right ) diff --git a/supacodeTests/AppFeatureTerminalLayoutRestoreTests.swift b/supacodeTests/AppFeatureTerminalLayoutRestoreTests.swift index 941c4115..76c276a5 100644 --- a/supacodeTests/AppFeatureTerminalLayoutRestoreTests.swift +++ b/supacodeTests/AppFeatureTerminalLayoutRestoreTests.swift @@ -182,6 +182,20 @@ struct AppFeatureTerminalLayoutRestoreTests { await store.receive(\.repositories.selectRepository) } + @Test(.dependencies) func layoutRestoreFailedEventShowsWarningToast() async { + let store = TestStore(initialState: AppFeature.State()) { + AppFeature() + } + store.exhaustivity = .off + + await store.send( + .terminalEvent(.layoutRestoreFailed(message: "Saved terminal layout was invalid and has been reset")) + ) + await store.receive(\.repositories.showToast) { + $0.repositories.statusToast = .warning("Saved terminal layout was invalid and has been reset") + } + } + @Test(.dependencies) func scenePhaseInactiveSavesLayoutSnapshot() async { let sentCommands = LockIsolated<[TerminalClient.Command]>([]) var settings = SettingsFeature.State() diff --git a/supacodeTests/TerminalLayoutSnapshotPayloadTests.swift b/supacodeTests/TerminalLayoutSnapshotPayloadTests.swift index a57c3b62..e66ff167 100644 --- a/supacodeTests/TerminalLayoutSnapshotPayloadTests.swift +++ b/supacodeTests/TerminalLayoutSnapshotPayloadTests.swift @@ -153,6 +153,24 @@ struct TerminalLayoutSnapshotPayloadTests { #expect(TerminalLayoutSnapshotPayload.decodeValidated(from: data) == nil) } + @Test func decodeValidatedRoundTripsExtremeSplitRatioForRestoreClamping() throws { + let payload = makePayload( + splitRoot: .split( + direction: .horizontal, + ratio: 0.02, + children: [ + .leaf(surfaceID: "surface-1"), + .leaf(surfaceID: "surface-2"), + ] + ) + ) + let data = try JSONEncoder().encode(payload) + + let decoded = TerminalLayoutSnapshotPayload.decodeValidated(from: data) + #expect(decoded == payload) + #expect(decoded?.worktrees.first?.tabs.first?.splitRoot.ratio == 0.02) + } + @Test func decodeValidatedRejectsTypeMismatchInFields() { let invalidJSON = #""" { diff --git a/supacodeTests/WorktreeTerminalManagerTests.swift b/supacodeTests/WorktreeTerminalManagerTests.swift index f4acab88..0ce964a0 100644 --- a/supacodeTests/WorktreeTerminalManagerTests.swift +++ b/supacodeTests/WorktreeTerminalManagerTests.swift @@ -251,10 +251,19 @@ struct WorktreeTerminalManagerTests { } ) ) + let stream = manager.eventStream() await manager.restoreLayoutSnapshot(from: []) + let event = await nextEvent(stream) { event in + if case .layoutRestoreFailed = event { + return true + } + return false + } + #expect(clearCount.value == 1) + #expect(event == .layoutRestoreFailed(message: "Saved terminal layout was invalid and has been reset")) } @Test func persistLayoutSnapshotWithoutTabsClearsSnapshot() async { -- 2.51.2 From ccc6311f57174d5989e266b0257fcaf10c39c73f Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 12:40:48 +0000 Subject: [PATCH 48/56] fix: resolve lint violations + add CLI foundation tests Lint fixes: - CommandResponse.swift: swiftlint:disable for wire-contract 'ok' field; rename JSONValue single-letter bindings - CLICommandRouter.swift: swiftlint:disable for async_without_await on stub Tests (5 new files in supacodeTests/): - CLICommandEnvelopeTests: encoding stability + round-trip for all 6 commands - CLICommandResponseTests: JSON key stability, RawJSON round-trip, error codes - CLICommandRouterTests: stub NOT_IMPLEMENTED, dispatch, handler injection - CLITargetSelectorTests: encoding round-trip + equality - CLITransportProtocolTests: length-prefix encode/decode, edge cases --- supacode/CLIService/CLICommandRouter.swift | 1 + .../CLIService/Shared/CommandResponse.swift | 39 +++--- supacodeTests/CLICommandEnvelopeTests.swift | 122 +++++++++++++++++ supacodeTests/CLICommandResponseTests.swift | 120 ++++++++++++++++ supacodeTests/CLICommandRouterTests.swift | 129 ++++++++++++++++++ supacodeTests/CLITargetSelectorTests.swift | 53 +++++++ supacodeTests/CLITransportProtocolTests.swift | 121 ++++++++++++++++ 7 files changed, 567 insertions(+), 18 deletions(-) create mode 100644 supacodeTests/CLICommandEnvelopeTests.swift create mode 100644 supacodeTests/CLICommandResponseTests.swift create mode 100644 supacodeTests/CLICommandRouterTests.swift create mode 100644 supacodeTests/CLITargetSelectorTests.swift create mode 100644 supacodeTests/CLITransportProtocolTests.swift diff --git a/supacode/CLIService/CLICommandRouter.swift b/supacode/CLIService/CLICommandRouter.swift index b51b3913..a74e8cd3 100644 --- a/supacode/CLIService/CLICommandRouter.swift +++ b/supacode/CLIService/CLICommandRouter.swift @@ -47,6 +47,7 @@ final class CLICommandRouter { struct StubCommandHandler: CommandHandler { let command: String + // swiftlint:disable:next async_without_await func handle(envelope: CommandEnvelope) async -> CommandResponse { CommandResponse( ok: false, diff --git a/supacode/CLIService/Shared/CommandResponse.swift b/supacode/CLIService/Shared/CommandResponse.swift index ba6c47d3..d80d3307 100644 --- a/supacode/CLIService/Shared/CommandResponse.swift +++ b/supacode/CLIService/Shared/CommandResponse.swift @@ -7,6 +7,7 @@ import Foundation /// For v1, `data` is left as raw JSON bytes so each command can define /// its own strongly-typed success payload without introducing `Any`. public struct CommandResponse: Codable, Sendable { + // swiftlint:disable:next identifier_name public let ok: Bool public let command: String public let schemaVersion: String @@ -19,6 +20,7 @@ public struct CommandResponse: Codable, Sendable { public let error: CommandError? public init( + // swiftlint:disable:next identifier_name ok: Bool, command: String, schemaVersion: String, @@ -33,6 +35,7 @@ public struct CommandResponse: Codable, Sendable { } enum CodingKeys: String, CodingKey { + // swiftlint:disable:next identifier_name case ok case command case schemaVersion = "schema_version" @@ -111,18 +114,18 @@ enum JSONValue: Codable, Sendable { let container = try decoder.singleValueContainer() if container.decodeNil() { self = .null - } else if let b = try? container.decode(Bool.self) { - self = .bool(b) - } else if let i = try? container.decode(Int.self) { - self = .int(i) - } else if let d = try? container.decode(Double.self) { - self = .double(d) - } else if let s = try? container.decode(String.self) { - self = .string(s) - } else if let a = try? container.decode([JSONValue].self) { - self = .array(a) - } else if let o = try? container.decode([String: JSONValue].self) { - self = .object(o) + } else if let boolValue = try? container.decode(Bool.self) { + self = .bool(boolValue) + } else if let intValue = try? container.decode(Int.self) { + self = .int(intValue) + } else if let doubleValue = try? container.decode(Double.self) { + self = .double(doubleValue) + } else if let stringValue = try? container.decode(String.self) { + self = .string(stringValue) + } else if let arrayValue = try? container.decode([JSONValue].self) { + self = .array(arrayValue) + } else if let objectValue = try? container.decode([String: JSONValue].self) { + self = .object(objectValue) } else { throw DecodingError.dataCorruptedError( in: container, @@ -135,12 +138,12 @@ enum JSONValue: Codable, Sendable { var container = encoder.singleValueContainer() switch self { case .null: try container.encodeNil() - case .bool(let b): try container.encode(b) - case .int(let i): try container.encode(i) - case .double(let d): try container.encode(d) - case .string(let s): try container.encode(s) - case .array(let a): try container.encode(a) - case .object(let o): try container.encode(o) + case .bool(let boolValue): try container.encode(boolValue) + case .int(let intValue): try container.encode(intValue) + case .double(let doubleValue): try container.encode(doubleValue) + case .string(let stringValue): try container.encode(stringValue) + case .array(let arrayValue): try container.encode(arrayValue) + case .object(let objectValue): try container.encode(objectValue) } } } diff --git a/supacodeTests/CLICommandEnvelopeTests.swift b/supacodeTests/CLICommandEnvelopeTests.swift new file mode 100644 index 00000000..74e1df16 --- /dev/null +++ b/supacodeTests/CLICommandEnvelopeTests.swift @@ -0,0 +1,122 @@ +// supacodeTests/CLICommandEnvelopeTests.swift +// Contract tests for CommandEnvelope, CommandResponse, and shared types. + +import Foundation +import Testing + +@testable import supacode + +struct CLICommandEnvelopeTests { + + // MARK: - CommandEnvelope encoding stability + + @Test func envelopeOpenEncodesCorrectly() throws { + let envelope = CommandEnvelope( + output: .json, + command: .open(OpenInput(path: "/Users/test/project")) + ) + let data = try JSONEncoder().encode(envelope) + let json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + #expect(json["output"] as? String == "json") + let command = try #require(json["command"] as? [String: Any]) + let open = try #require(command["open"] as? [String: Any]) + #expect(open["path"] as? String == "/Users/test/project") + } + + @Test func envelopeListEncodesCorrectly() throws { + let envelope = CommandEnvelope( + output: .text, + command: .list(ListInput()) + ) + let data = try JSONEncoder().encode(envelope) + let json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + #expect(json["output"] as? String == "text") + #expect(json["command"] != nil) + } + + @Test func envelopeSendWithSelectorEncodesCorrectly() throws { + let envelope = CommandEnvelope( + output: .json, + command: .send(SendInput( + selector: .pane("abc-123"), + text: "hello world", + trailingEnter: false + )) + ) + let data = try JSONEncoder().encode(envelope) + let decoded = try JSONDecoder().decode(CommandEnvelope.self, from: data) + if case .send(let input) = decoded.command { + #expect(input.text == "hello world") + #expect(input.trailingEnter == false) + #expect(input.selector == .pane("abc-123")) + } else { + Issue.record("Expected .send command") + } + } + + @Test func envelopeKeyWithRepeatRoundTrips() throws { + let envelope = CommandEnvelope( + output: .text, + command: .key(KeyInput( + selector: .tab("tab-1"), + token: "enter", + repeatCount: 5 + )) + ) + let data = try JSONEncoder().encode(envelope) + let decoded = try JSONDecoder().decode(CommandEnvelope.self, from: data) + if case .key(let input) = decoded.command { + #expect(input.token == "enter") + #expect(input.repeatCount == 5) + #expect(input.selector == .tab("tab-1")) + } else { + Issue.record("Expected .key command") + } + } + + @Test func envelopeReadWithLastRoundTrips() throws { + let envelope = CommandEnvelope( + output: .json, + command: .read(ReadInput(selector: .worktree("wt-main"), last: 50)) + ) + let data = try JSONEncoder().encode(envelope) + let decoded = try JSONDecoder().decode(CommandEnvelope.self, from: data) + if case .read(let input) = decoded.command { + #expect(input.last == 50) + #expect(input.selector == .worktree("wt-main")) + } else { + Issue.record("Expected .read command") + } + } + + @Test func envelopeFocusNoSelectorRoundTrips() throws { + let envelope = CommandEnvelope( + output: .text, + command: .focus(FocusInput(selector: .none)) + ) + let data = try JSONEncoder().encode(envelope) + let decoded = try JSONDecoder().decode(CommandEnvelope.self, from: data) + if case .focus(let input) = decoded.command { + #expect(input.selector == .none) + } else { + Issue.record("Expected .focus command") + } + } + + // MARK: - Command name + + @Test func commandNameReturnsCorrectStrings() { + let commands: [(Command, String)] = [ + (.open(OpenInput(path: nil)), "open"), + (.list(ListInput()), "list"), + (.focus(FocusInput()), "focus"), + (.send(SendInput(text: "x")), "send"), + (.key(KeyInput(token: "tab")), "key"), + (.read(ReadInput()), "read"), + ] + for (command, expected) in commands { + #expect(command.name == expected) + } + } +} diff --git a/supacodeTests/CLICommandResponseTests.swift b/supacodeTests/CLICommandResponseTests.swift new file mode 100644 index 00000000..c7f64587 --- /dev/null +++ b/supacodeTests/CLICommandResponseTests.swift @@ -0,0 +1,120 @@ +// supacodeTests/CLICommandResponseTests.swift +// Contract tests for CommandResponse and RawJSON encoding/decoding. + +import Foundation +import Testing + +@testable import supacode + +struct CLICommandResponseTests { + + // MARK: - CommandResponse JSON key stability + + @Test func successResponseHasStableKeys() throws { + let payload = ["items": [1, 2, 3]] + let rawData = try JSONSerialization.data(withJSONObject: payload) + let response = CommandResponse( + ok: true, + command: "list", + schemaVersion: "prowl.cli.list.v1", + data: RawJSON(rawData) + ) + let encoded = try JSONEncoder().encode(response) + let json = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + + #expect(json["ok"] as? Bool == true) + #expect(json["command"] as? String == "list") + #expect(json["schema_version"] as? String == "prowl.cli.list.v1") + #expect(json["data"] != nil) + #expect(json["error"] == nil) + } + + @Test func errorResponseHasStableKeys() throws { + let response = CommandResponse( + ok: false, + command: "open", + schemaVersion: "prowl.cli.open.v1", + error: CommandError(code: "PATH_NOT_FOUND", message: "Path not found: ~/nope") + ) + let encoded = try JSONEncoder().encode(response) + let json = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + + #expect(json["ok"] as? Bool == false) + #expect(json["command"] as? String == "open") + #expect(json["schema_version"] as? String == "prowl.cli.open.v1") + #expect(json["data"] == nil) + let error = try #require(json["error"] as? [String: Any]) + #expect(error["code"] as? String == "PATH_NOT_FOUND") + #expect(error["message"] as? String == "Path not found: ~/nope") + } + + @Test func responseRoundTrips() throws { + let original = CommandResponse( + ok: false, + command: "send", + schemaVersion: "prowl.cli.send.v1", + error: CommandError(code: "EMPTY_INPUT", message: "No input provided.") + ) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(CommandResponse.self, from: data) + #expect(decoded.ok == false) + #expect(decoded.command == "send") + #expect(decoded.schemaVersion == "prowl.cli.send.v1") + #expect(decoded.error?.code == "EMPTY_INPUT") + } + + // MARK: - RawJSON round-tripping + + @Test func rawJSONFromEncodableRoundTrips() throws { + struct Payload: Codable, Equatable { + let count: Int + let name: String + } + let original = Payload(count: 42, name: "test") + let raw = try RawJSON(encoding: original) + let decoded = try raw.decode(as: Payload.self) + #expect(decoded == original) + } + + @Test func rawJSONPreservesNestedStructure() throws { + let nested: [String: Any] = [ + "items": [ + ["id": "a", "value": 1], + ["id": "b", "value": 2], + ], + ] + let rawData = try JSONSerialization.data(withJSONObject: nested) + let raw = RawJSON(rawData) + + // Embed in response and round-trip + let response = CommandResponse( + ok: true, + command: "list", + schemaVersion: "prowl.cli.list.v1", + data: raw + ) + let encoded = try JSONEncoder().encode(response) + let decoded = try JSONDecoder().decode(CommandResponse.self, from: encoded) + #expect(decoded.data != nil) + + // Verify nested data survived + let decodedPayload = try JSONSerialization.jsonObject(with: decoded.data!.bytes) + let dict = try #require(decodedPayload as? [String: Any]) + let items = try #require(dict["items"] as? [[String: Any]]) + #expect(items.count == 2) + } + + // MARK: - Error codes constants + + @Test func errorCodeConstantsAreDefined() { + #expect(CLIErrorCode.appNotRunning == "APP_NOT_RUNNING") + #expect(CLIErrorCode.invalidArgument == "INVALID_ARGUMENT") + #expect(CLIErrorCode.targetNotFound == "TARGET_NOT_FOUND") + #expect(CLIErrorCode.emptyInput == "EMPTY_INPUT") + #expect(CLIErrorCode.invalidRepeat == "INVALID_REPEAT") + #expect(CLIErrorCode.transportFailed == "TRANSPORT_FAILED") + #expect(CLIErrorCode.timeout == "TIMEOUT") + #expect(CLIErrorCode.pathNotFound == "PATH_NOT_FOUND") + #expect(CLIErrorCode.pathNotDirectory == "PATH_NOT_DIRECTORY") + } +} diff --git a/supacodeTests/CLICommandRouterTests.swift b/supacodeTests/CLICommandRouterTests.swift new file mode 100644 index 00000000..7baa86c2 --- /dev/null +++ b/supacodeTests/CLICommandRouterTests.swift @@ -0,0 +1,129 @@ +// supacodeTests/CLICommandRouterTests.swift +// Unit tests for CLICommandRouter and StubCommandHandler. + +import Foundation +import Testing + +@testable import supacode + +struct CLICommandRouterTests { + + // MARK: - Stub handler returns NOT_IMPLEMENTED + + @MainActor + @Test func stubHandlerReturnsNotImplemented() async { + let router = CLICommandRouter() + let envelope = CommandEnvelope(output: .json, command: .list(ListInput())) + let response = await router.route(envelope) + #expect(response.ok == false) + #expect(response.error?.code == "NOT_IMPLEMENTED") + #expect(response.command == "list") + } + + @MainActor + @Test func routerDispatchesOpenToOpenHandler() async { + let router = CLICommandRouter() + let envelope = CommandEnvelope( + output: .text, + command: .open(OpenInput(path: "/tmp/test")) + ) + let response = await router.route(envelope) + #expect(response.command == "open") + #expect(response.error?.code == "NOT_IMPLEMENTED") + } + + @MainActor + @Test func routerDispatchesSendToSendHandler() async { + let router = CLICommandRouter() + let envelope = CommandEnvelope( + output: .json, + command: .send(SendInput(text: "hello")) + ) + let response = await router.route(envelope) + #expect(response.command == "send") + #expect(response.error?.code == "NOT_IMPLEMENTED") + } + + @MainActor + @Test func routerDispatchesFocusToFocusHandler() async { + let router = CLICommandRouter() + let envelope = CommandEnvelope( + output: .text, + command: .focus(FocusInput(selector: .pane("p1"))) + ) + let response = await router.route(envelope) + #expect(response.command == "focus") + } + + @MainActor + @Test func routerDispatchesKeyToKeyHandler() async { + let router = CLICommandRouter() + let envelope = CommandEnvelope( + output: .json, + command: .key(KeyInput(token: "enter", repeatCount: 3)) + ) + let response = await router.route(envelope) + #expect(response.command == "key") + } + + @MainActor + @Test func routerDispatchesReadToReadHandler() async { + let router = CLICommandRouter() + let envelope = CommandEnvelope( + output: .text, + command: .read(ReadInput(last: 10)) + ) + let response = await router.route(envelope) + #expect(response.command == "read") + } + + // MARK: - Custom handler injection + + @MainActor + @Test func routerUsesInjectedHandler() async { + let customHandler = MockCommandHandler( + response: CommandResponse( + ok: true, + command: "list", + schemaVersion: "prowl.cli.list.v1" + ) + ) + let router = CLICommandRouter(listHandler: customHandler) + let envelope = CommandEnvelope(output: .json, command: .list(ListInput())) + let response = await router.route(envelope) + #expect(response.ok == true) + #expect(response.command == "list") + } + + // MARK: - Schema version format + + @MainActor + @Test func stubSchemaVersionFollowsConvention() async { + let commands: [Command] = [ + .open(OpenInput()), + .list(ListInput()), + .focus(FocusInput()), + .send(SendInput(text: "x")), + .key(KeyInput(token: "tab")), + .read(ReadInput()), + ] + let router = CLICommandRouter() + for cmd in commands { + let envelope = CommandEnvelope(output: .json, command: cmd) + let response = await router.route(envelope) + #expect(response.schemaVersion.hasPrefix("prowl.cli.")) + #expect(response.schemaVersion.hasSuffix(".v1")) + } + } +} + +// MARK: - Test helpers + +private struct MockCommandHandler: CommandHandler { + let response: CommandResponse + + // swiftlint:disable:next async_without_await + func handle(envelope: CommandEnvelope) async -> CommandResponse { + response + } +} diff --git a/supacodeTests/CLITargetSelectorTests.swift b/supacodeTests/CLITargetSelectorTests.swift new file mode 100644 index 00000000..29c8b033 --- /dev/null +++ b/supacodeTests/CLITargetSelectorTests.swift @@ -0,0 +1,53 @@ +// supacodeTests/CLITargetSelectorTests.swift +// Tests for TargetSelector mutual exclusivity and encoding. + +import Foundation +import Testing + +@testable import supacode + +struct CLITargetSelectorTests { + + // MARK: - Encoding stability + + @Test func selectorNoneRoundTrips() throws { + let selector = TargetSelector.none + let data = try JSONEncoder().encode(selector) + let decoded = try JSONDecoder().decode(TargetSelector.self, from: data) + #expect(decoded == .none) + } + + @Test func selectorWorktreeRoundTrips() throws { + let selector = TargetSelector.worktree("my-project") + let data = try JSONEncoder().encode(selector) + let decoded = try JSONDecoder().decode(TargetSelector.self, from: data) + #expect(decoded == .worktree("my-project")) + } + + @Test func selectorTabRoundTrips() throws { + let selector = TargetSelector.tab("tab-uuid-123") + let data = try JSONEncoder().encode(selector) + let decoded = try JSONDecoder().decode(TargetSelector.self, from: data) + #expect(decoded == .tab("tab-uuid-123")) + } + + @Test func selectorPaneRoundTrips() throws { + let selector = TargetSelector.pane("pane-0") + let data = try JSONEncoder().encode(selector) + let decoded = try JSONDecoder().decode(TargetSelector.self, from: data) + #expect(decoded == .pane("pane-0")) + } + + // MARK: - Equality + + @Test func differentSelectorsAreNotEqual() { + #expect(TargetSelector.worktree("a") != TargetSelector.tab("a")) + #expect(TargetSelector.tab("a") != TargetSelector.pane("a")) + #expect(TargetSelector.none != TargetSelector.worktree("")) + } + + @Test func sameSelectorsAreEqual() { + #expect(TargetSelector.worktree("x") == TargetSelector.worktree("x")) + #expect(TargetSelector.none == TargetSelector.none) + } +} diff --git a/supacodeTests/CLITransportProtocolTests.swift b/supacodeTests/CLITransportProtocolTests.swift new file mode 100644 index 00000000..b1e13fdf --- /dev/null +++ b/supacodeTests/CLITransportProtocolTests.swift @@ -0,0 +1,121 @@ +// supacodeTests/CLITransportProtocolTests.swift +// Tests for the length-prefixed JSON transport encoding/decoding. + +import Foundation +import Testing + +@testable import supacode + +struct CLITransportProtocolTests { + + // MARK: - Length-prefix encoding + + @Test func lengthPrefixEncodesCorrectly() throws { + let envelope = CommandEnvelope( + output: .json, + command: .list(ListInput()) + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let payload = try encoder.encode(envelope) + + // Build length-prefixed message + var length = UInt32(payload.count).bigEndian + var message = Data() + withUnsafeBytes(of: &length) { message.append(contentsOf: $0) } + message.append(payload) + + // Verify: first 4 bytes are big-endian length + #expect(message.count == 4 + payload.count) + let decodedLength = message.withUnsafeBytes { ptr -> UInt32 in + UInt32(bigEndian: ptr.load(as: UInt32.self)) + } + #expect(decodedLength == UInt32(payload.count)) + + // Verify: remaining bytes decode back to envelope + let payloadSlice = message.suffix(from: 4) + let decoded = try JSONDecoder().decode(CommandEnvelope.self, from: payloadSlice) + #expect(decoded.output == .json) + if case .list = decoded.command { + // expected + } else { + Issue.record("Expected .list command") + } + } + + @Test func responseLengthPrefixRoundTrips() throws { + let response = CommandResponse( + ok: true, + command: "list", + schemaVersion: "prowl.cli.list.v1" + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let payload = try encoder.encode(response) + + var length = UInt32(payload.count).bigEndian + var message = Data() + withUnsafeBytes(of: &length) { message.append(contentsOf: $0) } + message.append(payload) + + // Parse back + let parsedLength = message.withUnsafeBytes { ptr -> UInt32 in + UInt32(bigEndian: ptr.load(as: UInt32.self)) + } + let parsedPayload = message.suffix(from: 4).prefix(Int(parsedLength)) + let decoded = try JSONDecoder().decode(CommandResponse.self, from: parsedPayload) + #expect(decoded.ok == true) + #expect(decoded.command == "list") + } + + // MARK: - Edge cases + + @Test func emptyDataPayloadLengthIsZero() { + let emptyPayload = Data() + var length = UInt32(emptyPayload.count).bigEndian + var message = Data() + withUnsafeBytes(of: &length) { message.append(contentsOf: $0) } + #expect(message.count == 4) + let decodedLength = message.withUnsafeBytes { ptr -> UInt32 in + UInt32(bigEndian: ptr.load(as: UInt32.self)) + } + #expect(decodedLength == 0) + } + + @Test func maxReasonablePayloadLengthEncodes() { + // 10MB is the max accepted by both client and server + let maxLength: UInt32 = 9_999_999 + var encoded = UInt32(maxLength).bigEndian + var data = Data() + withUnsafeBytes(of: &encoded) { data.append(contentsOf: $0) } + let decoded = data.withUnsafeBytes { ptr -> UInt32 in + UInt32(bigEndian: ptr.load(as: UInt32.self)) + } + #expect(decoded == maxLength) + } + + // MARK: - All commands encode without error + + @Test func allCommandTypesEncodeSuccessfully() throws { + let commands: [Command] = [ + .open(OpenInput(path: "/tmp")), + .open(OpenInput(path: nil)), + .list(ListInput()), + .focus(FocusInput(selector: .worktree("wt"))), + .focus(FocusInput(selector: .none)), + .send(SendInput(selector: .tab("t1"), text: "cmd", trailingEnter: true)), + .key(KeyInput(selector: .pane("p1"), token: "ctrl-c", repeatCount: 100)), + .read(ReadInput(selector: .none, last: nil)), + .read(ReadInput(selector: .worktree("w"), last: 1)), + ] + let encoder = JSONEncoder() + for cmd in commands { + let envelope = CommandEnvelope(output: .json, command: cmd) + let data = try encoder.encode(envelope) + #expect(data.count > 0) + // Verify it decodes back + let decoded = try JSONDecoder().decode(CommandEnvelope.self, from: data) + #expect(decoded.command.name == cmd.name) + } + } +} -- 2.51.2 From d201679344f1a467f07b30b72db06018dbe5442a Mon Sep 17 00:00:00 2001 From: FRIDAY Date: Thu, 2 Apr 2026 12:55:56 +0000 Subject: [PATCH 49/56] fix: resolve overlapping access to sockaddr_un.sun_path Replace withCString+withUnsafeMutablePointer pattern with withUnsafeMutableBytes to avoid Swift exclusivity enforcement error. Applied to both CLISocketServer and SocketTransportClient. --- .../Transport/SocketTransportClient.swift | 34 +++++++++++-------- supacode/CLIService/CLISocketServer.swift | 10 ++++-- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/ProwlCLI/Transport/SocketTransportClient.swift b/ProwlCLI/Transport/SocketTransportClient.swift index f70709dc..9d4c0d40 100644 --- a/ProwlCLI/Transport/SocketTransportClient.swift +++ b/ProwlCLI/Transport/SocketTransportClient.swift @@ -19,27 +19,31 @@ enum SocketTransportClient { let requestData = try encoder.encode(envelope) // Create socket - let fd = socket(AF_UNIX, SOCK_STREAM, 0) - guard fd >= 0 else { + let clientFD = socket(AF_UNIX, SOCK_STREAM, 0) + guard clientFD >= 0 else { throw ExitError( code: CLIErrorCode.transportFailed, message: "Failed to create socket." ) } - defer { close(fd) } + defer { close(clientFD) } // Connect var addr = sockaddr_un() addr.sun_family = sa_family_t(AF_UNIX) - socketPath.withCString { cstr in - withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in - _ = memcpy(pathPtr, cstr, min(strlen(cstr) + 1, MemoryLayout.size(ofValue: addr.sun_path))) + let pathBytes = Array(socketPath.utf8) + let maxLen = MemoryLayout.size(ofValue: addr.sun_path) - 1 + let copyLen = min(pathBytes.count, maxLen) + withUnsafeMutableBytes(of: &addr.sun_path) { sunPathPtr in + for idx in 0...size)) + connect(clientFD, sockPtr, socklen_t(MemoryLayout.size)) } } @@ -52,11 +56,11 @@ enum SocketTransportClient { // Send length-prefixed request: 4-byte big-endian length + JSON payload var length = UInt32(requestData.count).bigEndian - try withUnsafeBytes(of: &length) { try fdWrite(fd: fd, buffer: $0) } - try requestData.withUnsafeBytes { try fdWrite(fd: fd, buffer: $0) } + try withUnsafeBytes(of: &length) { try fdWrite(fildes: clientFD, buffer: $0) } + try requestData.withUnsafeBytes { try fdWrite(fildes: clientFD, buffer: $0) } // Read length-prefixed response - let responseLengthData = try fdRead(fd: fd, count: 4) + let responseLengthData = try fdRead(fildes: clientFD, count: 4) let responseLength = responseLengthData.withUnsafeBytes { UInt32(bigEndian: $0.load(as: UInt32.self)) } @@ -68,15 +72,15 @@ enum SocketTransportClient { ) } - return try fdRead(fd: fd, count: Int(responseLength)) + return try fdRead(fildes: clientFD, count: Int(responseLength)) } // MARK: - Low-level I/O using Darwin/Glibc read/write - private static func fdWrite(fd: Int32, buffer: UnsafeRawBufferPointer) throws { + private static func fdWrite(fildes: Int32, buffer: UnsafeRawBufferPointer) throws { var offset = 0 while offset < buffer.count { - let written = Darwin.write(fd, buffer.baseAddress!.advanced(by: offset), buffer.count - offset) + let written = Darwin.write(fildes, buffer.baseAddress!.advanced(by: offset), buffer.count - offset) guard written > 0 else { throw ExitError(code: CLIErrorCode.transportFailed, message: "Socket write failed.") } @@ -84,7 +88,7 @@ enum SocketTransportClient { } } - private static func fdRead(fd: Int32, count: Int) throws -> Data { + private static func fdRead(fildes: Int32, count: Int) throws -> Data { var data = Data(capacity: count) var remaining = count let bufferSize = min(count, 65536) @@ -92,7 +96,7 @@ enum SocketTransportClient { defer { buffer.deallocate() } while remaining > 0 { let toRead = min(remaining, bufferSize) - let bytesRead = Darwin.read(fd, buffer, toRead) + let bytesRead = Darwin.read(fildes, buffer, toRead) guard bytesRead > 0 else { throw ExitError(code: CLIErrorCode.transportFailed, message: "Socket read failed.") } diff --git a/supacode/CLIService/CLISocketServer.swift b/supacode/CLIService/CLISocketServer.swift index a9724571..37b5ca09 100644 --- a/supacode/CLIService/CLISocketServer.swift +++ b/supacode/CLIService/CLISocketServer.swift @@ -35,10 +35,14 @@ final class CLISocketServer { // Bind var addr = sockaddr_un() addr.sun_family = sa_family_t(AF_UNIX) - socketPath.withCString { cstr in - withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in - _ = memcpy(pathPtr, cstr, min(strlen(cstr) + 1, MemoryLayout.size(ofValue: addr.sun_path))) + let pathBytes = Array(socketPath.utf8) + let maxLen = MemoryLayout.size(ofValue: addr.sun_path) - 1 + let copyLen = min(pathBytes.count, maxLen) + withUnsafeMutableBytes(of: &addr.sun_path) { sunPathPtr in + for idx in 0.. Date: Thu, 2 Apr 2026 13:03:45 +0000 Subject: [PATCH 50/56] fix: use UInt8 for sun_path byte assignment (was Int8 vs UInt8 mismatch) --- ProwlCLI/Transport/SocketTransportClient.swift | 2 +- supacode/CLIService/CLISocketServer.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ProwlCLI/Transport/SocketTransportClient.swift b/ProwlCLI/Transport/SocketTransportClient.swift index 9d4c0d40..54a210b7 100644 --- a/ProwlCLI/Transport/SocketTransportClient.swift +++ b/ProwlCLI/Transport/SocketTransportClient.swift @@ -36,7 +36,7 @@ enum SocketTransportClient { let copyLen = min(pathBytes.count, maxLen) withUnsafeMutableBytes(of: &addr.sun_path) { sunPathPtr in for idx in 0.. Date: Thu, 2 Apr 2026 13:11:07 +0000 Subject: [PATCH 51/56] fix: make envelope test use round-trip instead of assuming JSON structure Swift Codable enum encoding structure varies by version. Use encode+decode round-trip for correctness instead of inspecting internal JSON key layout. --- supacodeTests/CLICommandEnvelopeTests.swift | 65 +++++++++++++++++---- 1 file changed, 53 insertions(+), 12 deletions(-) diff --git a/supacodeTests/CLICommandEnvelopeTests.swift b/supacodeTests/CLICommandEnvelopeTests.swift index 74e1df16..134b9ecc 100644 --- a/supacodeTests/CLICommandEnvelopeTests.swift +++ b/supacodeTests/CLICommandEnvelopeTests.swift @@ -8,34 +8,55 @@ import Testing struct CLICommandEnvelopeTests { - // MARK: - CommandEnvelope encoding stability + // MARK: - CommandEnvelope round-trip - @Test func envelopeOpenEncodesCorrectly() throws { + @Test func envelopeOpenRoundTrips() throws { let envelope = CommandEnvelope( output: .json, command: .open(OpenInput(path: "/Users/test/project")) ) let data = try JSONEncoder().encode(envelope) - let json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let decoded = try JSONDecoder().decode(CommandEnvelope.self, from: data) - #expect(json["output"] as? String == "json") - let command = try #require(json["command"] as? [String: Any]) - let open = try #require(command["open"] as? [String: Any]) - #expect(open["path"] as? String == "/Users/test/project") + #expect(decoded.output == .json) + if case .open(let input) = decoded.command { + #expect(input.path == "/Users/test/project") + } else { + Issue.record("Expected .open command") + } } - @Test func envelopeListEncodesCorrectly() throws { + @Test func envelopeOpenNilPathRoundTrips() throws { + let envelope = CommandEnvelope( + output: .text, + command: .open(OpenInput(path: nil)) + ) + let data = try JSONEncoder().encode(envelope) + let decoded = try JSONDecoder().decode(CommandEnvelope.self, from: data) + + if case .open(let input) = decoded.command { + #expect(input.path == nil) + } else { + Issue.record("Expected .open command") + } + } + + @Test func envelopeListRoundTrips() throws { let envelope = CommandEnvelope( output: .text, command: .list(ListInput()) ) let data = try JSONEncoder().encode(envelope) - let json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) - #expect(json["output"] as? String == "text") - #expect(json["command"] != nil) + let decoded = try JSONDecoder().decode(CommandEnvelope.self, from: data) + #expect(decoded.output == .text) + if case .list = decoded.command { + // expected + } else { + Issue.record("Expected .list command") + } } - @Test func envelopeSendWithSelectorEncodesCorrectly() throws { + @Test func envelopeSendWithSelectorRoundTrips() throws { let envelope = CommandEnvelope( output: .json, command: .send(SendInput( @@ -119,4 +140,24 @@ struct CLICommandEnvelopeTests { #expect(command.name == expected) } } + + // MARK: - Encoding produces valid JSON + + @Test func allCommandsEncodeToValidJSON() throws { + let commands: [Command] = [ + .open(OpenInput(path: "/tmp")), + .list(ListInput()), + .focus(FocusInput()), + .send(SendInput(text: "test")), + .key(KeyInput(token: "enter")), + .read(ReadInput()), + ] + for cmd in commands { + let envelope = CommandEnvelope(output: .json, command: cmd) + let data = try JSONEncoder().encode(envelope) + let json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + #expect(json["output"] as? String == "json") + #expect(json["command"] != nil) + } + } } -- 2.51.2 From 25693660647e6ec1c7b03ece49f340659a64db2a Mon Sep 17 00:00:00 2001 From: onevclaw Date: Thu, 2 Apr 2026 22:28:16 +0900 Subject: [PATCH 52/56] feat(cli): wire SPM prowl target with smoke/integration tests --- Makefile | 17 +- Package.resolved | 15 + Package.swift | 45 +++ ProwlCLI/CLIExecution.swift | 21 ++ ProwlCLI/CLIRunner.swift | 2 + ProwlCLI/Commands/FocusCommand.swift | 15 +- ProwlCLI/Commands/GlobalOptions.swift | 1 + ProwlCLI/Commands/KeyCommand.swift | 39 +- ProwlCLI/Commands/ListCommand.swift | 13 +- ProwlCLI/Commands/OpenCommand.swift | 41 +- ProwlCLI/Commands/ReadCommand.swift | 25 +- ProwlCLI/Commands/SelectorOptions.swift | 6 +- ProwlCLI/Commands/SendCommand.swift | 78 ++-- ProwlCLI/Output/OutputRenderer.swift | 1 + .../Transport/SocketTransportClient.swift | 1 + ProwlCLITests/ProwlCLIIntegrationTests.swift | 349 ++++++++++++++++++ README.md | 9 +- supacode/App/supacodeApp.swift | 16 + .../CLIService/Shared/SocketConstants.swift | 8 + 19 files changed, 610 insertions(+), 92 deletions(-) create mode 100644 Package.resolved create mode 100644 Package.swift create mode 100644 ProwlCLI/CLIExecution.swift create mode 100644 ProwlCLITests/ProwlCLIIntegrationTests.swift diff --git a/Makefile b/Makefile index 8ad88722..9d9c5697 100644 --- a/Makefile +++ b/Makefile @@ -18,7 +18,7 @@ VERSION ?= BUILD ?= XCODEBUILD_FLAGS ?= .DEFAULT_GOAL := help -.PHONY: build-ghostty-xcframework build-app run-app install-dev-build install-release archive export-archive format lint check test bump-version bump-and-release log-stream +.PHONY: build-ghostty-xcframework build-app build-cli run-app install-dev-build install-release archive export-archive format lint check test test-cli-smoke test-cli-integration bump-version bump-and-release log-stream help: # Display this help. @-+echo "Run make with one of the following targets:" @@ -42,6 +42,9 @@ $(GHOSTTY_BUILD_OUTPUTS): build-app: build-ghostty-xcframework # Build the macOS app (Debug) bash -o pipefail -c 'xcodebuild -project supacode.xcodeproj -scheme supacode -configuration Debug build -skipMacroValidation -clonedSourcePackagesDirPath $(SPM_CACHE_DIR) 2>&1 | mise exec -- xcsift -qw --format toon' +build-cli: # Build Swift CLI binary (SPM) + swift build --product prowl + run-app: build-app # Build then launch (Debug) with log streaming @set -euo pipefail; \ settings="$$(xcodebuild -project supacode.xcodeproj -scheme supacode -configuration Debug -showBuildSettings -json 2>/dev/null)"; \ @@ -197,6 +200,18 @@ export-archive: # Export xarchive test: build-ghostty-xcframework xcodebuild test -project supacode.xcodeproj -scheme supacode -destination "platform=macOS" CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation -clonedSourcePackagesDirPath $(SPM_CACHE_DIR) 2>&1 +test-cli-smoke: build-cli # Smoke test CLI executable + @set -euo pipefail; \ + bin="$$(swift build --show-bin-path)/prowl"; \ + "$$bin" --help >/dev/null; \ + "$$bin" --version >/dev/null; \ + socket="/tmp/prowl-cli-smoke-$$RANDOM.sock"; \ + PROWL_CLI_SOCKET="$$socket" "$$bin" list --json >/tmp/prowl-cli-smoke.json || true; \ + jq -e '.error.code == "APP_NOT_RUNNING"' /tmp/prowl-cli-smoke.json >/dev/null + +test-cli-integration: # Run CLI integration tests via SwiftPM + swift test --filter ProwlCLIIntegrationTests + format: # Format code with swift-format (local only) swift-format -p --in-place --recursive --configuration ./.swift-format.json supacode supacodeTests diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 00000000..1ee2d0bc --- /dev/null +++ b/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "2df18def4fd8abdae1d0f5aeeda482240785c69802373a91637b7fa48e11e3ad", + "pins" : [ + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "626b5b7b2f45e1b0b1c6f4a309296d1d21d7311b", + "version" : "1.7.1" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 00000000..ca3eac8d --- /dev/null +++ b/Package.swift @@ -0,0 +1,45 @@ +// swift-tools-version: 6.2 + +import PackageDescription + +let package = Package( + name: "ProwlCLI", + platforms: [ + .macOS(.v13), + ], + products: [ + .library( + name: "ProwlCLIShared", + targets: ["ProwlCLIShared"] + ), + .executable( + name: "prowl", + targets: ["prowl"] + ), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"), + ], + targets: [ + .target( + name: "ProwlCLIShared", + path: "supacode/CLIService/Shared" + ), + .executableTarget( + name: "prowl", + dependencies: [ + "ProwlCLIShared", + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ], + path: "ProwlCLI" + ), + .testTarget( + name: "ProwlCLITests", + dependencies: [ + "ProwlCLIShared", + "prowl", + ], + path: "ProwlCLITests" + ), + ] +) diff --git a/ProwlCLI/CLIExecution.swift b/ProwlCLI/CLIExecution.swift new file mode 100644 index 00000000..16275479 --- /dev/null +++ b/ProwlCLI/CLIExecution.swift @@ -0,0 +1,21 @@ +// ProwlCLI/CLIExecution.swift +// Common command execution wrapper for consistent JSON/text error rendering. + +import ArgumentParser +import ProwlCLIShared + +enum CLIExecution { + static func run(command: String, output: OutputMode, _ body: () throws -> Void) throws { + do { + try body() + } catch let error as ExitError { + OutputRenderer.renderError( + code: error.code, + message: error.message, + command: command, + mode: output + ) + throw ExitCode.failure + } + } +} diff --git a/ProwlCLI/CLIRunner.swift b/ProwlCLI/CLIRunner.swift index 032af1ae..ca197fe8 100644 --- a/ProwlCLI/CLIRunner.swift +++ b/ProwlCLI/CLIRunner.swift @@ -1,7 +1,9 @@ // ProwlCLI/CLIRunner.swift // Central execution point: send envelope to app, render response. +import ArgumentParser import Foundation +import ProwlCLIShared enum CLIRunner { /// Execute a command envelope by sending it to the running app diff --git a/ProwlCLI/Commands/FocusCommand.swift b/ProwlCLI/Commands/FocusCommand.swift index 6865222a..7e3602d6 100644 --- a/ProwlCLI/Commands/FocusCommand.swift +++ b/ProwlCLI/Commands/FocusCommand.swift @@ -1,6 +1,7 @@ // ProwlCLI/Commands/FocusCommand.swift import ArgumentParser +import ProwlCLIShared struct FocusCommand: ParsableCommand { static let configuration = CommandConfiguration( @@ -12,11 +13,13 @@ struct FocusCommand: ParsableCommand { @OptionGroup var options: GlobalOptions mutating func run() throws { - let sel = try selector.resolve() - let envelope = CommandEnvelope( - output: options.outputMode, - command: .focus(FocusInput(selector: sel)) - ) - try CLIRunner.execute(envelope) + try CLIExecution.run(command: "focus", output: options.outputMode) { + let sel = try selector.resolve() + let envelope = CommandEnvelope( + output: options.outputMode, + command: .focus(FocusInput(selector: sel)) + ) + try CLIRunner.execute(envelope) + } } } diff --git a/ProwlCLI/Commands/GlobalOptions.swift b/ProwlCLI/Commands/GlobalOptions.swift index 6119ee34..27436585 100644 --- a/ProwlCLI/Commands/GlobalOptions.swift +++ b/ProwlCLI/Commands/GlobalOptions.swift @@ -2,6 +2,7 @@ // Shared output options. import ArgumentParser +import ProwlCLIShared struct GlobalOptions: ParsableArguments { @Flag(name: .long, help: "Output in JSON format matching schema contracts.") diff --git a/ProwlCLI/Commands/KeyCommand.swift b/ProwlCLI/Commands/KeyCommand.swift index 48d9b9ce..777ff4d8 100644 --- a/ProwlCLI/Commands/KeyCommand.swift +++ b/ProwlCLI/Commands/KeyCommand.swift @@ -1,6 +1,7 @@ // ProwlCLI/Commands/KeyCommand.swift import ArgumentParser +import ProwlCLIShared struct KeyCommand: ParsableCommand { static let configuration = CommandConfiguration( @@ -18,25 +19,27 @@ struct KeyCommand: ParsableCommand { var token: String mutating func run() throws { - let sel = try selector.resolve() - - guard (1...100).contains(self.repeat) else { - throw ExitError( - code: CLIErrorCode.invalidRepeat, - message: "Repeat count must be between 1 and 100, got \(self.repeat)." + try CLIExecution.run(command: "key", output: options.outputMode) { + let sel = try selector.resolve() + + guard (1...100).contains(self.repeat) else { + throw ExitError( + code: CLIErrorCode.invalidRepeat, + message: "Repeat count must be between 1 and 100, got \(self.repeat)." + ) + } + + let normalized = token.lowercased() + + let envelope = CommandEnvelope( + output: options.outputMode, + command: .key(KeyInput( + selector: sel, + token: normalized, + repeatCount: self.repeat + )) ) + try CLIRunner.execute(envelope) } - - let normalized = token.lowercased() - - let envelope = CommandEnvelope( - output: options.outputMode, - command: .key(KeyInput( - selector: sel, - token: normalized, - repeatCount: self.repeat - )) - ) - try CLIRunner.execute(envelope) } } diff --git a/ProwlCLI/Commands/ListCommand.swift b/ProwlCLI/Commands/ListCommand.swift index 0ab6145c..9633b9ba 100644 --- a/ProwlCLI/Commands/ListCommand.swift +++ b/ProwlCLI/Commands/ListCommand.swift @@ -1,6 +1,7 @@ // ProwlCLI/Commands/ListCommand.swift import ArgumentParser +import ProwlCLIShared struct ListCommand: ParsableCommand { static let configuration = CommandConfiguration( @@ -11,10 +12,12 @@ struct ListCommand: ParsableCommand { @OptionGroup var options: GlobalOptions mutating func run() throws { - let envelope = CommandEnvelope( - output: options.outputMode, - command: .list(ListInput()) - ) - try CLIRunner.execute(envelope) + try CLIExecution.run(command: "list", output: options.outputMode) { + let envelope = CommandEnvelope( + output: options.outputMode, + command: .list(ListInput()) + ) + try CLIRunner.execute(envelope) + } } } diff --git a/ProwlCLI/Commands/OpenCommand.swift b/ProwlCLI/Commands/OpenCommand.swift index 7b04defb..708f1eaa 100644 --- a/ProwlCLI/Commands/OpenCommand.swift +++ b/ProwlCLI/Commands/OpenCommand.swift @@ -2,6 +2,7 @@ import ArgumentParser import Foundation +import ProwlCLIShared struct OpenCommand: ParsableCommand { static let configuration = CommandConfiguration( @@ -15,21 +16,39 @@ struct OpenCommand: ParsableCommand { var path: String? mutating func run() throws { - let resolvedPath: String? = try path.map { try normalizePath($0) } - let envelope = CommandEnvelope( - output: options.outputMode, - command: .open(OpenInput(path: resolvedPath)) - ) - try CLIRunner.execute(envelope) + try CLIExecution.run(command: "open", output: options.outputMode) { + let resolvedPath: String? = try path.map { try normalizePath($0) } + let envelope = CommandEnvelope( + output: options.outputMode, + command: .open(OpenInput(path: resolvedPath)) + ) + try CLIRunner.execute(envelope) + } } private func normalizePath(_ raw: String) throws -> String { - let expanded = NSString(string: raw).expandingTildeInPath - let url = URL(fileURLWithPath: expanded).standardized - let abs = url.path + let path: String + if raw.hasPrefix("file://") { + guard let fileURL = URL(string: raw), fileURL.isFileURL else { + throw ExitError( + code: CLIErrorCode.invalidArgument, + message: "Invalid file URL: \(raw)" + ) + } + path = fileURL.standardizedFileURL.path + } else { + let expanded = NSString(string: raw).expandingTildeInPath + if expanded.hasPrefix("/") { + path = URL(fileURLWithPath: expanded).standardizedFileURL.path + } else { + let cwdURL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true) + path = URL(fileURLWithPath: expanded, relativeTo: cwdURL).standardizedFileURL.path + } + } + let fm = FileManager.default var isDir: ObjCBool = false - guard fm.fileExists(atPath: abs, isDirectory: &isDir) else { + guard fm.fileExists(atPath: path, isDirectory: &isDir) else { throw ExitError( code: CLIErrorCode.pathNotFound, message: "Path not found: \(raw)" @@ -41,6 +60,6 @@ struct OpenCommand: ParsableCommand { message: "Not a directory: \(raw)" ) } - return abs + return path } } diff --git a/ProwlCLI/Commands/ReadCommand.swift b/ProwlCLI/Commands/ReadCommand.swift index 31fe0508..e7a3525b 100644 --- a/ProwlCLI/Commands/ReadCommand.swift +++ b/ProwlCLI/Commands/ReadCommand.swift @@ -1,6 +1,7 @@ // ProwlCLI/Commands/ReadCommand.swift import ArgumentParser +import ProwlCLIShared struct ReadCommand: ParsableCommand { static let configuration = CommandConfiguration( @@ -15,19 +16,21 @@ struct ReadCommand: ParsableCommand { var last: Int? mutating func run() throws { - let sel = try selector.resolve() + try CLIExecution.run(command: "read", output: options.outputMode) { + let sel = try selector.resolve() - if let n = last, n < 1 { - throw ExitError( - code: CLIErrorCode.invalidArgument, - message: "--last requires a positive integer, got \(n)." + if let n = last, n < 1 { + throw ExitError( + code: CLIErrorCode.invalidArgument, + message: "--last requires a positive integer, got \(n)." + ) + } + + let envelope = CommandEnvelope( + output: options.outputMode, + command: .read(ReadInput(selector: sel, last: last)) ) + try CLIRunner.execute(envelope) } - - let envelope = CommandEnvelope( - output: options.outputMode, - command: .read(ReadInput(selector: sel, last: last)) - ) - try CLIRunner.execute(envelope) } } diff --git a/ProwlCLI/Commands/SelectorOptions.swift b/ProwlCLI/Commands/SelectorOptions.swift index f8561ad7..915c8026 100644 --- a/ProwlCLI/Commands/SelectorOptions.swift +++ b/ProwlCLI/Commands/SelectorOptions.swift @@ -2,6 +2,7 @@ // Shared target selector options for commands that support them. import ArgumentParser +import ProwlCLIShared struct SelectorOptions: ParsableArguments { @Option(name: .long, help: "Target worktree by id, name, or path.") @@ -17,8 +18,9 @@ struct SelectorOptions: ParsableArguments { func resolve() throws -> TargetSelector { let provided = [worktree, tab, pane].compactMap { $0 } guard provided.count <= 1 else { - throw ValidationError( - "At most one target selector (--worktree, --tab, --pane) is allowed." + throw ExitError( + code: CLIErrorCode.invalidArgument, + message: "At most one target selector (--worktree, --tab, --pane) is allowed." ) } if let w = worktree { return .worktree(w) } diff --git a/ProwlCLI/Commands/SendCommand.swift b/ProwlCLI/Commands/SendCommand.swift index fd2eb0db..8a624288 100644 --- a/ProwlCLI/Commands/SendCommand.swift +++ b/ProwlCLI/Commands/SendCommand.swift @@ -1,7 +1,13 @@ // ProwlCLI/Commands/SendCommand.swift +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif import ArgumentParser import Foundation +import ProwlCLIShared struct SendCommand: ParsableCommand { static let configuration = CommandConfiguration( @@ -19,47 +25,49 @@ struct SendCommand: ParsableCommand { var text: String? mutating func run() throws { - let sel = try selector.resolve() + try CLIExecution.run(command: "send", output: options.outputMode) { + let sel = try selector.resolve() - // Resolve input source: argv xor stdin - let inputText: String - if let argText = text { - // Check stdin is not also provided - if !isatty(fileno(stdin)) { - // stdin has data too — ambiguous - throw ExitError( - code: CLIErrorCode.invalidArgument, - message: "Cannot provide text as both argument and stdin." - ) - } - inputText = argText - } else if !isatty(fileno(stdin)) { - // Read from stdin - guard let stdinData = try? FileHandle.standardInput.readToEnd(), - let stdinText = String(data: stdinData, encoding: .utf8), - !stdinText.isEmpty - else { + // Resolve input source: argv xor stdin + let inputText: String + if let argText = text { + // Check stdin is not also provided + if isatty(fileno(stdin)) == 0 { + // stdin has data too — ambiguous + throw ExitError( + code: CLIErrorCode.invalidArgument, + message: "Cannot provide text as both argument and stdin." + ) + } + inputText = argText + } else if isatty(fileno(stdin)) == 0 { + // Read from stdin + guard let stdinData = try? FileHandle.standardInput.readToEnd(), + let stdinText = String(data: stdinData, encoding: .utf8), + !stdinText.isEmpty + else { + throw ExitError( + code: CLIErrorCode.emptyInput, + message: "No input provided via argument or stdin." + ) + } + inputText = stdinText + } else { throw ExitError( code: CLIErrorCode.emptyInput, - message: "No input provided via argument or stdin." + message: "No input provided. Pass text as argument or pipe via stdin." ) } - inputText = stdinText - } else { - throw ExitError( - code: CLIErrorCode.emptyInput, - message: "No input provided. Pass text as argument or pipe via stdin." + + let envelope = CommandEnvelope( + output: options.outputMode, + command: .send(SendInput( + selector: sel, + text: inputText, + trailingEnter: !noEnter + )) ) + try CLIRunner.execute(envelope) } - - let envelope = CommandEnvelope( - output: options.outputMode, - command: .send(SendInput( - selector: sel, - text: inputText, - trailingEnter: !noEnter - )) - ) - try CLIRunner.execute(envelope) } } diff --git a/ProwlCLI/Output/OutputRenderer.swift b/ProwlCLI/Output/OutputRenderer.swift index aedd0eb8..36dadd36 100644 --- a/ProwlCLI/Output/OutputRenderer.swift +++ b/ProwlCLI/Output/OutputRenderer.swift @@ -2,6 +2,7 @@ // Renders command responses for terminal output. import Foundation +import ProwlCLIShared enum OutputRenderer { static func render(_ response: CommandResponse, mode: OutputMode) { diff --git a/ProwlCLI/Transport/SocketTransportClient.swift b/ProwlCLI/Transport/SocketTransportClient.swift index 54a210b7..b1658715 100644 --- a/ProwlCLI/Transport/SocketTransportClient.swift +++ b/ProwlCLI/Transport/SocketTransportClient.swift @@ -7,6 +7,7 @@ import Darwin import Glibc #endif import Foundation +import ProwlCLIShared enum SocketTransportClient { /// Send a command envelope to the Prowl app and receive a response. diff --git a/ProwlCLITests/ProwlCLIIntegrationTests.swift b/ProwlCLITests/ProwlCLIIntegrationTests.swift new file mode 100644 index 00000000..9b9c5636 --- /dev/null +++ b/ProwlCLITests/ProwlCLIIntegrationTests.swift @@ -0,0 +1,349 @@ +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif +import Foundation +import ProwlCLIShared +import XCTest + +final class ProwlCLIIntegrationTests: XCTestCase { + private var repoRoot: URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + } + + func testHelpAndVersionSmoke() throws { + let version = try runProwl(args: ["--version"]) + XCTAssertEqual(version.exitCode, 0) + XCTAssertTrue(version.stdout.contains("1.0.0-dev")) + + let help = try runProwl(args: ["--help"]) + XCTAssertEqual(help.exitCode, 0) + XCTAssertTrue(help.stdout.contains("USAGE:")) + } + + func testListReturnsAppNotRunningWhenSocketUnavailable() throws { + let socketPath = temporarySocketPath(suffix: "app-not-running") + let result = try runProwl( + args: ["list", "--json"], + environment: [ProwlSocket.environmentKey: socketPath] + ) + + XCTAssertNotEqual(result.exitCode, 0) + let payload = try jsonObject(from: result.stdout) + XCTAssertEqual(payload["ok"] as? Bool, false) + let error = try XCTUnwrap(payload["error"] as? [String: Any]) + XCTAssertEqual(error["code"] as? String, CLIErrorCode.appNotRunning) + } + + func testOpenCommandRoundTripsOverSocket() throws { + let socketPath = temporarySocketPath(suffix: "open") + let response = try CommandResponse( + ok: true, + command: "open", + schemaVersion: "prowl.cli.open.v1", + data: RawJSON(encoding: OpenPayload( + resolution: "exact-root", + broughtToFront: true + )) + ) + + let (requestData, result) = try runWithMockServer( + socketPath: socketPath, + response: response, + args: ["open", ".", "--json"] + ) + + XCTAssertEqual(result.exitCode, 0) + let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: requestData) + if case .open(let input) = envelope.command { + XCTAssertEqual(input.path, repoRoot.path) + } else { + XCTFail("Expected open command envelope") + } + + let payload = try jsonObject(from: result.stdout) + XCTAssertEqual(payload["ok"] as? Bool, true) + XCTAssertEqual(payload["command"] as? String, "open") + } + + func testFocusCommandRoundTripsOverSocket() throws { + let socketPath = temporarySocketPath(suffix: "focus") + let response = CommandResponse( + ok: true, + command: "focus", + schemaVersion: "prowl.cli.focus.v1" + ) + + let (requestData, result) = try runWithMockServer( + socketPath: socketPath, + response: response, + args: ["focus", "--pane", "pane-123", "--json"] + ) + + XCTAssertEqual(result.exitCode, 0) + let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: requestData) + if case .focus(let input) = envelope.command { + XCTAssertEqual(input.selector, .pane("pane-123")) + } else { + XCTFail("Expected focus command envelope") + } + + let payload = try jsonObject(from: result.stdout) + XCTAssertEqual(payload["ok"] as? Bool, true) + XCTAssertEqual(payload["command"] as? String, "focus") + } + + // MARK: - Helpers + + private func runWithMockServer( + socketPath: String, + response: CommandResponse, + args: [String] + ) throws -> (Data, CommandResult) { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let responseData = try encoder.encode(response) + let server = try MockSocketServer(socketPath: socketPath, responseData: responseData) + try server.start() + + let result = try runProwl( + args: args, + environment: [ProwlSocket.environmentKey: socketPath] + ) + + let requestData = try XCTUnwrap(server.waitForRequest(timeout: 2.0), "No request received by mock server") + return (requestData, result) + } + + private func runProwl( + args: [String], + environment: [String: String] = [:] + ) throws -> CommandResult { + let binaryPath = try ensureProwlBinary() + var mergedEnvironment = ProcessInfo.processInfo.environment + for (key, value) in environment { + mergedEnvironment[key] = value + } + return try runProcess( + executable: binaryPath, + arguments: args, + currentDirectory: repoRoot.path, + environment: mergedEnvironment + ) + } + + private func ensureProwlBinary() throws -> String { + let candidates = [ + repoRoot.appendingPathComponent(".build/debug/prowl").path, + repoRoot.appendingPathComponent(".build/arm64-apple-macosx/debug/prowl").path, + repoRoot.appendingPathComponent(".build/x86_64-apple-macosx/debug/prowl").path, + ] + + if let existing = candidates.first(where: { FileManager.default.isExecutableFile(atPath: $0) }) { + return existing + } + + throw NSError( + domain: "ProwlCLITests", + code: 1, + userInfo: [ + NSLocalizedDescriptionKey: "Could not find prowl binary. Checked: \(candidates.joined(separator: ", "))", + ] + ) + } + + private func runProcess( + executable: String, + arguments: [String], + currentDirectory: String, + environment: [String: String]? = nil + ) throws -> CommandResult { + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + process.currentDirectoryURL = URL(fileURLWithPath: currentDirectory) + if let environment { + process.environment = environment + } + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + try process.run() + process.waitUntilExit() + + let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + return CommandResult(exitCode: process.terminationStatus, stdout: stdout, stderr: stderr) + } + + private func jsonObject(from text: String) throws -> [String: Any] { + let data = try XCTUnwrap(text.data(using: .utf8)) + return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + + private func temporarySocketPath(suffix: String) -> String { + let uuid = UUID().uuidString.lowercased() + let filename = "prowl-cli-\(suffix)-\(uuid).sock" + return (NSTemporaryDirectory() as NSString).appendingPathComponent(filename) + } +} + +private struct OpenPayload: Encodable { + let resolution: String + + enum CodingKeys: String, CodingKey { + case resolution + case broughtToFront = "brought_to_front" + } + + let broughtToFront: Bool +} + +private struct CommandResult { + let exitCode: Int32 + let stdout: String + let stderr: String +} + +private final class MockSocketServer: @unchecked Sendable { + private let socketPath: String + private let responseData: Data + + private var serverFD: Int32 = -1 + private var receivedRequestData: Data? + private let lock = NSLock() + private let requestSemaphore = DispatchSemaphore(value: 0) + + init(socketPath: String, responseData: Data) throws { + self.socketPath = socketPath + self.responseData = responseData + } + + deinit { + if serverFD >= 0 { + close(serverFD) + } + unlink(socketPath) + } + + func start() throws { + unlink(socketPath) + + serverFD = socket(AF_UNIX, SOCK_STREAM, 0) + guard serverFD >= 0 else { + throw MockSocketError.socketCreateFailed + } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + + let pathBytes = Array(socketPath.utf8) + let maxLength = MemoryLayout.size(ofValue: addr.sun_path) - 1 + let copyLength = min(pathBytes.count, maxLength) + + withUnsafeMutableBytes(of: &addr.sun_path) { buffer in + for index in 0...size)) + } + } + + guard bindResult == 0 else { + throw MockSocketError.bindFailed + } + + guard listen(serverFD, 1) == 0 else { + throw MockSocketError.listenFailed + } + + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let self else { return } + let clientFD = accept(self.serverFD, nil, nil) + guard clientFD >= 0 else { return } + defer { close(clientFD) } + + do { + let lengthData = try self.readExact(fd: clientFD, count: 4) + let bodyLength = lengthData.withUnsafeBytes { + UInt32(bigEndian: $0.load(as: UInt32.self)) + } + let body = try self.readExact(fd: clientFD, count: Int(bodyLength)) + + self.lock.lock() + self.receivedRequestData = body + self.lock.unlock() + self.requestSemaphore.signal() + + var responseLength = UInt32(self.responseData.count).bigEndian + try withUnsafeBytes(of: &responseLength) { lengthBytes in + try self.writeAll(fd: clientFD, bytes: lengthBytes) + } + try self.responseData.withUnsafeBytes { bytes in + try self.writeAll(fd: clientFD, bytes: bytes) + } + } catch { + self.requestSemaphore.signal() + } + } + } + + func waitForRequest(timeout: TimeInterval) -> Data? { + let result = requestSemaphore.wait(timeout: .now() + timeout) + guard result == .success else { return nil } + + lock.lock() + defer { lock.unlock() } + return receivedRequestData + } + + private func readExact(fd: Int32, count: Int) throws -> Data { + var data = Data(capacity: count) + var remaining = count + let bufferSize = min(count, 65536) + let buffer = UnsafeMutableRawPointer.allocate(byteCount: bufferSize, alignment: 1) + defer { buffer.deallocate() } + + while remaining > 0 { + let toRead = min(remaining, bufferSize) + let readCount = Darwin.read(fd, buffer, toRead) + guard readCount > 0 else { + throw MockSocketError.readFailed + } + data.append(buffer.assumingMemoryBound(to: UInt8.self), count: readCount) + remaining -= readCount + } + + return data + } + + private func writeAll(fd: Int32, bytes: UnsafeRawBufferPointer) throws { + var offset = 0 + while offset < bytes.count { + let written = Darwin.write(fd, bytes.baseAddress!.advanced(by: offset), bytes.count - offset) + guard written > 0 else { + throw MockSocketError.writeFailed + } + offset += written + } + } +} + +private enum MockSocketError: Error { + case socketCreateFailed + case bindFailed + case listenFailed + case readFailed + case writeFailed +} diff --git a/README.md b/README.md index 58e2491a..a7bbea1d 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,12 @@ make run-app # Build and launch ## Development ```bash -make check # Run swiftformat and swiftlint -make test # Run tests -make format # Run swift-format +make check # Run swiftformat and swiftlint +make test # Run app/unit tests (xcodebuild) +make build-cli # Build `prowl` CLI via SwiftPM +make test-cli-smoke # Quick CLI smoke checks +make test-cli-integration # End-to-end CLI socket integration tests +make format # Run swift-format ``` ## Contributing diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index 63f8cc3c..4e9f074f 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -31,6 +31,7 @@ private enum GhosttyCLI { final class SupacodeAppDelegate: NSObject, NSApplicationDelegate { var appStore: StoreOf? var terminalManager: WorktreeTerminalManager? + var cliSocketServer: CLISocketServer? func applicationDidFinishLaunching(_ notification: Notification) { // Disable press-and-hold accent menu so that key repeat works in the terminal. @@ -52,6 +53,7 @@ final class SupacodeAppDelegate: NSObject, NSApplicationDelegate { } func applicationWillTerminate(_ notification: Notification) { + defer { cliSocketServer?.stop() } guard appStore?.state.settings.restoreTerminalLayoutOnLaunch == true else { return } guard appStore?.state.suppressLayoutSaveUntilRelaunch != true else { return } terminalManager?.persistLayoutSnapshotSync() @@ -91,6 +93,7 @@ struct SupacodeApp: App { @State private var terminalManager: WorktreeTerminalManager @State private var worktreeInfoWatcher: WorktreeInfoWatcherManager @State private var commandKeyObserver: CommandKeyObserver + @State private var cliSocketServer: CLISocketServer @State private var store: StoreOf @MainActor init() { @@ -172,11 +175,24 @@ struct SupacodeApp: App { ) } _store = State(initialValue: appStore) + + let cliRouter = CLICommandRouter() + let cliServer = CLISocketServer(router: cliRouter) + let cliLogger = SupaLogger("CLIService") + do { + try cliServer.start() + cliLogger.info("CLI socket server started at \(ProwlSocket.defaultPath)") + } catch { + cliLogger.warning("Failed to start CLI socket server: \(String(describing: error))") + } + _cliSocketServer = State(initialValue: cliServer) + runtime.onQuit = { [weak appStore] in appStore?.send(.requestQuit) } appDelegate.appStore = appStore appDelegate.terminalManager = terminalManager + appDelegate.cliSocketServer = cliServer SettingsWindowManager.shared.configure( store: appStore, ghosttyShortcuts: shortcuts, diff --git a/supacode/CLIService/Shared/SocketConstants.swift b/supacode/CLIService/Shared/SocketConstants.swift index 09231dc5..b7194722 100644 --- a/supacode/CLIService/Shared/SocketConstants.swift +++ b/supacode/CLIService/Shared/SocketConstants.swift @@ -4,9 +4,17 @@ import Foundation public enum ProwlSocket { + /// Environment variable for overriding socket path. + public static let environmentKey = "PROWL_CLI_SOCKET" + /// Default Unix domain socket path. /// Located in user's temporary directory to avoid permission issues. + /// + /// If `PROWL_CLI_SOCKET` is set and not empty, it takes precedence. public static var defaultPath: String { + if let override = ProcessInfo.processInfo.environment[environmentKey], !override.isEmpty { + return override + } let tmpDir = NSTemporaryDirectory() return (tmpDir as NSString).appendingPathComponent("prowl-cli.sock") } -- 2.51.2 From 59509af7e2f955f356c9ce73c81124a1b436b596 Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 2 Apr 2026 22:55:44 +0900 Subject: [PATCH 53/56] fix: use DispatchQueue for CLI accept loop and stabilize envelope tests - CLISocketServer: replace Task.detached with a dedicated DispatchQueue for the blocking accept() loop. The old approach starved the Swift cooperative thread pool, hanging the app during xcodebuild testing. - CLICommandEnvelopeTests: replace brittle JSON-structure assertions with round-trip encode/decode tests; add nil-path and valid-JSON coverage. --- supacode/CLIService/CLISocketServer.swift | 24 ++++---- supacodeTests/CLICommandEnvelopeTests.swift | 65 +++++++++++++++++---- 2 files changed, 64 insertions(+), 25 deletions(-) diff --git a/supacode/CLIService/CLISocketServer.swift b/supacode/CLIService/CLISocketServer.swift index da3197c3..7e26dfbe 100644 --- a/supacode/CLIService/CLISocketServer.swift +++ b/supacode/CLIService/CLISocketServer.swift @@ -14,7 +14,7 @@ final class CLISocketServer { private let socketPath: String private var serverFD: Int32 = -1 private var isRunning = false - private var acceptTask: Task? + private let acceptQueue = DispatchQueue(label: "com.onevcat.prowl.cli-accept", qos: .userInitiated) init(router: CLICommandRouter, socketPath: String = ProwlSocket.defaultPath) { self.router = router @@ -64,17 +64,18 @@ final class CLISocketServer { isRunning = true - // Capture fd value for the nonisolated accept loop + // Run the blocking accept loop on a dedicated dispatch queue so it does + // not occupy a Swift cooperative-thread-pool thread (which would starve + // the concurrency runtime and hang the app – especially during testing). let listeningFD = serverFD - acceptTask = Task.detached { [weak self] in - await Self.acceptLoop(serverFD: listeningFD, server: self) + acceptQueue.async { [weak self] in + Self.acceptLoop(serverFD: listeningFD, server: self) } } /// Stop the server and clean up. func stop() { isRunning = false - acceptTask?.cancel() if serverFD >= 0 { close(serverFD) serverFD = -1 @@ -82,18 +83,15 @@ final class CLISocketServer { unlink(socketPath) } - // MARK: - Accept loop (static + nonisolated to avoid actor isolation issues) + // MARK: - Accept loop (runs on acceptQueue, NOT in Swift concurrency) - private static func acceptLoop(serverFD: Int32, server: CLISocketServer?) async { - while !Task.isCancelled { + private nonisolated static func acceptLoop(serverFD: Int32, server: CLISocketServer?) { + while true { let clientFD = Darwin.accept(serverFD, nil, nil) guard clientFD >= 0 else { - if !Task.isCancelled { - try? await Task.sleep(for: .milliseconds(100)) - } - continue + // serverFD was closed (stop() called) or an error occurred – exit. + return } - // Dispatch to MainActor for routing if let server { Task { @MainActor in await server.handleClient(clientFD: clientFD) diff --git a/supacodeTests/CLICommandEnvelopeTests.swift b/supacodeTests/CLICommandEnvelopeTests.swift index 74e1df16..134b9ecc 100644 --- a/supacodeTests/CLICommandEnvelopeTests.swift +++ b/supacodeTests/CLICommandEnvelopeTests.swift @@ -8,34 +8,55 @@ import Testing struct CLICommandEnvelopeTests { - // MARK: - CommandEnvelope encoding stability + // MARK: - CommandEnvelope round-trip - @Test func envelopeOpenEncodesCorrectly() throws { + @Test func envelopeOpenRoundTrips() throws { let envelope = CommandEnvelope( output: .json, command: .open(OpenInput(path: "/Users/test/project")) ) let data = try JSONEncoder().encode(envelope) - let json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let decoded = try JSONDecoder().decode(CommandEnvelope.self, from: data) - #expect(json["output"] as? String == "json") - let command = try #require(json["command"] as? [String: Any]) - let open = try #require(command["open"] as? [String: Any]) - #expect(open["path"] as? String == "/Users/test/project") + #expect(decoded.output == .json) + if case .open(let input) = decoded.command { + #expect(input.path == "/Users/test/project") + } else { + Issue.record("Expected .open command") + } } - @Test func envelopeListEncodesCorrectly() throws { + @Test func envelopeOpenNilPathRoundTrips() throws { + let envelope = CommandEnvelope( + output: .text, + command: .open(OpenInput(path: nil)) + ) + let data = try JSONEncoder().encode(envelope) + let decoded = try JSONDecoder().decode(CommandEnvelope.self, from: data) + + if case .open(let input) = decoded.command { + #expect(input.path == nil) + } else { + Issue.record("Expected .open command") + } + } + + @Test func envelopeListRoundTrips() throws { let envelope = CommandEnvelope( output: .text, command: .list(ListInput()) ) let data = try JSONEncoder().encode(envelope) - let json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) - #expect(json["output"] as? String == "text") - #expect(json["command"] != nil) + let decoded = try JSONDecoder().decode(CommandEnvelope.self, from: data) + #expect(decoded.output == .text) + if case .list = decoded.command { + // expected + } else { + Issue.record("Expected .list command") + } } - @Test func envelopeSendWithSelectorEncodesCorrectly() throws { + @Test func envelopeSendWithSelectorRoundTrips() throws { let envelope = CommandEnvelope( output: .json, command: .send(SendInput( @@ -119,4 +140,24 @@ struct CLICommandEnvelopeTests { #expect(command.name == expected) } } + + // MARK: - Encoding produces valid JSON + + @Test func allCommandsEncodeToValidJSON() throws { + let commands: [Command] = [ + .open(OpenInput(path: "/tmp")), + .list(ListInput()), + .focus(FocusInput()), + .send(SendInput(text: "test")), + .key(KeyInput(token: "enter")), + .read(ReadInput()), + ] + for cmd in commands { + let envelope = CommandEnvelope(output: .json, command: cmd) + let data = try JSONEncoder().encode(envelope) + let json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + #expect(json["output"] as? String == "json") + #expect(json["command"] != nil) + } + } } -- 2.51.2 From 1b12d3b968c3a3f13031b4875659b0b039e1ba3a Mon Sep 17 00:00:00 2001 From: onevclaw Date: Thu, 2 Apr 2026 23:11:31 +0900 Subject: [PATCH 54/56] ci: run CLI smoke/integration tests --- .github/workflows/test.yml | 2 ++ Makefile | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c2d35f95..f2dd137d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,3 +21,5 @@ jobs: - run: make lint - run: make build-app - run: make test + - run: make test-cli-smoke + - run: make test-cli-integration diff --git a/Makefile b/Makefile index 9d9c5697..679d89f6 100644 --- a/Makefile +++ b/Makefile @@ -203,8 +203,10 @@ test: build-ghostty-xcframework test-cli-smoke: build-cli # Smoke test CLI executable @set -euo pipefail; \ bin="$$(swift build --show-bin-path)/prowl"; \ - "$$bin" --help >/dev/null; \ - "$$bin" --version >/dev/null; \ + help_output="$$("$$bin" --help)"; \ + version_output="$$("$$bin" --version)"; \ + echo "$$help_output" | grep -q "USAGE:"; \ + echo "$$version_output" | grep -q "1.0.0"; \ socket="/tmp/prowl-cli-smoke-$$RANDOM.sock"; \ PROWL_CLI_SOCKET="$$socket" "$$bin" list --json >/tmp/prowl-cli-smoke.json || true; \ jq -e '.error.code == "APP_NOT_RUNNING"' /tmp/prowl-cli-smoke.json >/dev/null -- 2.51.2 From 21294db07ba5b2edaed63805e040d4da8f858ca9 Mon Sep 17 00:00:00 2001 From: onevclaw Date: Thu, 2 Apr 2026 23:19:10 +0900 Subject: [PATCH 55/56] test: loosen CLI version smoke check --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 679d89f6..78e721a4 100644 --- a/Makefile +++ b/Makefile @@ -206,7 +206,7 @@ test-cli-smoke: build-cli # Smoke test CLI executable help_output="$$("$$bin" --help)"; \ version_output="$$("$$bin" --version)"; \ echo "$$help_output" | grep -q "USAGE:"; \ - echo "$$version_output" | grep -q "1.0.0"; \ + echo "$$version_output" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?$$'; \ socket="/tmp/prowl-cli-smoke-$$RANDOM.sock"; \ PROWL_CLI_SOCKET="$$socket" "$$bin" list --json >/tmp/prowl-cli-smoke.json || true; \ jq -e '.error.code == "APP_NOT_RUNNING"' /tmp/prowl-cli-smoke.json >/dev/null -- 2.51.2 From 1b9f6349ff2212c7c73ac823b0d1029d2a653932 Mon Sep 17 00:00:00 2001 From: Friday Date: Thu, 2 Apr 2026 23:33:46 +0800 Subject: [PATCH 56/56] fix(test): remove unnecessary skipInFlightEffects call The clearTerminalLayoutSnapshotShowsSuccessToast test calls skipInFlightEffects() after all effects have already completed, which causes a test failure with newer TCA versions that enforce the presence of in-flight effects. Remove the redundant call. --- supacodeTests/AppFeatureSettingsChangedTests.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/supacodeTests/AppFeatureSettingsChangedTests.swift b/supacodeTests/AppFeatureSettingsChangedTests.swift index 752f2052..a22ebfbe 100644 --- a/supacodeTests/AppFeatureSettingsChangedTests.swift +++ b/supacodeTests/AppFeatureSettingsChangedTests.swift @@ -111,6 +111,5 @@ struct AppFeatureSettingsChangedTests { await store.receive(\.repositories.showToast) { $0.repositories.statusToast = .success("Saved terminal layout cleared") } - await store.skipInFlightEffects() } } -- 2.51.2