native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #127 from onevcat/onevclaw/issue-105-cli-v1-followup

feat(cli): wire SPM prowl target with smoke and integration tests

authored by

onevpaw and committed by
GitHub
cdddde0c 6eb283d5

+2229 -4
+2
.github/workflows/test.yml
··· 21 21 - run: make lint 22 22 - run: make build-app 23 23 - run: make test 24 + - run: make test-cli-smoke 25 + - run: make test-cli-integration
+18 -1
Makefile
··· 18 18 BUILD ?= 19 19 XCODEBUILD_FLAGS ?= 20 20 .DEFAULT_GOAL := help 21 - .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 21 + .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 22 22 23 23 help: # Display this help. 24 24 @-+echo "Run make with one of the following targets:" ··· 41 41 42 42 build-app: build-ghostty-xcframework # Build the macOS app (Debug) 43 43 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' 44 + 45 + build-cli: # Build Swift CLI binary (SPM) 46 + swift build --product prowl 44 47 45 48 run-app: build-app # Build then launch (Debug) with log streaming 46 49 @set -euo pipefail; \ ··· 196 199 197 200 test: build-ghostty-xcframework 198 201 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 202 + 203 + test-cli-smoke: build-cli # Smoke test CLI executable 204 + @set -euo pipefail; \ 205 + bin="$$(swift build --show-bin-path)/prowl"; \ 206 + help_output="$$("$$bin" --help)"; \ 207 + version_output="$$("$$bin" --version)"; \ 208 + echo "$$help_output" | grep -q "USAGE:"; \ 209 + echo "$$version_output" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?$$'; \ 210 + socket="/tmp/prowl-cli-smoke-$$RANDOM.sock"; \ 211 + PROWL_CLI_SOCKET="$$socket" "$$bin" list --json >/tmp/prowl-cli-smoke.json || true; \ 212 + jq -e '.error.code == "APP_NOT_RUNNING"' /tmp/prowl-cli-smoke.json >/dev/null 213 + 214 + test-cli-integration: # Run CLI integration tests via SwiftPM 215 + swift test --filter ProwlCLIIntegrationTests 199 216 200 217 format: # Format code with swift-format (local only) 201 218 swift-format -p --in-place --recursive --configuration ./.swift-format.json supacode supacodeTests
+15
Package.resolved
··· 1 + { 2 + "originHash" : "2df18def4fd8abdae1d0f5aeeda482240785c69802373a91637b7fa48e11e3ad", 3 + "pins" : [ 4 + { 5 + "identity" : "swift-argument-parser", 6 + "kind" : "remoteSourceControl", 7 + "location" : "https://github.com/apple/swift-argument-parser", 8 + "state" : { 9 + "revision" : "626b5b7b2f45e1b0b1c6f4a309296d1d21d7311b", 10 + "version" : "1.7.1" 11 + } 12 + } 13 + ], 14 + "version" : 3 15 + }
+45
Package.swift
··· 1 + // swift-tools-version: 6.2 2 + 3 + import PackageDescription 4 + 5 + let package = Package( 6 + name: "ProwlCLI", 7 + platforms: [ 8 + .macOS(.v13), 9 + ], 10 + products: [ 11 + .library( 12 + name: "ProwlCLIShared", 13 + targets: ["ProwlCLIShared"] 14 + ), 15 + .executable( 16 + name: "prowl", 17 + targets: ["prowl"] 18 + ), 19 + ], 20 + dependencies: [ 21 + .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"), 22 + ], 23 + targets: [ 24 + .target( 25 + name: "ProwlCLIShared", 26 + path: "supacode/CLIService/Shared" 27 + ), 28 + .executableTarget( 29 + name: "prowl", 30 + dependencies: [ 31 + "ProwlCLIShared", 32 + .product(name: "ArgumentParser", package: "swift-argument-parser"), 33 + ], 34 + path: "ProwlCLI" 35 + ), 36 + .testTarget( 37 + name: "ProwlCLITests", 38 + dependencies: [ 39 + "ProwlCLIShared", 40 + "prowl", 41 + ], 42 + path: "ProwlCLITests" 43 + ), 44 + ] 45 + )
+21
ProwlCLI/CLIExecution.swift
··· 1 + // ProwlCLI/CLIExecution.swift 2 + // Common command execution wrapper for consistent JSON/text error rendering. 3 + 4 + import ArgumentParser 5 + import ProwlCLIShared 6 + 7 + enum CLIExecution { 8 + static func run(command: String, output: OutputMode, _ body: () throws -> Void) throws { 9 + do { 10 + try body() 11 + } catch let error as ExitError { 12 + OutputRenderer.renderError( 13 + code: error.code, 14 + message: error.message, 15 + command: command, 16 + mode: output 17 + ) 18 + throw ExitCode.failure 19 + } 20 + } 21 + }
+40
ProwlCLI/CLIRunner.swift
··· 1 + // ProwlCLI/CLIRunner.swift 2 + // Central execution point: send envelope to app, render response. 3 + 4 + import ArgumentParser 5 + import Foundation 6 + import ProwlCLIShared 7 + 8 + enum CLIRunner { 9 + /// Execute a command envelope by sending it to the running app 10 + /// and rendering the response. 11 + static func execute(_ envelope: CommandEnvelope) throws { 12 + do { 13 + let responseData = try SocketTransportClient.send(envelope) 14 + let decoder = JSONDecoder() 15 + let response = try decoder.decode(CommandResponse.self, from: responseData) 16 + OutputRenderer.render(response, mode: envelope.output) 17 + if !response.ok { 18 + throw ExitCode.failure 19 + } 20 + } catch let error as ExitError { 21 + OutputRenderer.renderError( 22 + code: error.code, 23 + message: error.message, 24 + command: envelope.command.name, 25 + mode: envelope.output 26 + ) 27 + throw ExitCode.failure 28 + } catch is ExitCode { 29 + throw ExitCode.failure 30 + } catch { 31 + OutputRenderer.renderError( 32 + code: CLIErrorCode.transportFailed, 33 + message: error.localizedDescription, 34 + command: envelope.command.name, 35 + mode: envelope.output 36 + ) 37 + throw ExitCode.failure 38 + } 39 + } 40 + }
+25
ProwlCLI/Commands/FocusCommand.swift
··· 1 + // ProwlCLI/Commands/FocusCommand.swift 2 + 3 + import ArgumentParser 4 + import ProwlCLIShared 5 + 6 + struct FocusCommand: ParsableCommand { 7 + static let configuration = CommandConfiguration( 8 + commandName: "focus", 9 + abstract: "Focus a worktree, tab, or pane and bring app to front." 10 + ) 11 + 12 + @OptionGroup var selector: SelectorOptions 13 + @OptionGroup var options: GlobalOptions 14 + 15 + mutating func run() throws { 16 + try CLIExecution.run(command: "focus", output: options.outputMode) { 17 + let sel = try selector.resolve() 18 + let envelope = CommandEnvelope( 19 + output: options.outputMode, 20 + command: .focus(FocusInput(selector: sel)) 21 + ) 22 + try CLIRunner.execute(envelope) 23 + } 24 + } 25 + }
+14
ProwlCLI/Commands/GlobalOptions.swift
··· 1 + // ProwlCLI/Commands/GlobalOptions.swift 2 + // Shared output options. 3 + 4 + import ArgumentParser 5 + import ProwlCLIShared 6 + 7 + struct GlobalOptions: ParsableArguments { 8 + @Flag(name: .long, help: "Output in JSON format matching schema contracts.") 9 + var json = false 10 + 11 + var outputMode: OutputMode { 12 + json ? .json : .text 13 + } 14 + }
+45
ProwlCLI/Commands/KeyCommand.swift
··· 1 + // ProwlCLI/Commands/KeyCommand.swift 2 + 3 + import ArgumentParser 4 + import ProwlCLIShared 5 + 6 + struct KeyCommand: ParsableCommand { 7 + static let configuration = CommandConfiguration( 8 + commandName: "key", 9 + abstract: "Send a key event to a terminal pane." 10 + ) 11 + 12 + @OptionGroup var selector: SelectorOptions 13 + @OptionGroup var options: GlobalOptions 14 + 15 + @Option(name: .long, help: "Number of times to repeat the key (1-100).") 16 + var `repeat`: Int = 1 17 + 18 + @Argument(help: "Key token (e.g. enter, esc, tab, ctrl-c, up, down).") 19 + var token: String 20 + 21 + mutating func run() throws { 22 + try CLIExecution.run(command: "key", output: options.outputMode) { 23 + let sel = try selector.resolve() 24 + 25 + guard (1...100).contains(self.repeat) else { 26 + throw ExitError( 27 + code: CLIErrorCode.invalidRepeat, 28 + message: "Repeat count must be between 1 and 100, got \(self.repeat)." 29 + ) 30 + } 31 + 32 + let normalized = token.lowercased() 33 + 34 + let envelope = CommandEnvelope( 35 + output: options.outputMode, 36 + command: .key(KeyInput( 37 + selector: sel, 38 + token: normalized, 39 + repeatCount: self.repeat 40 + )) 41 + ) 42 + try CLIRunner.execute(envelope) 43 + } 44 + } 45 + }
+23
ProwlCLI/Commands/ListCommand.swift
··· 1 + // ProwlCLI/Commands/ListCommand.swift 2 + 3 + import ArgumentParser 4 + import ProwlCLIShared 5 + 6 + struct ListCommand: ParsableCommand { 7 + static let configuration = CommandConfiguration( 8 + commandName: "list", 9 + abstract: "List all worktrees, tabs, and panes." 10 + ) 11 + 12 + @OptionGroup var options: GlobalOptions 13 + 14 + mutating func run() throws { 15 + try CLIExecution.run(command: "list", output: options.outputMode) { 16 + let envelope = CommandEnvelope( 17 + output: options.outputMode, 18 + command: .list(ListInput()) 19 + ) 20 + try CLIRunner.execute(envelope) 21 + } 22 + } 23 + }
+65
ProwlCLI/Commands/OpenCommand.swift
··· 1 + // ProwlCLI/Commands/OpenCommand.swift 2 + 3 + import ArgumentParser 4 + import Foundation 5 + import ProwlCLIShared 6 + 7 + struct OpenCommand: ParsableCommand { 8 + static let configuration = CommandConfiguration( 9 + commandName: "open", 10 + abstract: "Open a path in Prowl, or bring the app to front." 11 + ) 12 + 13 + @OptionGroup var options: GlobalOptions 14 + 15 + @Argument(help: "Path to open. Omit to bring Prowl to front.") 16 + var path: String? 17 + 18 + mutating func run() throws { 19 + try CLIExecution.run(command: "open", output: options.outputMode) { 20 + let resolvedPath: String? = try path.map { try normalizePath($0) } 21 + let envelope = CommandEnvelope( 22 + output: options.outputMode, 23 + command: .open(OpenInput(path: resolvedPath)) 24 + ) 25 + try CLIRunner.execute(envelope) 26 + } 27 + } 28 + 29 + private func normalizePath(_ raw: String) throws -> String { 30 + let path: String 31 + if raw.hasPrefix("file://") { 32 + guard let fileURL = URL(string: raw), fileURL.isFileURL else { 33 + throw ExitError( 34 + code: CLIErrorCode.invalidArgument, 35 + message: "Invalid file URL: \(raw)" 36 + ) 37 + } 38 + path = fileURL.standardizedFileURL.path 39 + } else { 40 + let expanded = NSString(string: raw).expandingTildeInPath 41 + if expanded.hasPrefix("/") { 42 + path = URL(fileURLWithPath: expanded).standardizedFileURL.path 43 + } else { 44 + let cwdURL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true) 45 + path = URL(fileURLWithPath: expanded, relativeTo: cwdURL).standardizedFileURL.path 46 + } 47 + } 48 + 49 + let fm = FileManager.default 50 + var isDir: ObjCBool = false 51 + guard fm.fileExists(atPath: path, isDirectory: &isDir) else { 52 + throw ExitError( 53 + code: CLIErrorCode.pathNotFound, 54 + message: "Path not found: \(raw)" 55 + ) 56 + } 57 + guard isDir.boolValue else { 58 + throw ExitError( 59 + code: CLIErrorCode.pathNotDirectory, 60 + message: "Not a directory: \(raw)" 61 + ) 62 + } 63 + return path 64 + } 65 + }
+28
ProwlCLI/Commands/ProwlCommand.swift
··· 1 + // ProwlCLI/Commands/ProwlCommand.swift 2 + // Root command with bare path entry detection. 3 + 4 + import ArgumentParser 5 + import Foundation 6 + 7 + struct ProwlCommand: ParsableCommand { 8 + static let configuration = CommandConfiguration( 9 + commandName: "prowl", 10 + abstract: "Control a running Prowl instance from the command line.", 11 + version: ProwlVersion.current, 12 + subcommands: [ 13 + OpenCommand.self, 14 + ListCommand.self, 15 + FocusCommand.self, 16 + SendCommand.self, 17 + KeyCommand.self, 18 + ReadCommand.self, 19 + ], 20 + defaultSubcommand: OpenCommand.self 21 + ) 22 + } 23 + 24 + // MARK: - Version 25 + 26 + enum ProwlVersion { 27 + static let current = "1.0.0-dev" 28 + }
+36
ProwlCLI/Commands/ReadCommand.swift
··· 1 + // ProwlCLI/Commands/ReadCommand.swift 2 + 3 + import ArgumentParser 4 + import ProwlCLIShared 5 + 6 + struct ReadCommand: ParsableCommand { 7 + static let configuration = CommandConfiguration( 8 + commandName: "read", 9 + abstract: "Read terminal content from a pane." 10 + ) 11 + 12 + @OptionGroup var selector: SelectorOptions 13 + @OptionGroup var options: GlobalOptions 14 + 15 + @Option(name: .long, help: "Number of recent lines to read (omit for snapshot).") 16 + var last: Int? 17 + 18 + mutating func run() throws { 19 + try CLIExecution.run(command: "read", output: options.outputMode) { 20 + let sel = try selector.resolve() 21 + 22 + if let n = last, n < 1 { 23 + throw ExitError( 24 + code: CLIErrorCode.invalidArgument, 25 + message: "--last requires a positive integer, got \(n)." 26 + ) 27 + } 28 + 29 + let envelope = CommandEnvelope( 30 + output: options.outputMode, 31 + command: .read(ReadInput(selector: sel, last: last)) 32 + ) 33 + try CLIRunner.execute(envelope) 34 + } 35 + } 36 + }
+31
ProwlCLI/Commands/SelectorOptions.swift
··· 1 + // ProwlCLI/Commands/SelectorOptions.swift 2 + // Shared target selector options for commands that support them. 3 + 4 + import ArgumentParser 5 + import ProwlCLIShared 6 + 7 + struct SelectorOptions: ParsableArguments { 8 + @Option(name: .long, help: "Target worktree by id, name, or path.") 9 + var worktree: String? 10 + 11 + @Option(name: .long, help: "Target tab by id.") 12 + var tab: String? 13 + 14 + @Option(name: .long, help: "Target pane by id.") 15 + var pane: String? 16 + 17 + /// Validate mutual exclusivity and return typed selector. 18 + func resolve() throws -> TargetSelector { 19 + let provided = [worktree, tab, pane].compactMap { $0 } 20 + guard provided.count <= 1 else { 21 + throw ExitError( 22 + code: CLIErrorCode.invalidArgument, 23 + message: "At most one target selector (--worktree, --tab, --pane) is allowed." 24 + ) 25 + } 26 + if let w = worktree { return .worktree(w) } 27 + if let t = tab { return .tab(t) } 28 + if let p = pane { return .pane(p) } 29 + return .none 30 + } 31 + }
+73
ProwlCLI/Commands/SendCommand.swift
··· 1 + // ProwlCLI/Commands/SendCommand.swift 2 + 3 + #if canImport(Darwin) 4 + import Darwin 5 + #elseif canImport(Glibc) 6 + import Glibc 7 + #endif 8 + import ArgumentParser 9 + import Foundation 10 + import ProwlCLIShared 11 + 12 + struct SendCommand: ParsableCommand { 13 + static let configuration = CommandConfiguration( 14 + commandName: "send", 15 + abstract: "Send text input to a terminal pane." 16 + ) 17 + 18 + @OptionGroup var selector: SelectorOptions 19 + @OptionGroup var options: GlobalOptions 20 + 21 + @Flag(name: .long, help: "Do not send trailing Enter after text.") 22 + var noEnter = false 23 + 24 + @Argument(help: "Text to send. Alternatively pipe via stdin.") 25 + var text: String? 26 + 27 + mutating func run() throws { 28 + try CLIExecution.run(command: "send", output: options.outputMode) { 29 + let sel = try selector.resolve() 30 + 31 + // Resolve input source: argv xor stdin 32 + let inputText: String 33 + if let argText = text { 34 + // Check stdin is not also provided 35 + if isatty(fileno(stdin)) == 0 { 36 + // stdin has data too — ambiguous 37 + throw ExitError( 38 + code: CLIErrorCode.invalidArgument, 39 + message: "Cannot provide text as both argument and stdin." 40 + ) 41 + } 42 + inputText = argText 43 + } else if isatty(fileno(stdin)) == 0 { 44 + // Read from stdin 45 + guard let stdinData = try? FileHandle.standardInput.readToEnd(), 46 + let stdinText = String(data: stdinData, encoding: .utf8), 47 + !stdinText.isEmpty 48 + else { 49 + throw ExitError( 50 + code: CLIErrorCode.emptyInput, 51 + message: "No input provided via argument or stdin." 52 + ) 53 + } 54 + inputText = stdinText 55 + } else { 56 + throw ExitError( 57 + code: CLIErrorCode.emptyInput, 58 + message: "No input provided. Pass text as argument or pipe via stdin." 59 + ) 60 + } 61 + 62 + let envelope = CommandEnvelope( 63 + output: options.outputMode, 64 + command: .send(SendInput( 65 + selector: sel, 66 + text: inputText, 67 + trailingEnter: !noEnter 68 + )) 69 + ) 70 + try CLIRunner.execute(envelope) 71 + } 72 + } 73 + }
+11
ProwlCLI/ExitError.swift
··· 1 + // ProwlCLI/ExitError.swift 2 + // CLI-specific error that carries an error code for JSON output. 3 + 4 + import Foundation 5 + 6 + struct ExitError: Error, CustomStringConvertible { 7 + let code: String 8 + let message: String 9 + 10 + var description: String { message } 11 + }
+50
ProwlCLI/Output/OutputRenderer.swift
··· 1 + // ProwlCLI/Output/OutputRenderer.swift 2 + // Renders command responses for terminal output. 3 + 4 + import Foundation 5 + import ProwlCLIShared 6 + 7 + enum OutputRenderer { 8 + static func render(_ response: CommandResponse, mode: OutputMode) { 9 + switch mode { 10 + case .json: 11 + renderJSON(response) 12 + case .text: 13 + renderText(response) 14 + } 15 + } 16 + 17 + static func renderError(code: String, message: String, command: String, mode: OutputMode) { 18 + let response = CommandResponse( 19 + ok: false, 20 + command: command, 21 + schemaVersion: "prowl.cli.\(command).v1", 22 + error: CommandError(code: code, message: message) 23 + ) 24 + render(response, mode: mode) 25 + } 26 + 27 + // MARK: - JSON 28 + 29 + private static func renderJSON(_ response: CommandResponse) { 30 + let encoder = JSONEncoder() 31 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 32 + if let data = try? encoder.encode(response), 33 + let jsonString = String(data: data, encoding: .utf8) 34 + { 35 + print(jsonString) 36 + } 37 + } 38 + 39 + // MARK: - Text 40 + 41 + private static func renderText(_ response: CommandResponse) { 42 + if response.ok { 43 + print("ok: \(response.command)") 44 + } else if let error = response.error { 45 + FileHandle.standardError.write( 46 + Data("error [\(error.code)]: \(error.message)\n".utf8) 47 + ) 48 + } 49 + } 50 + }
+109
ProwlCLI/Transport/SocketTransportClient.swift
··· 1 + // ProwlCLI/Transport/SocketTransportClient.swift 2 + // Unix domain socket client for communicating with running Prowl app. 3 + 4 + #if canImport(Darwin) 5 + import Darwin 6 + #elseif canImport(Glibc) 7 + import Glibc 8 + #endif 9 + import Foundation 10 + import ProwlCLIShared 11 + 12 + enum SocketTransportClient { 13 + /// Send a command envelope to the Prowl app and receive a response. 14 + static func send(_ envelope: CommandEnvelope) throws -> Data { 15 + let socketPath = ProwlSocket.defaultPath 16 + 17 + // Encode request 18 + let encoder = JSONEncoder() 19 + encoder.outputFormatting = [.sortedKeys] 20 + let requestData = try encoder.encode(envelope) 21 + 22 + // Create socket 23 + let clientFD = socket(AF_UNIX, SOCK_STREAM, 0) 24 + guard clientFD >= 0 else { 25 + throw ExitError( 26 + code: CLIErrorCode.transportFailed, 27 + message: "Failed to create socket." 28 + ) 29 + } 30 + defer { close(clientFD) } 31 + 32 + // Connect 33 + var addr = sockaddr_un() 34 + addr.sun_family = sa_family_t(AF_UNIX) 35 + let pathBytes = Array(socketPath.utf8) 36 + let maxLen = MemoryLayout.size(ofValue: addr.sun_path) - 1 37 + let copyLen = min(pathBytes.count, maxLen) 38 + withUnsafeMutableBytes(of: &addr.sun_path) { sunPathPtr in 39 + for idx in 0..<copyLen { 40 + sunPathPtr[idx] = pathBytes[idx] 41 + } 42 + sunPathPtr[copyLen] = 0 43 + } 44 + 45 + let connectResult = withUnsafePointer(to: &addr) { ptr in 46 + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in 47 + connect(clientFD, sockPtr, socklen_t(MemoryLayout<sockaddr_un>.size)) 48 + } 49 + } 50 + 51 + guard connectResult == 0 else { 52 + throw ExitError( 53 + code: CLIErrorCode.appNotRunning, 54 + message: "Cannot connect to Prowl. Is the app running?" 55 + ) 56 + } 57 + 58 + // Send length-prefixed request: 4-byte big-endian length + JSON payload 59 + var length = UInt32(requestData.count).bigEndian 60 + try withUnsafeBytes(of: &length) { try fdWrite(fildes: clientFD, buffer: $0) } 61 + try requestData.withUnsafeBytes { try fdWrite(fildes: clientFD, buffer: $0) } 62 + 63 + // Read length-prefixed response 64 + let responseLengthData = try fdRead(fildes: clientFD, count: 4) 65 + let responseLength = responseLengthData.withUnsafeBytes { 66 + UInt32(bigEndian: $0.load(as: UInt32.self)) 67 + } 68 + 69 + guard responseLength > 0, responseLength < 10_000_000 else { 70 + throw ExitError( 71 + code: CLIErrorCode.transportFailed, 72 + message: "Invalid response length from app." 73 + ) 74 + } 75 + 76 + return try fdRead(fildes: clientFD, count: Int(responseLength)) 77 + } 78 + 79 + // MARK: - Low-level I/O using Darwin/Glibc read/write 80 + 81 + private static func fdWrite(fildes: Int32, buffer: UnsafeRawBufferPointer) throws { 82 + var offset = 0 83 + while offset < buffer.count { 84 + let written = Darwin.write(fildes, buffer.baseAddress!.advanced(by: offset), buffer.count - offset) 85 + guard written > 0 else { 86 + throw ExitError(code: CLIErrorCode.transportFailed, message: "Socket write failed.") 87 + } 88 + offset += written 89 + } 90 + } 91 + 92 + private static func fdRead(fildes: Int32, count: Int) throws -> Data { 93 + var data = Data(capacity: count) 94 + var remaining = count 95 + let bufferSize = min(count, 65536) 96 + let buffer = UnsafeMutableRawPointer.allocate(byteCount: bufferSize, alignment: 1) 97 + defer { buffer.deallocate() } 98 + while remaining > 0 { 99 + let toRead = min(remaining, bufferSize) 100 + let bytesRead = Darwin.read(fildes, buffer, toRead) 101 + guard bytesRead > 0 else { 102 + throw ExitError(code: CLIErrorCode.transportFailed, message: "Socket read failed.") 103 + } 104 + data.append(buffer.assumingMemoryBound(to: UInt8.self), count: bytesRead) 105 + remaining -= bytesRead 106 + } 107 + return data 108 + } 109 + }
+5
ProwlCLI/main.swift
··· 1 + // ProwlCLI/main.swift 2 + 3 + import ArgumentParser 4 + 5 + ProwlCommand.main()
+349
ProwlCLITests/ProwlCLIIntegrationTests.swift
··· 1 + #if canImport(Darwin) 2 + import Darwin 3 + #elseif canImport(Glibc) 4 + import Glibc 5 + #endif 6 + import Foundation 7 + import ProwlCLIShared 8 + import XCTest 9 + 10 + final class ProwlCLIIntegrationTests: XCTestCase { 11 + private var repoRoot: URL { 12 + URL(fileURLWithPath: #filePath) 13 + .deletingLastPathComponent() 14 + .deletingLastPathComponent() 15 + } 16 + 17 + func testHelpAndVersionSmoke() throws { 18 + let version = try runProwl(args: ["--version"]) 19 + XCTAssertEqual(version.exitCode, 0) 20 + XCTAssertTrue(version.stdout.contains("1.0.0-dev")) 21 + 22 + let help = try runProwl(args: ["--help"]) 23 + XCTAssertEqual(help.exitCode, 0) 24 + XCTAssertTrue(help.stdout.contains("USAGE:")) 25 + } 26 + 27 + func testListReturnsAppNotRunningWhenSocketUnavailable() throws { 28 + let socketPath = temporarySocketPath(suffix: "app-not-running") 29 + let result = try runProwl( 30 + args: ["list", "--json"], 31 + environment: [ProwlSocket.environmentKey: socketPath] 32 + ) 33 + 34 + XCTAssertNotEqual(result.exitCode, 0) 35 + let payload = try jsonObject(from: result.stdout) 36 + XCTAssertEqual(payload["ok"] as? Bool, false) 37 + let error = try XCTUnwrap(payload["error"] as? [String: Any]) 38 + XCTAssertEqual(error["code"] as? String, CLIErrorCode.appNotRunning) 39 + } 40 + 41 + func testOpenCommandRoundTripsOverSocket() throws { 42 + let socketPath = temporarySocketPath(suffix: "open") 43 + let response = try CommandResponse( 44 + ok: true, 45 + command: "open", 46 + schemaVersion: "prowl.cli.open.v1", 47 + data: RawJSON(encoding: OpenPayload( 48 + resolution: "exact-root", 49 + broughtToFront: true 50 + )) 51 + ) 52 + 53 + let (requestData, result) = try runWithMockServer( 54 + socketPath: socketPath, 55 + response: response, 56 + args: ["open", ".", "--json"] 57 + ) 58 + 59 + XCTAssertEqual(result.exitCode, 0) 60 + let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: requestData) 61 + if case .open(let input) = envelope.command { 62 + XCTAssertEqual(input.path, repoRoot.path) 63 + } else { 64 + XCTFail("Expected open command envelope") 65 + } 66 + 67 + let payload = try jsonObject(from: result.stdout) 68 + XCTAssertEqual(payload["ok"] as? Bool, true) 69 + XCTAssertEqual(payload["command"] as? String, "open") 70 + } 71 + 72 + func testFocusCommandRoundTripsOverSocket() throws { 73 + let socketPath = temporarySocketPath(suffix: "focus") 74 + let response = CommandResponse( 75 + ok: true, 76 + command: "focus", 77 + schemaVersion: "prowl.cli.focus.v1" 78 + ) 79 + 80 + let (requestData, result) = try runWithMockServer( 81 + socketPath: socketPath, 82 + response: response, 83 + args: ["focus", "--pane", "pane-123", "--json"] 84 + ) 85 + 86 + XCTAssertEqual(result.exitCode, 0) 87 + let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: requestData) 88 + if case .focus(let input) = envelope.command { 89 + XCTAssertEqual(input.selector, .pane("pane-123")) 90 + } else { 91 + XCTFail("Expected focus command envelope") 92 + } 93 + 94 + let payload = try jsonObject(from: result.stdout) 95 + XCTAssertEqual(payload["ok"] as? Bool, true) 96 + XCTAssertEqual(payload["command"] as? String, "focus") 97 + } 98 + 99 + // MARK: - Helpers 100 + 101 + private func runWithMockServer( 102 + socketPath: String, 103 + response: CommandResponse, 104 + args: [String] 105 + ) throws -> (Data, CommandResult) { 106 + let encoder = JSONEncoder() 107 + encoder.outputFormatting = [.sortedKeys] 108 + let responseData = try encoder.encode(response) 109 + let server = try MockSocketServer(socketPath: socketPath, responseData: responseData) 110 + try server.start() 111 + 112 + let result = try runProwl( 113 + args: args, 114 + environment: [ProwlSocket.environmentKey: socketPath] 115 + ) 116 + 117 + let requestData = try XCTUnwrap(server.waitForRequest(timeout: 2.0), "No request received by mock server") 118 + return (requestData, result) 119 + } 120 + 121 + private func runProwl( 122 + args: [String], 123 + environment: [String: String] = [:] 124 + ) throws -> CommandResult { 125 + let binaryPath = try ensureProwlBinary() 126 + var mergedEnvironment = ProcessInfo.processInfo.environment 127 + for (key, value) in environment { 128 + mergedEnvironment[key] = value 129 + } 130 + return try runProcess( 131 + executable: binaryPath, 132 + arguments: args, 133 + currentDirectory: repoRoot.path, 134 + environment: mergedEnvironment 135 + ) 136 + } 137 + 138 + private func ensureProwlBinary() throws -> String { 139 + let candidates = [ 140 + repoRoot.appendingPathComponent(".build/debug/prowl").path, 141 + repoRoot.appendingPathComponent(".build/arm64-apple-macosx/debug/prowl").path, 142 + repoRoot.appendingPathComponent(".build/x86_64-apple-macosx/debug/prowl").path, 143 + ] 144 + 145 + if let existing = candidates.first(where: { FileManager.default.isExecutableFile(atPath: $0) }) { 146 + return existing 147 + } 148 + 149 + throw NSError( 150 + domain: "ProwlCLITests", 151 + code: 1, 152 + userInfo: [ 153 + NSLocalizedDescriptionKey: "Could not find prowl binary. Checked: \(candidates.joined(separator: ", "))", 154 + ] 155 + ) 156 + } 157 + 158 + private func runProcess( 159 + executable: String, 160 + arguments: [String], 161 + currentDirectory: String, 162 + environment: [String: String]? = nil 163 + ) throws -> CommandResult { 164 + let process = Process() 165 + process.executableURL = URL(fileURLWithPath: executable) 166 + process.arguments = arguments 167 + process.currentDirectoryURL = URL(fileURLWithPath: currentDirectory) 168 + if let environment { 169 + process.environment = environment 170 + } 171 + 172 + let stdoutPipe = Pipe() 173 + let stderrPipe = Pipe() 174 + process.standardOutput = stdoutPipe 175 + process.standardError = stderrPipe 176 + 177 + try process.run() 178 + process.waitUntilExit() 179 + 180 + let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" 181 + let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" 182 + return CommandResult(exitCode: process.terminationStatus, stdout: stdout, stderr: stderr) 183 + } 184 + 185 + private func jsonObject(from text: String) throws -> [String: Any] { 186 + let data = try XCTUnwrap(text.data(using: .utf8)) 187 + return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) 188 + } 189 + 190 + private func temporarySocketPath(suffix: String) -> String { 191 + let uuid = UUID().uuidString.lowercased() 192 + let filename = "prowl-cli-\(suffix)-\(uuid).sock" 193 + return (NSTemporaryDirectory() as NSString).appendingPathComponent(filename) 194 + } 195 + } 196 + 197 + private struct OpenPayload: Encodable { 198 + let resolution: String 199 + 200 + enum CodingKeys: String, CodingKey { 201 + case resolution 202 + case broughtToFront = "brought_to_front" 203 + } 204 + 205 + let broughtToFront: Bool 206 + } 207 + 208 + private struct CommandResult { 209 + let exitCode: Int32 210 + let stdout: String 211 + let stderr: String 212 + } 213 + 214 + private final class MockSocketServer: @unchecked Sendable { 215 + private let socketPath: String 216 + private let responseData: Data 217 + 218 + private var serverFD: Int32 = -1 219 + private var receivedRequestData: Data? 220 + private let lock = NSLock() 221 + private let requestSemaphore = DispatchSemaphore(value: 0) 222 + 223 + init(socketPath: String, responseData: Data) throws { 224 + self.socketPath = socketPath 225 + self.responseData = responseData 226 + } 227 + 228 + deinit { 229 + if serverFD >= 0 { 230 + close(serverFD) 231 + } 232 + unlink(socketPath) 233 + } 234 + 235 + func start() throws { 236 + unlink(socketPath) 237 + 238 + serverFD = socket(AF_UNIX, SOCK_STREAM, 0) 239 + guard serverFD >= 0 else { 240 + throw MockSocketError.socketCreateFailed 241 + } 242 + 243 + var addr = sockaddr_un() 244 + addr.sun_family = sa_family_t(AF_UNIX) 245 + 246 + let pathBytes = Array(socketPath.utf8) 247 + let maxLength = MemoryLayout.size(ofValue: addr.sun_path) - 1 248 + let copyLength = min(pathBytes.count, maxLength) 249 + 250 + withUnsafeMutableBytes(of: &addr.sun_path) { buffer in 251 + for index in 0..<copyLength { 252 + buffer[index] = pathBytes[index] 253 + } 254 + buffer[copyLength] = 0 255 + } 256 + 257 + let bindResult = withUnsafePointer(to: &addr) { pointer in 258 + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { addrPointer in 259 + bind(serverFD, addrPointer, socklen_t(MemoryLayout<sockaddr_un>.size)) 260 + } 261 + } 262 + 263 + guard bindResult == 0 else { 264 + throw MockSocketError.bindFailed 265 + } 266 + 267 + guard listen(serverFD, 1) == 0 else { 268 + throw MockSocketError.listenFailed 269 + } 270 + 271 + DispatchQueue.global(qos: .userInitiated).async { [weak self] in 272 + guard let self else { return } 273 + let clientFD = accept(self.serverFD, nil, nil) 274 + guard clientFD >= 0 else { return } 275 + defer { close(clientFD) } 276 + 277 + do { 278 + let lengthData = try self.readExact(fd: clientFD, count: 4) 279 + let bodyLength = lengthData.withUnsafeBytes { 280 + UInt32(bigEndian: $0.load(as: UInt32.self)) 281 + } 282 + let body = try self.readExact(fd: clientFD, count: Int(bodyLength)) 283 + 284 + self.lock.lock() 285 + self.receivedRequestData = body 286 + self.lock.unlock() 287 + self.requestSemaphore.signal() 288 + 289 + var responseLength = UInt32(self.responseData.count).bigEndian 290 + try withUnsafeBytes(of: &responseLength) { lengthBytes in 291 + try self.writeAll(fd: clientFD, bytes: lengthBytes) 292 + } 293 + try self.responseData.withUnsafeBytes { bytes in 294 + try self.writeAll(fd: clientFD, bytes: bytes) 295 + } 296 + } catch { 297 + self.requestSemaphore.signal() 298 + } 299 + } 300 + } 301 + 302 + func waitForRequest(timeout: TimeInterval) -> Data? { 303 + let result = requestSemaphore.wait(timeout: .now() + timeout) 304 + guard result == .success else { return nil } 305 + 306 + lock.lock() 307 + defer { lock.unlock() } 308 + return receivedRequestData 309 + } 310 + 311 + private func readExact(fd: Int32, count: Int) throws -> Data { 312 + var data = Data(capacity: count) 313 + var remaining = count 314 + let bufferSize = min(count, 65536) 315 + let buffer = UnsafeMutableRawPointer.allocate(byteCount: bufferSize, alignment: 1) 316 + defer { buffer.deallocate() } 317 + 318 + while remaining > 0 { 319 + let toRead = min(remaining, bufferSize) 320 + let readCount = Darwin.read(fd, buffer, toRead) 321 + guard readCount > 0 else { 322 + throw MockSocketError.readFailed 323 + } 324 + data.append(buffer.assumingMemoryBound(to: UInt8.self), count: readCount) 325 + remaining -= readCount 326 + } 327 + 328 + return data 329 + } 330 + 331 + private func writeAll(fd: Int32, bytes: UnsafeRawBufferPointer) throws { 332 + var offset = 0 333 + while offset < bytes.count { 334 + let written = Darwin.write(fd, bytes.baseAddress!.advanced(by: offset), bytes.count - offset) 335 + guard written > 0 else { 336 + throw MockSocketError.writeFailed 337 + } 338 + offset += written 339 + } 340 + } 341 + } 342 + 343 + private enum MockSocketError: Error { 344 + case socketCreateFailed 345 + case bindFailed 346 + case listenFailed 347 + case readFailed 348 + case writeFailed 349 + }
+6 -3
README.md
··· 23 23 ## Development 24 24 25 25 ```bash 26 - make check # Run swiftformat and swiftlint 27 - make test # Run tests 28 - make format # Run swift-format 26 + make check # Run swiftformat and swiftlint 27 + make test # Run app/unit tests (xcodebuild) 28 + make build-cli # Build `prowl` CLI via SwiftPM 29 + make test-cli-smoke # Quick CLI smoke checks 30 + make test-cli-integration # End-to-end CLI socket integration tests 31 + make format # Run swift-format 29 32 ``` 30 33 31 34 ## Contributing
+16
supacode/App/supacodeApp.swift
··· 31 31 final class SupacodeAppDelegate: NSObject, NSApplicationDelegate { 32 32 var appStore: StoreOf<AppFeature>? 33 33 var terminalManager: WorktreeTerminalManager? 34 + var cliSocketServer: CLISocketServer? 34 35 35 36 func applicationDidFinishLaunching(_ notification: Notification) { 36 37 // Disable press-and-hold accent menu so that key repeat works in the terminal. ··· 52 53 } 53 54 54 55 func applicationWillTerminate(_ notification: Notification) { 56 + defer { cliSocketServer?.stop() } 55 57 guard appStore?.state.settings.restoreTerminalLayoutOnLaunch == true else { return } 56 58 guard appStore?.state.suppressLayoutSaveUntilRelaunch != true else { return } 57 59 terminalManager?.persistLayoutSnapshotSync() ··· 91 93 @State private var terminalManager: WorktreeTerminalManager 92 94 @State private var worktreeInfoWatcher: WorktreeInfoWatcherManager 93 95 @State private var commandKeyObserver: CommandKeyObserver 96 + @State private var cliSocketServer: CLISocketServer 94 97 @State private var store: StoreOf<AppFeature> 95 98 96 99 @MainActor init() { ··· 172 175 ) 173 176 } 174 177 _store = State(initialValue: appStore) 178 + 179 + let cliRouter = CLICommandRouter() 180 + let cliServer = CLISocketServer(router: cliRouter) 181 + let cliLogger = SupaLogger("CLIService") 182 + do { 183 + try cliServer.start() 184 + cliLogger.info("CLI socket server started at \(ProwlSocket.defaultPath)") 185 + } catch { 186 + cliLogger.warning("Failed to start CLI socket server: \(String(describing: error))") 187 + } 188 + _cliSocketServer = State(initialValue: cliServer) 189 + 175 190 runtime.onQuit = { [weak appStore] in 176 191 appStore?.send(.requestQuit) 177 192 } 178 193 appDelegate.appStore = appStore 179 194 appDelegate.terminalManager = terminalManager 195 + appDelegate.cliSocketServer = cliServer 180 196 SettingsWindowManager.shared.configure( 181 197 store: appStore, 182 198 ghosttyShortcuts: shortcuts,
+62
supacode/CLIService/CLICommandRouter.swift
··· 1 + // supacode/CLIService/CLICommandRouter.swift 2 + // Routes incoming command envelopes to the appropriate handler. 3 + 4 + import Foundation 5 + 6 + @MainActor 7 + final class CLICommandRouter { 8 + private let openHandler: any CommandHandler 9 + private let listHandler: any CommandHandler 10 + private let focusHandler: any CommandHandler 11 + private let sendHandler: any CommandHandler 12 + private let keyHandler: any CommandHandler 13 + private let readHandler: any CommandHandler 14 + 15 + init( 16 + openHandler: any CommandHandler = StubCommandHandler(command: "open"), 17 + listHandler: any CommandHandler = StubCommandHandler(command: "list"), 18 + focusHandler: any CommandHandler = StubCommandHandler(command: "focus"), 19 + sendHandler: any CommandHandler = StubCommandHandler(command: "send"), 20 + keyHandler: any CommandHandler = StubCommandHandler(command: "key"), 21 + readHandler: any CommandHandler = StubCommandHandler(command: "read") 22 + ) { 23 + self.openHandler = openHandler 24 + self.listHandler = listHandler 25 + self.focusHandler = focusHandler 26 + self.sendHandler = sendHandler 27 + self.keyHandler = keyHandler 28 + self.readHandler = readHandler 29 + } 30 + 31 + func route(_ envelope: CommandEnvelope) async -> CommandResponse { 32 + let handler: any CommandHandler 33 + switch envelope.command { 34 + case .open: handler = openHandler 35 + case .list: handler = listHandler 36 + case .focus: handler = focusHandler 37 + case .send: handler = sendHandler 38 + case .key: handler = keyHandler 39 + case .read: handler = readHandler 40 + } 41 + return await handler.handle(envelope: envelope) 42 + } 43 + } 44 + 45 + // MARK: - Stub handler (placeholder until real handlers are implemented) 46 + 47 + struct StubCommandHandler: CommandHandler { 48 + let command: String 49 + 50 + // swiftlint:disable:next async_without_await 51 + func handle(envelope: CommandEnvelope) async -> CommandResponse { 52 + CommandResponse( 53 + ok: false, 54 + command: command, 55 + schemaVersion: "prowl.cli.\(command).v1", 56 + error: CommandError( 57 + code: "NOT_IMPLEMENTED", 58 + message: "Command '\(command)' is not yet implemented." 59 + ) 60 + ) 61 + } 62 + }
+179
supacode/CLIService/CLISocketServer.swift
··· 1 + // supacode/CLIService/CLISocketServer.swift 2 + // Unix domain socket server that listens for CLI command requests. 3 + 4 + #if canImport(Darwin) 5 + import Darwin 6 + #elseif canImport(Glibc) 7 + import Glibc 8 + #endif 9 + import Foundation 10 + 11 + @MainActor 12 + final class CLISocketServer { 13 + private let router: CLICommandRouter 14 + private let socketPath: String 15 + private var serverFD: Int32 = -1 16 + private var isRunning = false 17 + private let acceptQueue = DispatchQueue(label: "com.onevcat.prowl.cli-accept", qos: .userInitiated) 18 + 19 + init(router: CLICommandRouter, socketPath: String = ProwlSocket.defaultPath) { 20 + self.router = router 21 + self.socketPath = socketPath 22 + } 23 + 24 + /// Start listening for CLI connections. 25 + func start() throws { 26 + // Clean up stale socket file 27 + unlink(socketPath) 28 + 29 + // Create socket 30 + serverFD = socket(AF_UNIX, SOCK_STREAM, 0) 31 + guard serverFD >= 0 else { 32 + throw CLIServiceError.socketCreationFailed 33 + } 34 + 35 + // Bind 36 + var addr = sockaddr_un() 37 + addr.sun_family = sa_family_t(AF_UNIX) 38 + let pathBytes = Array(socketPath.utf8) 39 + let maxLen = MemoryLayout.size(ofValue: addr.sun_path) - 1 40 + let copyLen = min(pathBytes.count, maxLen) 41 + withUnsafeMutableBytes(of: &addr.sun_path) { sunPathPtr in 42 + for idx in 0..<copyLen { 43 + sunPathPtr[idx] = pathBytes[idx] 44 + } 45 + sunPathPtr[copyLen] = 0 46 + } 47 + 48 + let bindResult = withUnsafePointer(to: &addr) { ptr in 49 + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in 50 + bind(serverFD, sockPtr, socklen_t(MemoryLayout<sockaddr_un>.size)) 51 + } 52 + } 53 + 54 + guard bindResult == 0 else { 55 + close(serverFD) 56 + throw CLIServiceError.bindFailed 57 + } 58 + 59 + // Listen 60 + guard listen(serverFD, 5) == 0 else { 61 + close(serverFD) 62 + throw CLIServiceError.listenFailed 63 + } 64 + 65 + isRunning = true 66 + 67 + // Run the blocking accept loop on a dedicated dispatch queue so it does 68 + // not occupy a Swift cooperative-thread-pool thread (which would starve 69 + // the concurrency runtime and hang the app – especially during testing). 70 + let listeningFD = serverFD 71 + acceptQueue.async { [weak self] in 72 + Self.acceptLoop(serverFD: listeningFD, server: self) 73 + } 74 + } 75 + 76 + /// Stop the server and clean up. 77 + func stop() { 78 + isRunning = false 79 + if serverFD >= 0 { 80 + close(serverFD) 81 + serverFD = -1 82 + } 83 + unlink(socketPath) 84 + } 85 + 86 + // MARK: - Accept loop (runs on acceptQueue, NOT in Swift concurrency) 87 + 88 + private nonisolated static func acceptLoop(serverFD: Int32, server: CLISocketServer?) { 89 + while true { 90 + let clientFD = Darwin.accept(serverFD, nil, nil) 91 + guard clientFD >= 0 else { 92 + // serverFD was closed (stop() called) or an error occurred – exit. 93 + return 94 + } 95 + if let server { 96 + Task { @MainActor in 97 + await server.handleClient(clientFD: clientFD) 98 + } 99 + } else { 100 + Darwin.close(clientFD) 101 + } 102 + } 103 + } 104 + 105 + private func handleClient(clientFD: Int32) async { 106 + defer { Darwin.close(clientFD) } 107 + 108 + do { 109 + // Read length-prefixed request 110 + let lengthData = try Self.fdRead(fildes: clientFD, count: 4) 111 + let length = lengthData.withUnsafeBytes { 112 + UInt32(bigEndian: $0.load(as: UInt32.self)) 113 + } 114 + guard length > 0, length < 10_000_000 else { return } 115 + 116 + let requestData = try Self.fdRead(fildes: clientFD, count: Int(length)) 117 + 118 + // Decode envelope 119 + let decoder = JSONDecoder() 120 + let envelope = try decoder.decode(CommandEnvelope.self, from: requestData) 121 + 122 + // Route to handler 123 + let response = await router.route(envelope) 124 + 125 + // Encode and send response 126 + let encoder = JSONEncoder() 127 + encoder.outputFormatting = [.sortedKeys] 128 + let responseData = try encoder.encode(response) 129 + 130 + var responseLength = UInt32(responseData.count).bigEndian 131 + try withUnsafeBytes(of: &responseLength) { try Self.fdWrite(fildes: clientFD, buffer: $0) } 132 + try responseData.withUnsafeBytes { try Self.fdWrite(fildes: clientFD, buffer: $0) } 133 + } catch { 134 + // Connection-level errors are silently dropped 135 + } 136 + } 137 + 138 + // MARK: - Low-level I/O using Darwin read/write 139 + 140 + private static func fdRead(fildes: Int32, count: Int) throws -> Data { 141 + var data = Data(capacity: count) 142 + var remaining = count 143 + let bufferSize = min(count, 65536) 144 + let buffer = UnsafeMutableRawPointer.allocate(byteCount: bufferSize, alignment: 1) 145 + defer { buffer.deallocate() } 146 + while remaining > 0 { 147 + let toRead = min(remaining, bufferSize) 148 + let bytesRead = Darwin.read(fildes, buffer, toRead) 149 + guard bytesRead > 0 else { 150 + throw CLIServiceError.readFailed 151 + } 152 + data.append(buffer.assumingMemoryBound(to: UInt8.self), count: bytesRead) 153 + remaining -= bytesRead 154 + } 155 + return data 156 + } 157 + 158 + private static func fdWrite(fildes: Int32, buffer: UnsafeRawBufferPointer) throws { 159 + var offset = 0 160 + while offset < buffer.count { 161 + let written = Darwin.write(fildes, buffer.baseAddress!.advanced(by: offset), buffer.count - offset) 162 + guard written > 0 else { 163 + throw CLIServiceError.writeFailed 164 + } 165 + offset += written 166 + } 167 + } 168 + } 169 + 170 + // MARK: - Errors 171 + 172 + enum CLIServiceError: Error { 173 + case socketCreationFailed 174 + case socketPathTooLong 175 + case bindFailed 176 + case listenFailed 177 + case readFailed 178 + case writeFailed 179 + }
+11
supacode/CLIService/CommandHandlerProtocol.swift
··· 1 + // supacode/CLIService/CommandHandlerProtocol.swift 2 + // Protocol for command handlers on the app side. 3 + 4 + import Foundation 5 + 6 + /// Each CLI command has a corresponding handler that executes 7 + /// within the app's process context. 8 + protocol CommandHandler { 9 + /// Execute the command and return a structured response. 10 + func handle(envelope: CommandEnvelope) async -> CommandResponse 11 + }
+34
supacode/CLIService/Shared/CommandEnvelope.swift
··· 1 + // ProwlShared/CommandEnvelope.swift 2 + // The handoff contract between CLI parser and app command service. 3 + 4 + import Foundation 5 + 6 + public struct CommandEnvelope: Codable, Sendable { 7 + public let output: OutputMode 8 + public let command: Command 9 + 10 + public init(output: OutputMode, command: Command) { 11 + self.output = output 12 + self.command = command 13 + } 14 + } 15 + 16 + public enum Command: Codable, Sendable { 17 + case open(OpenInput) 18 + case list(ListInput) 19 + case focus(FocusInput) 20 + case send(SendInput) 21 + case key(KeyInput) 22 + case read(ReadInput) 23 + 24 + public var name: String { 25 + switch self { 26 + case .open: "open" 27 + case .list: "list" 28 + case .focus: "focus" 29 + case .send: "send" 30 + case .key: "key" 31 + case .read: "read" 32 + } 33 + } 34 + }
+149
supacode/CLIService/Shared/CommandResponse.swift
··· 1 + // ProwlShared/CommandResponse.swift 2 + // Structured response from app command service back to CLI. 3 + 4 + import Foundation 5 + 6 + /// Top-level response wrapper. 7 + /// For v1, `data` is left as raw JSON bytes so each command can define 8 + /// its own strongly-typed success payload without introducing `Any`. 9 + public struct CommandResponse: Codable, Sendable { 10 + // swiftlint:disable:next identifier_name 11 + public let ok: Bool 12 + public let command: String 13 + public let schemaVersion: String 14 + 15 + /// Raw JSON data payload (success case). Consumers decode into 16 + /// command-specific types. Nil when `ok == false`. 17 + public let data: RawJSON? 18 + 19 + /// Error payload (failure case). Nil when `ok == true`. 20 + public let error: CommandError? 21 + 22 + public init( 23 + // swiftlint:disable:next identifier_name 24 + ok: Bool, 25 + command: String, 26 + schemaVersion: String, 27 + data: RawJSON? = nil, 28 + error: CommandError? = nil 29 + ) { 30 + self.ok = ok 31 + self.command = command 32 + self.schemaVersion = schemaVersion 33 + self.data = data 34 + self.error = error 35 + } 36 + 37 + enum CodingKeys: String, CodingKey { 38 + // swiftlint:disable:next identifier_name 39 + case ok 40 + case command 41 + case schemaVersion = "schema_version" 42 + case data 43 + case error 44 + } 45 + } 46 + 47 + public struct CommandError: Codable, Sendable { 48 + public let code: String 49 + public let message: String 50 + 51 + public init(code: String, message: String) { 52 + self.code = code 53 + self.message = message 54 + } 55 + } 56 + 57 + // MARK: - RawJSON 58 + 59 + /// A type-safe wrapper around raw JSON bytes. 60 + /// Preserves the original JSON without round-tripping through `Any`. 61 + /// Fully `Sendable` because it only holds `Data`. 62 + public struct RawJSON: Codable, Sendable { 63 + public let bytes: Data 64 + 65 + public init(_ bytes: Data) { 66 + self.bytes = bytes 67 + } 68 + 69 + /// Create from an Encodable value. 70 + public init<T: Encodable>(encoding value: T) throws { 71 + let encoder = JSONEncoder() 72 + encoder.outputFormatting = [.sortedKeys] 73 + self.bytes = try encoder.encode(value) 74 + } 75 + 76 + public init(from decoder: Decoder) throws { 77 + // When decoding as part of a larger structure, capture the raw JSON. 78 + // This works by re-encoding the decoded JSON value container. 79 + let container = try decoder.singleValueContainer() 80 + // Decode as a generic JSON value, then re-encode to bytes. 81 + let jsonValue = try container.decode(JSONValue.self) 82 + let encoder = JSONEncoder() 83 + encoder.outputFormatting = [.sortedKeys] 84 + self.bytes = try encoder.encode(jsonValue) 85 + } 86 + 87 + public func encode(to encoder: Encoder) throws { 88 + // Decode bytes back to JSONValue, then encode inline. 89 + let decoder = JSONDecoder() 90 + let jsonValue = try decoder.decode(JSONValue.self, from: bytes) 91 + var container = encoder.singleValueContainer() 92 + try container.encode(jsonValue) 93 + } 94 + 95 + /// Decode the raw JSON into a specific type. 96 + public func decode<T: Decodable>(as type: T.Type) throws -> T { 97 + try JSONDecoder().decode(type, from: bytes) 98 + } 99 + } 100 + 101 + // MARK: - JSONValue (internal helper for RawJSON round-tripping) 102 + 103 + /// A simple recursive JSON value type that is fully Codable and Sendable. 104 + enum JSONValue: Codable, Sendable { 105 + case null 106 + case bool(Bool) 107 + case int(Int) 108 + case double(Double) 109 + case string(String) 110 + case array([JSONValue]) 111 + case object([String: JSONValue]) 112 + 113 + init(from decoder: Decoder) throws { 114 + let container = try decoder.singleValueContainer() 115 + if container.decodeNil() { 116 + self = .null 117 + } else if let boolValue = try? container.decode(Bool.self) { 118 + self = .bool(boolValue) 119 + } else if let intValue = try? container.decode(Int.self) { 120 + self = .int(intValue) 121 + } else if let doubleValue = try? container.decode(Double.self) { 122 + self = .double(doubleValue) 123 + } else if let stringValue = try? container.decode(String.self) { 124 + self = .string(stringValue) 125 + } else if let arrayValue = try? container.decode([JSONValue].self) { 126 + self = .array(arrayValue) 127 + } else if let objectValue = try? container.decode([String: JSONValue].self) { 128 + self = .object(objectValue) 129 + } else { 130 + throw DecodingError.dataCorruptedError( 131 + in: container, 132 + debugDescription: "Unsupported JSON value" 133 + ) 134 + } 135 + } 136 + 137 + func encode(to encoder: Encoder) throws { 138 + var container = encoder.singleValueContainer() 139 + switch self { 140 + case .null: try container.encodeNil() 141 + case .bool(let boolValue): try container.encode(boolValue) 142 + case .int(let intValue): try container.encode(intValue) 143 + case .double(let doubleValue): try container.encode(doubleValue) 144 + case .string(let stringValue): try container.encode(stringValue) 145 + case .array(let arrayValue): try container.encode(arrayValue) 146 + case .object(let objectValue): try container.encode(objectValue) 147 + } 148 + } 149 + }
+42
supacode/CLIService/Shared/ErrorCodes.swift
··· 1 + // ProwlShared/ErrorCodes.swift 2 + // Stable error codes matching schema.md contracts. 3 + 4 + import Foundation 5 + 6 + public enum CLIErrorCode { 7 + // Common 8 + public static let appNotRunning = "APP_NOT_RUNNING" 9 + public static let invalidArgument = "INVALID_ARGUMENT" 10 + public static let targetNotFound = "TARGET_NOT_FOUND" 11 + public static let targetNotUnique = "TARGET_NOT_UNIQUE" 12 + 13 + // Open 14 + public static let pathNotFound = "PATH_NOT_FOUND" 15 + public static let pathNotDirectory = "PATH_NOT_DIRECTORY" 16 + public static let pathNotAllowed = "PATH_NOT_ALLOWED" 17 + public static let launchFailed = "LAUNCH_FAILED" 18 + public static let openFailed = "OPEN_FAILED" 19 + 20 + // List 21 + public static let listFailed = "LIST_FAILED" 22 + 23 + // Focus 24 + public static let focusFailed = "FOCUS_FAILED" 25 + 26 + // Send 27 + public static let emptyInput = "EMPTY_INPUT" 28 + public static let sendFailed = "SEND_FAILED" 29 + 30 + // Key 31 + public static let invalidRepeat = "INVALID_REPEAT" 32 + public static let noActivePane = "NO_ACTIVE_PANE" 33 + public static let unsupportedKey = "UNSUPPORTED_KEY" 34 + public static let keyDeliveryFailed = "KEY_DELIVERY_FAILED" 35 + 36 + // Read 37 + public static let readFailed = "READ_FAILED" 38 + 39 + // Transport 40 + public static let transportFailed = "TRANSPORT_FAILED" 41 + public static let timeout = "TIMEOUT" 42 + }
+67
supacode/CLIService/Shared/InputModels.swift
··· 1 + // ProwlShared/InputModels.swift 2 + // Typed input models matching input.md contract 3 + 4 + import Foundation 5 + 6 + public struct OpenInput: Codable, Sendable { 7 + /// Normalized absolute path, or nil for bare `prowl` (bring to front). 8 + public let path: String? 9 + 10 + public init(path: String? = nil) { 11 + self.path = path 12 + } 13 + } 14 + 15 + public struct ListInput: Codable, Sendable { 16 + public init() {} 17 + } 18 + 19 + public struct FocusInput: Codable, Sendable { 20 + public let selector: TargetSelector 21 + 22 + public init(selector: TargetSelector = .none) { 23 + self.selector = selector 24 + } 25 + } 26 + 27 + public struct SendInput: Codable, Sendable { 28 + public let selector: TargetSelector 29 + public let text: String 30 + public let trailingEnter: Bool 31 + 32 + public init( 33 + selector: TargetSelector = .none, 34 + text: String, 35 + trailingEnter: Bool = true 36 + ) { 37 + self.selector = selector 38 + self.text = text 39 + self.trailingEnter = trailingEnter 40 + } 41 + } 42 + 43 + public struct KeyInput: Codable, Sendable { 44 + public let selector: TargetSelector 45 + public let token: String 46 + public let repeatCount: Int 47 + 48 + public init( 49 + selector: TargetSelector = .none, 50 + token: String, 51 + repeatCount: Int = 1 52 + ) { 53 + self.selector = selector 54 + self.token = token 55 + self.repeatCount = repeatCount 56 + } 57 + } 58 + 59 + public struct ReadInput: Codable, Sendable { 60 + public let selector: TargetSelector 61 + public let last: Int? 62 + 63 + public init(selector: TargetSelector = .none, last: Int? = nil) { 64 + self.selector = selector 65 + self.last = last 66 + } 67 + }
+9
supacode/CLIService/Shared/OutputMode.swift
··· 1 + // ProwlShared/OutputMode.swift 2 + // Shared between CLI and App targets 3 + 4 + import Foundation 5 + 6 + public enum OutputMode: String, Codable, Sendable { 7 + case text 8 + case json 9 + }
+21
supacode/CLIService/Shared/SocketConstants.swift
··· 1 + // ProwlShared/SocketConstants.swift 2 + // Shared socket path convention between CLI client and app server. 3 + 4 + import Foundation 5 + 6 + public enum ProwlSocket { 7 + /// Environment variable for overriding socket path. 8 + public static let environmentKey = "PROWL_CLI_SOCKET" 9 + 10 + /// Default Unix domain socket path. 11 + /// Located in user's temporary directory to avoid permission issues. 12 + /// 13 + /// If `PROWL_CLI_SOCKET` is set and not empty, it takes precedence. 14 + public static var defaultPath: String { 15 + if let override = ProcessInfo.processInfo.environment[environmentKey], !override.isEmpty { 16 + return override 17 + } 18 + let tmpDir = NSTemporaryDirectory() 19 + return (tmpDir as NSString).appendingPathComponent("prowl-cli.sock") 20 + } 21 + }
+13
supacode/CLIService/Shared/TargetSelector.swift
··· 1 + // ProwlShared/TargetSelector.swift 2 + // Shared between CLI and App targets 3 + 4 + import Foundation 5 + 6 + /// Exactly zero or one selector is allowed per command. 7 + /// Multiple selectors → INVALID_ARGUMENT. 8 + public enum TargetSelector: Codable, Sendable, Equatable { 9 + case none 10 + case worktree(String) 11 + case tab(String) 12 + case pane(String) 13 + }
+29
supacode/CLIService/TargetResolver.swift
··· 1 + // supacode/CLIService/TargetResolver.swift 2 + // Resolves target selectors against current app state. 3 + // Scaffold — actual resolution depends on wiring to WorktreeTerminalManager. 4 + 5 + import Foundation 6 + 7 + @MainActor 8 + final class TargetResolver { 9 + /// Resolve a target selector to concrete worktree/tab/pane IDs. 10 + /// Returns nil if the target cannot be found. 11 + func resolve(_ selector: TargetSelector) -> ResolvedTarget? { 12 + // Scaffold: resolution not yet wired to WorktreeTerminalManager. 13 + // Will be implemented when command handlers are built out. 14 + switch selector { 15 + case .none: 16 + return nil 17 + case .worktree, .tab, .pane: 18 + return nil 19 + } 20 + } 21 + } 22 + 23 + /// Placeholder for resolved target information. 24 + /// Will be populated with actual worktree/tab/pane data when wired. 25 + struct ResolvedTarget { 26 + let worktreeID: String 27 + let tabID: String 28 + let paneID: String 29 + }
+163
supacodeTests/CLICommandEnvelopeTests.swift
··· 1 + // supacodeTests/CLICommandEnvelopeTests.swift 2 + // Contract tests for CommandEnvelope, CommandResponse, and shared types. 3 + 4 + import Foundation 5 + import Testing 6 + 7 + @testable import supacode 8 + 9 + struct CLICommandEnvelopeTests { 10 + 11 + // MARK: - CommandEnvelope round-trip 12 + 13 + @Test func envelopeOpenRoundTrips() throws { 14 + let envelope = CommandEnvelope( 15 + output: .json, 16 + command: .open(OpenInput(path: "/Users/test/project")) 17 + ) 18 + let data = try JSONEncoder().encode(envelope) 19 + let decoded = try JSONDecoder().decode(CommandEnvelope.self, from: data) 20 + 21 + #expect(decoded.output == .json) 22 + if case .open(let input) = decoded.command { 23 + #expect(input.path == "/Users/test/project") 24 + } else { 25 + Issue.record("Expected .open command") 26 + } 27 + } 28 + 29 + @Test func envelopeOpenNilPathRoundTrips() throws { 30 + let envelope = CommandEnvelope( 31 + output: .text, 32 + command: .open(OpenInput(path: nil)) 33 + ) 34 + let data = try JSONEncoder().encode(envelope) 35 + let decoded = try JSONDecoder().decode(CommandEnvelope.self, from: data) 36 + 37 + if case .open(let input) = decoded.command { 38 + #expect(input.path == nil) 39 + } else { 40 + Issue.record("Expected .open command") 41 + } 42 + } 43 + 44 + @Test func envelopeListRoundTrips() throws { 45 + let envelope = CommandEnvelope( 46 + output: .text, 47 + command: .list(ListInput()) 48 + ) 49 + let data = try JSONEncoder().encode(envelope) 50 + let decoded = try JSONDecoder().decode(CommandEnvelope.self, from: data) 51 + #expect(decoded.output == .text) 52 + if case .list = decoded.command { 53 + // expected 54 + } else { 55 + Issue.record("Expected .list command") 56 + } 57 + } 58 + 59 + @Test func envelopeSendWithSelectorRoundTrips() throws { 60 + let envelope = CommandEnvelope( 61 + output: .json, 62 + command: .send(SendInput( 63 + selector: .pane("abc-123"), 64 + text: "hello world", 65 + trailingEnter: false 66 + )) 67 + ) 68 + let data = try JSONEncoder().encode(envelope) 69 + let decoded = try JSONDecoder().decode(CommandEnvelope.self, from: data) 70 + if case .send(let input) = decoded.command { 71 + #expect(input.text == "hello world") 72 + #expect(input.trailingEnter == false) 73 + #expect(input.selector == .pane("abc-123")) 74 + } else { 75 + Issue.record("Expected .send command") 76 + } 77 + } 78 + 79 + @Test func envelopeKeyWithRepeatRoundTrips() throws { 80 + let envelope = CommandEnvelope( 81 + output: .text, 82 + command: .key(KeyInput( 83 + selector: .tab("tab-1"), 84 + token: "enter", 85 + repeatCount: 5 86 + )) 87 + ) 88 + let data = try JSONEncoder().encode(envelope) 89 + let decoded = try JSONDecoder().decode(CommandEnvelope.self, from: data) 90 + if case .key(let input) = decoded.command { 91 + #expect(input.token == "enter") 92 + #expect(input.repeatCount == 5) 93 + #expect(input.selector == .tab("tab-1")) 94 + } else { 95 + Issue.record("Expected .key command") 96 + } 97 + } 98 + 99 + @Test func envelopeReadWithLastRoundTrips() throws { 100 + let envelope = CommandEnvelope( 101 + output: .json, 102 + command: .read(ReadInput(selector: .worktree("wt-main"), last: 50)) 103 + ) 104 + let data = try JSONEncoder().encode(envelope) 105 + let decoded = try JSONDecoder().decode(CommandEnvelope.self, from: data) 106 + if case .read(let input) = decoded.command { 107 + #expect(input.last == 50) 108 + #expect(input.selector == .worktree("wt-main")) 109 + } else { 110 + Issue.record("Expected .read command") 111 + } 112 + } 113 + 114 + @Test func envelopeFocusNoSelectorRoundTrips() throws { 115 + let envelope = CommandEnvelope( 116 + output: .text, 117 + command: .focus(FocusInput(selector: .none)) 118 + ) 119 + let data = try JSONEncoder().encode(envelope) 120 + let decoded = try JSONDecoder().decode(CommandEnvelope.self, from: data) 121 + if case .focus(let input) = decoded.command { 122 + #expect(input.selector == .none) 123 + } else { 124 + Issue.record("Expected .focus command") 125 + } 126 + } 127 + 128 + // MARK: - Command name 129 + 130 + @Test func commandNameReturnsCorrectStrings() { 131 + let commands: [(Command, String)] = [ 132 + (.open(OpenInput(path: nil)), "open"), 133 + (.list(ListInput()), "list"), 134 + (.focus(FocusInput()), "focus"), 135 + (.send(SendInput(text: "x")), "send"), 136 + (.key(KeyInput(token: "tab")), "key"), 137 + (.read(ReadInput()), "read"), 138 + ] 139 + for (command, expected) in commands { 140 + #expect(command.name == expected) 141 + } 142 + } 143 + 144 + // MARK: - Encoding produces valid JSON 145 + 146 + @Test func allCommandsEncodeToValidJSON() throws { 147 + let commands: [Command] = [ 148 + .open(OpenInput(path: "/tmp")), 149 + .list(ListInput()), 150 + .focus(FocusInput()), 151 + .send(SendInput(text: "test")), 152 + .key(KeyInput(token: "enter")), 153 + .read(ReadInput()), 154 + ] 155 + for cmd in commands { 156 + let envelope = CommandEnvelope(output: .json, command: cmd) 157 + let data = try JSONEncoder().encode(envelope) 158 + let json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) 159 + #expect(json["output"] as? String == "json") 160 + #expect(json["command"] != nil) 161 + } 162 + } 163 + }
+120
supacodeTests/CLICommandResponseTests.swift
··· 1 + // supacodeTests/CLICommandResponseTests.swift 2 + // Contract tests for CommandResponse and RawJSON encoding/decoding. 3 + 4 + import Foundation 5 + import Testing 6 + 7 + @testable import supacode 8 + 9 + struct CLICommandResponseTests { 10 + 11 + // MARK: - CommandResponse JSON key stability 12 + 13 + @Test func successResponseHasStableKeys() throws { 14 + let payload = ["items": [1, 2, 3]] 15 + let rawData = try JSONSerialization.data(withJSONObject: payload) 16 + let response = CommandResponse( 17 + ok: true, 18 + command: "list", 19 + schemaVersion: "prowl.cli.list.v1", 20 + data: RawJSON(rawData) 21 + ) 22 + let encoded = try JSONEncoder().encode(response) 23 + let json = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) 24 + 25 + #expect(json["ok"] as? Bool == true) 26 + #expect(json["command"] as? String == "list") 27 + #expect(json["schema_version"] as? String == "prowl.cli.list.v1") 28 + #expect(json["data"] != nil) 29 + #expect(json["error"] == nil) 30 + } 31 + 32 + @Test func errorResponseHasStableKeys() throws { 33 + let response = CommandResponse( 34 + ok: false, 35 + command: "open", 36 + schemaVersion: "prowl.cli.open.v1", 37 + error: CommandError(code: "PATH_NOT_FOUND", message: "Path not found: ~/nope") 38 + ) 39 + let encoded = try JSONEncoder().encode(response) 40 + let json = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) 41 + 42 + #expect(json["ok"] as? Bool == false) 43 + #expect(json["command"] as? String == "open") 44 + #expect(json["schema_version"] as? String == "prowl.cli.open.v1") 45 + #expect(json["data"] == nil) 46 + let error = try #require(json["error"] as? [String: Any]) 47 + #expect(error["code"] as? String == "PATH_NOT_FOUND") 48 + #expect(error["message"] as? String == "Path not found: ~/nope") 49 + } 50 + 51 + @Test func responseRoundTrips() throws { 52 + let original = CommandResponse( 53 + ok: false, 54 + command: "send", 55 + schemaVersion: "prowl.cli.send.v1", 56 + error: CommandError(code: "EMPTY_INPUT", message: "No input provided.") 57 + ) 58 + let data = try JSONEncoder().encode(original) 59 + let decoded = try JSONDecoder().decode(CommandResponse.self, from: data) 60 + #expect(decoded.ok == false) 61 + #expect(decoded.command == "send") 62 + #expect(decoded.schemaVersion == "prowl.cli.send.v1") 63 + #expect(decoded.error?.code == "EMPTY_INPUT") 64 + } 65 + 66 + // MARK: - RawJSON round-tripping 67 + 68 + @Test func rawJSONFromEncodableRoundTrips() throws { 69 + struct Payload: Codable, Equatable { 70 + let count: Int 71 + let name: String 72 + } 73 + let original = Payload(count: 42, name: "test") 74 + let raw = try RawJSON(encoding: original) 75 + let decoded = try raw.decode(as: Payload.self) 76 + #expect(decoded == original) 77 + } 78 + 79 + @Test func rawJSONPreservesNestedStructure() throws { 80 + let nested: [String: Any] = [ 81 + "items": [ 82 + ["id": "a", "value": 1], 83 + ["id": "b", "value": 2], 84 + ], 85 + ] 86 + let rawData = try JSONSerialization.data(withJSONObject: nested) 87 + let raw = RawJSON(rawData) 88 + 89 + // Embed in response and round-trip 90 + let response = CommandResponse( 91 + ok: true, 92 + command: "list", 93 + schemaVersion: "prowl.cli.list.v1", 94 + data: raw 95 + ) 96 + let encoded = try JSONEncoder().encode(response) 97 + let decoded = try JSONDecoder().decode(CommandResponse.self, from: encoded) 98 + #expect(decoded.data != nil) 99 + 100 + // Verify nested data survived 101 + let decodedPayload = try JSONSerialization.jsonObject(with: decoded.data!.bytes) 102 + let dict = try #require(decodedPayload as? [String: Any]) 103 + let items = try #require(dict["items"] as? [[String: Any]]) 104 + #expect(items.count == 2) 105 + } 106 + 107 + // MARK: - Error codes constants 108 + 109 + @Test func errorCodeConstantsAreDefined() { 110 + #expect(CLIErrorCode.appNotRunning == "APP_NOT_RUNNING") 111 + #expect(CLIErrorCode.invalidArgument == "INVALID_ARGUMENT") 112 + #expect(CLIErrorCode.targetNotFound == "TARGET_NOT_FOUND") 113 + #expect(CLIErrorCode.emptyInput == "EMPTY_INPUT") 114 + #expect(CLIErrorCode.invalidRepeat == "INVALID_REPEAT") 115 + #expect(CLIErrorCode.transportFailed == "TRANSPORT_FAILED") 116 + #expect(CLIErrorCode.timeout == "TIMEOUT") 117 + #expect(CLIErrorCode.pathNotFound == "PATH_NOT_FOUND") 118 + #expect(CLIErrorCode.pathNotDirectory == "PATH_NOT_DIRECTORY") 119 + } 120 + }
+129
supacodeTests/CLICommandRouterTests.swift
··· 1 + // supacodeTests/CLICommandRouterTests.swift 2 + // Unit tests for CLICommandRouter and StubCommandHandler. 3 + 4 + import Foundation 5 + import Testing 6 + 7 + @testable import supacode 8 + 9 + struct CLICommandRouterTests { 10 + 11 + // MARK: - Stub handler returns NOT_IMPLEMENTED 12 + 13 + @MainActor 14 + @Test func stubHandlerReturnsNotImplemented() async { 15 + let router = CLICommandRouter() 16 + let envelope = CommandEnvelope(output: .json, command: .list(ListInput())) 17 + let response = await router.route(envelope) 18 + #expect(response.ok == false) 19 + #expect(response.error?.code == "NOT_IMPLEMENTED") 20 + #expect(response.command == "list") 21 + } 22 + 23 + @MainActor 24 + @Test func routerDispatchesOpenToOpenHandler() async { 25 + let router = CLICommandRouter() 26 + let envelope = CommandEnvelope( 27 + output: .text, 28 + command: .open(OpenInput(path: "/tmp/test")) 29 + ) 30 + let response = await router.route(envelope) 31 + #expect(response.command == "open") 32 + #expect(response.error?.code == "NOT_IMPLEMENTED") 33 + } 34 + 35 + @MainActor 36 + @Test func routerDispatchesSendToSendHandler() async { 37 + let router = CLICommandRouter() 38 + let envelope = CommandEnvelope( 39 + output: .json, 40 + command: .send(SendInput(text: "hello")) 41 + ) 42 + let response = await router.route(envelope) 43 + #expect(response.command == "send") 44 + #expect(response.error?.code == "NOT_IMPLEMENTED") 45 + } 46 + 47 + @MainActor 48 + @Test func routerDispatchesFocusToFocusHandler() async { 49 + let router = CLICommandRouter() 50 + let envelope = CommandEnvelope( 51 + output: .text, 52 + command: .focus(FocusInput(selector: .pane("p1"))) 53 + ) 54 + let response = await router.route(envelope) 55 + #expect(response.command == "focus") 56 + } 57 + 58 + @MainActor 59 + @Test func routerDispatchesKeyToKeyHandler() async { 60 + let router = CLICommandRouter() 61 + let envelope = CommandEnvelope( 62 + output: .json, 63 + command: .key(KeyInput(token: "enter", repeatCount: 3)) 64 + ) 65 + let response = await router.route(envelope) 66 + #expect(response.command == "key") 67 + } 68 + 69 + @MainActor 70 + @Test func routerDispatchesReadToReadHandler() async { 71 + let router = CLICommandRouter() 72 + let envelope = CommandEnvelope( 73 + output: .text, 74 + command: .read(ReadInput(last: 10)) 75 + ) 76 + let response = await router.route(envelope) 77 + #expect(response.command == "read") 78 + } 79 + 80 + // MARK: - Custom handler injection 81 + 82 + @MainActor 83 + @Test func routerUsesInjectedHandler() async { 84 + let customHandler = MockCommandHandler( 85 + response: CommandResponse( 86 + ok: true, 87 + command: "list", 88 + schemaVersion: "prowl.cli.list.v1" 89 + ) 90 + ) 91 + let router = CLICommandRouter(listHandler: customHandler) 92 + let envelope = CommandEnvelope(output: .json, command: .list(ListInput())) 93 + let response = await router.route(envelope) 94 + #expect(response.ok == true) 95 + #expect(response.command == "list") 96 + } 97 + 98 + // MARK: - Schema version format 99 + 100 + @MainActor 101 + @Test func stubSchemaVersionFollowsConvention() async { 102 + let commands: [Command] = [ 103 + .open(OpenInput()), 104 + .list(ListInput()), 105 + .focus(FocusInput()), 106 + .send(SendInput(text: "x")), 107 + .key(KeyInput(token: "tab")), 108 + .read(ReadInput()), 109 + ] 110 + let router = CLICommandRouter() 111 + for cmd in commands { 112 + let envelope = CommandEnvelope(output: .json, command: cmd) 113 + let response = await router.route(envelope) 114 + #expect(response.schemaVersion.hasPrefix("prowl.cli.")) 115 + #expect(response.schemaVersion.hasSuffix(".v1")) 116 + } 117 + } 118 + } 119 + 120 + // MARK: - Test helpers 121 + 122 + private struct MockCommandHandler: CommandHandler { 123 + let response: CommandResponse 124 + 125 + // swiftlint:disable:next async_without_await 126 + func handle(envelope: CommandEnvelope) async -> CommandResponse { 127 + response 128 + } 129 + }
+53
supacodeTests/CLITargetSelectorTests.swift
··· 1 + // supacodeTests/CLITargetSelectorTests.swift 2 + // Tests for TargetSelector mutual exclusivity and encoding. 3 + 4 + import Foundation 5 + import Testing 6 + 7 + @testable import supacode 8 + 9 + struct CLITargetSelectorTests { 10 + 11 + // MARK: - Encoding stability 12 + 13 + @Test func selectorNoneRoundTrips() throws { 14 + let selector = TargetSelector.none 15 + let data = try JSONEncoder().encode(selector) 16 + let decoded = try JSONDecoder().decode(TargetSelector.self, from: data) 17 + #expect(decoded == .none) 18 + } 19 + 20 + @Test func selectorWorktreeRoundTrips() throws { 21 + let selector = TargetSelector.worktree("my-project") 22 + let data = try JSONEncoder().encode(selector) 23 + let decoded = try JSONDecoder().decode(TargetSelector.self, from: data) 24 + #expect(decoded == .worktree("my-project")) 25 + } 26 + 27 + @Test func selectorTabRoundTrips() throws { 28 + let selector = TargetSelector.tab("tab-uuid-123") 29 + let data = try JSONEncoder().encode(selector) 30 + let decoded = try JSONDecoder().decode(TargetSelector.self, from: data) 31 + #expect(decoded == .tab("tab-uuid-123")) 32 + } 33 + 34 + @Test func selectorPaneRoundTrips() throws { 35 + let selector = TargetSelector.pane("pane-0") 36 + let data = try JSONEncoder().encode(selector) 37 + let decoded = try JSONDecoder().decode(TargetSelector.self, from: data) 38 + #expect(decoded == .pane("pane-0")) 39 + } 40 + 41 + // MARK: - Equality 42 + 43 + @Test func differentSelectorsAreNotEqual() { 44 + #expect(TargetSelector.worktree("a") != TargetSelector.tab("a")) 45 + #expect(TargetSelector.tab("a") != TargetSelector.pane("a")) 46 + #expect(TargetSelector.none != TargetSelector.worktree("")) 47 + } 48 + 49 + @Test func sameSelectorsAreEqual() { 50 + #expect(TargetSelector.worktree("x") == TargetSelector.worktree("x")) 51 + #expect(TargetSelector.none == TargetSelector.none) 52 + } 53 + }
+121
supacodeTests/CLITransportProtocolTests.swift
··· 1 + // supacodeTests/CLITransportProtocolTests.swift 2 + // Tests for the length-prefixed JSON transport encoding/decoding. 3 + 4 + import Foundation 5 + import Testing 6 + 7 + @testable import supacode 8 + 9 + struct CLITransportProtocolTests { 10 + 11 + // MARK: - Length-prefix encoding 12 + 13 + @Test func lengthPrefixEncodesCorrectly() throws { 14 + let envelope = CommandEnvelope( 15 + output: .json, 16 + command: .list(ListInput()) 17 + ) 18 + let encoder = JSONEncoder() 19 + encoder.outputFormatting = [.sortedKeys] 20 + let payload = try encoder.encode(envelope) 21 + 22 + // Build length-prefixed message 23 + var length = UInt32(payload.count).bigEndian 24 + var message = Data() 25 + withUnsafeBytes(of: &length) { message.append(contentsOf: $0) } 26 + message.append(payload) 27 + 28 + // Verify: first 4 bytes are big-endian length 29 + #expect(message.count == 4 + payload.count) 30 + let decodedLength = message.withUnsafeBytes { ptr -> UInt32 in 31 + UInt32(bigEndian: ptr.load(as: UInt32.self)) 32 + } 33 + #expect(decodedLength == UInt32(payload.count)) 34 + 35 + // Verify: remaining bytes decode back to envelope 36 + let payloadSlice = message.suffix(from: 4) 37 + let decoded = try JSONDecoder().decode(CommandEnvelope.self, from: payloadSlice) 38 + #expect(decoded.output == .json) 39 + if case .list = decoded.command { 40 + // expected 41 + } else { 42 + Issue.record("Expected .list command") 43 + } 44 + } 45 + 46 + @Test func responseLengthPrefixRoundTrips() throws { 47 + let response = CommandResponse( 48 + ok: true, 49 + command: "list", 50 + schemaVersion: "prowl.cli.list.v1" 51 + ) 52 + let encoder = JSONEncoder() 53 + encoder.outputFormatting = [.sortedKeys] 54 + let payload = try encoder.encode(response) 55 + 56 + var length = UInt32(payload.count).bigEndian 57 + var message = Data() 58 + withUnsafeBytes(of: &length) { message.append(contentsOf: $0) } 59 + message.append(payload) 60 + 61 + // Parse back 62 + let parsedLength = message.withUnsafeBytes { ptr -> UInt32 in 63 + UInt32(bigEndian: ptr.load(as: UInt32.self)) 64 + } 65 + let parsedPayload = message.suffix(from: 4).prefix(Int(parsedLength)) 66 + let decoded = try JSONDecoder().decode(CommandResponse.self, from: parsedPayload) 67 + #expect(decoded.ok == true) 68 + #expect(decoded.command == "list") 69 + } 70 + 71 + // MARK: - Edge cases 72 + 73 + @Test func emptyDataPayloadLengthIsZero() { 74 + let emptyPayload = Data() 75 + var length = UInt32(emptyPayload.count).bigEndian 76 + var message = Data() 77 + withUnsafeBytes(of: &length) { message.append(contentsOf: $0) } 78 + #expect(message.count == 4) 79 + let decodedLength = message.withUnsafeBytes { ptr -> UInt32 in 80 + UInt32(bigEndian: ptr.load(as: UInt32.self)) 81 + } 82 + #expect(decodedLength == 0) 83 + } 84 + 85 + @Test func maxReasonablePayloadLengthEncodes() { 86 + // 10MB is the max accepted by both client and server 87 + let maxLength: UInt32 = 9_999_999 88 + var encoded = UInt32(maxLength).bigEndian 89 + var data = Data() 90 + withUnsafeBytes(of: &encoded) { data.append(contentsOf: $0) } 91 + let decoded = data.withUnsafeBytes { ptr -> UInt32 in 92 + UInt32(bigEndian: ptr.load(as: UInt32.self)) 93 + } 94 + #expect(decoded == maxLength) 95 + } 96 + 97 + // MARK: - All commands encode without error 98 + 99 + @Test func allCommandTypesEncodeSuccessfully() throws { 100 + let commands: [Command] = [ 101 + .open(OpenInput(path: "/tmp")), 102 + .open(OpenInput(path: nil)), 103 + .list(ListInput()), 104 + .focus(FocusInput(selector: .worktree("wt"))), 105 + .focus(FocusInput(selector: .none)), 106 + .send(SendInput(selector: .tab("t1"), text: "cmd", trailingEnter: true)), 107 + .key(KeyInput(selector: .pane("p1"), token: "ctrl-c", repeatCount: 100)), 108 + .read(ReadInput(selector: .none, last: nil)), 109 + .read(ReadInput(selector: .worktree("w"), last: 1)), 110 + ] 111 + let encoder = JSONEncoder() 112 + for cmd in commands { 113 + let envelope = CommandEnvelope(output: .json, command: cmd) 114 + let data = try encoder.encode(envelope) 115 + #expect(data.count > 0) 116 + // Verify it decodes back 117 + let decoded = try JSONDecoder().decode(CommandEnvelope.self, from: data) 118 + #expect(decoded.command.name == cmd.name) 119 + } 120 + } 121 + }