diff --git a/Apps/CLI/Sources/PeekabooCLI/CLI/Output/JSONOutput.swift b/Apps/CLI/Sources/PeekabooCLI/CLI/Output/JSONOutput.swift index 522f799c..bd196aca 100644 --- a/Apps/CLI/Sources/PeekabooCLI/CLI/Output/JSONOutput.swift +++ b/Apps/CLI/Sources/PeekabooCLI/CLI/Output/JSONOutput.swift @@ -87,6 +87,7 @@ enum ErrorCode: String, Codable { case ELEMENT_NOT_FOUND case SESSION_NOT_FOUND case SNAPSHOT_NOT_FOUND + case SNAPSHOT_STALE case APPLICATION_NOT_FOUND case NO_POINT_SPECIFIED case INVALID_COORDINATES diff --git a/Apps/CLI/Sources/PeekabooCLI/Commands/AI/SeeCommand.swift b/Apps/CLI/Sources/PeekabooCLI/Commands/AI/SeeCommand.swift index 39d9b297..b1ace732 100644 --- a/Apps/CLI/Sources/PeekabooCLI/Commands/AI/SeeCommand.swift +++ b/Apps/CLI/Sources/PeekabooCLI/Commands/AI/SeeCommand.swift @@ -147,113 +147,105 @@ struct SeeCommand: ApplicationResolvable, ErrorHandlingCommand, RuntimeOptionsCo "error": error.localizedDescription, ] ) - throw error + self.handleError(error) + throw ExitCode.failure } } private func runImpl(startTime: Date, logger: Logger) async throws { - do { - // ScreenCaptureService performs the authoritative permission check inside each capture path. - // Avoid duplicating that TCC probe here; `see` is often called in latency-sensitive loops. + // ScreenCaptureService performs the authoritative permission check inside each capture path. + // Avoid duplicating that TCC probe here; `see` is often called in latency-sensitive loops. - // Perform capture and element detection - logger.verbose("Starting capture and detection phase", category: "Capture") - let captureResult = try await performCaptureWithDetection() - logger.verbose("Capture completed successfully", category: "Capture", metadata: [ - "snapshotId": captureResult.snapshotId, - "elementCount": captureResult.elements.all.count, - "screenshotSize": self.getFileSize(captureResult.screenshotPath) ?? 0, - ]) + // Perform capture and element detection + logger.verbose("Starting capture and detection phase", category: "Capture") + let captureResult = try await performCaptureWithDetection() + logger.verbose("Capture completed successfully", category: "Capture", metadata: [ + "snapshotId": captureResult.snapshotId, + "elementCount": captureResult.elements.all.count, + "screenshotSize": self.getFileSize(captureResult.screenshotPath) ?? 0, + ]) - // Generate annotated screenshot if requested - var annotatedPath = captureResult.annotatedPath - let annotationsAllowed = self.allowsAnnotationForCurrentCapture() - if self.annotate, !annotationsAllowed { - self.logger.info("Annotation is disabled for full screen captures due to performance constraints") - } - if self.annotate, annotatedPath == nil, annotationsAllowed { - logger.operationStart("generate_annotations") - annotatedPath = try await self.generateAnnotatedScreenshot( - snapshotId: captureResult.snapshotId, - originalPath: captureResult.screenshotPath - ) - if let annotatedPath, - annotatedPath != captureResult.screenshotPath { - try await self.services.snapshots.storeAnnotatedScreenshot( - snapshotId: captureResult.snapshotId, - annotatedScreenshotPath: annotatedPath - ) - } - logger.operationComplete("generate_annotations", metadata: [ - "annotatedPath": annotatedPath ?? "none", - ]) - } - if self.annotate, annotationsAllowed, annotatedPath == nil, !self.jsonOutput { - print("\(AgentDisplayTokens.Status.warning) No interactive UI elements found to annotate") - } else if self.annotate, annotationsAllowed, let annotatedPath, !self.jsonOutput { - let interactableElements = captureResult.elements.all.filter(\.isEnabled) - print("📝 Created annotated screenshot with \(interactableElements.count) interactive elements") - self.logger.verbose("Annotated screenshot path: \(annotatedPath)") - } - - // Perform AI analysis if requested - var analysisResult: SeeAnalysisData? - if let prompt = analyze { - // Pre-analysis diagnostics - let fileSize = (try? FileManager.default - .attributesOfItem(atPath: captureResult.screenshotPath)[.size] as? Int) ?? 0 - logger.verbose( - "Starting AI analysis", - category: "AI", - metadata: [ - "imagePath": captureResult.screenshotPath, - "imageSizeBytes": fileSize, - "promptLength": prompt.count - ] - ) - logger.operationStart("ai_analysis", metadata: ["promptPreview": String(prompt.prefix(80))]) - logger.startTimer("ai_generate") - analysisResult = try await self.performAnalysisDetailed( - imagePath: captureResult.screenshotPath, - prompt: prompt - ) - logger.stopTimer("ai_generate") - logger.operationComplete( - "ai_analysis", - success: analysisResult != nil, - metadata: [ - "provider": analysisResult?.provider ?? "unknown", - "model": analysisResult?.model ?? "unknown" - ] - ) - } - - // Output results - let executionTime = Date().timeIntervalSince(startTime) - logger.operationComplete("see_command", metadata: [ - "executionTimeMs": Int(executionTime * 1000), - "success": true, - ]) - - let context = SeeCommandRenderContext( - snapshotId: captureResult.snapshotId, - screenshotPath: captureResult.screenshotPath, - annotatedPath: annotatedPath, - metadata: captureResult.metadata, - elements: captureResult.elements, - analysis: analysisResult, - executionTime: executionTime, - observation: captureResult.observation - ) - await self.renderResults(context: context) - - } catch { - logger.operationComplete("see_command", success: false, metadata: [ - "error": error.localizedDescription, - ]) - self.handleError(error) // Use protocol's error handling - throw ExitCode.failure + // Generate annotated screenshot if requested + var annotatedPath = captureResult.annotatedPath + let annotationsAllowed = self.allowsAnnotationForCurrentCapture() + if self.annotate, !annotationsAllowed { + self.logger.info("Annotation is disabled for full screen captures due to performance constraints") } + if self.annotate, annotatedPath == nil, annotationsAllowed { + logger.operationStart("generate_annotations") + annotatedPath = try await self.generateAnnotatedScreenshot( + snapshotId: captureResult.snapshotId, + originalPath: captureResult.screenshotPath + ) + if let annotatedPath, + annotatedPath != captureResult.screenshotPath { + try await self.services.snapshots.storeAnnotatedScreenshot( + snapshotId: captureResult.snapshotId, + annotatedScreenshotPath: annotatedPath + ) + } + logger.operationComplete("generate_annotations", metadata: [ + "annotatedPath": annotatedPath ?? "none", + ]) + } + if self.annotate, annotationsAllowed, annotatedPath == nil, !self.jsonOutput { + print("\(AgentDisplayTokens.Status.warning) No interactive UI elements found to annotate") + } else if self.annotate, annotationsAllowed, let annotatedPath, !self.jsonOutput { + let interactableElements = captureResult.elements.all.filter(\.isEnabled) + print("📝 Created annotated screenshot with \(interactableElements.count) interactive elements") + self.logger.verbose("Annotated screenshot path: \(annotatedPath)") + } + + // Perform AI analysis if requested + var analysisResult: SeeAnalysisData? + if let prompt = analyze { + // Pre-analysis diagnostics + let fileSize = (try? FileManager.default + .attributesOfItem(atPath: captureResult.screenshotPath)[.size] as? Int) ?? 0 + logger.verbose( + "Starting AI analysis", + category: "AI", + metadata: [ + "imagePath": captureResult.screenshotPath, + "imageSizeBytes": fileSize, + "promptLength": prompt.count + ] + ) + logger.operationStart("ai_analysis", metadata: ["promptPreview": String(prompt.prefix(80))]) + logger.startTimer("ai_generate") + analysisResult = try await self.performAnalysisDetailed( + imagePath: captureResult.screenshotPath, + prompt: prompt + ) + logger.stopTimer("ai_generate") + logger.operationComplete( + "ai_analysis", + success: analysisResult != nil, + metadata: [ + "provider": analysisResult?.provider ?? "unknown", + "model": analysisResult?.model ?? "unknown" + ] + ) + } + + // Output results + let executionTime = Date().timeIntervalSince(startTime) + logger.operationComplete("see_command", metadata: [ + "executionTimeMs": Int(executionTime * 1000), + "success": true, + ]) + + let context = SeeCommandRenderContext( + snapshotId: captureResult.snapshotId, + screenshotPath: captureResult.screenshotPath, + annotatedPath: annotatedPath, + metadata: captureResult.metadata, + elements: captureResult.elements, + analysis: analysisResult, + executionTime: executionTime, + observation: captureResult.observation + ) + await self.renderResults(context: context) } func getFileSize(_ path: String) -> Int? { diff --git a/Apps/CLI/Sources/PeekabooCLI/Commands/Base/CommandErrorHandling.swift b/Apps/CLI/Sources/PeekabooCLI/Commands/Base/CommandErrorHandling.swift index f2bb9e38..0e8107b0 100644 --- a/Apps/CLI/Sources/PeekabooCLI/Commands/Base/CommandErrorHandling.swift +++ b/Apps/CLI/Sources/PeekabooCLI/Commands/Base/CommandErrorHandling.swift @@ -1,5 +1,6 @@ import Commander import Foundation +import PeekabooBridge import PeekabooCore import PeekabooFoundation @@ -49,6 +50,10 @@ extension ErrorHandlingCommand { self.mapCaptureErrorToCode(captureError) case let observationError as DesktopObservationError: self.mapObservationErrorToCode(observationError) + case let bridgeError as PeekabooBridgeErrorEnvelope: + errorCode(for: bridgeError) + case let posixError as POSIXError: + errorCode(for: posixError) case is Commander.ValidationError: .VALIDATION_ERROR default: @@ -101,6 +106,8 @@ extension ErrorHandlingCommand { .SESSION_NOT_FOUND case .snapshotNotFound: .SNAPSHOT_NOT_FOUND + case .snapshotStale: + .SNAPSHOT_STALE case .menuNotFound: .MENU_BAR_NOT_FOUND case .menuItemNotFound: @@ -223,3 +230,21 @@ func errorCode(for focusError: FocusError) -> ErrorCode { .WINDOW_NOT_FOUND } } + +func errorCode(for bridgeError: PeekabooBridgeErrorEnvelope) -> ErrorCode { + switch bridgeError.code { + case .timeout: + .TIMEOUT + default: + .INTERNAL_SWIFT_ERROR + } +} + +func errorCode(for posixError: POSIXError) -> ErrorCode { + switch posixError.code { + case .ETIMEDOUT: + .TIMEOUT + default: + .INTERNAL_SWIFT_ERROR + } +} diff --git a/Apps/CLI/Sources/PeekabooCLI/Commands/Interaction/PressCommand+CommanderMetadata.swift b/Apps/CLI/Sources/PeekabooCLI/Commands/Interaction/PressCommand+CommanderMetadata.swift index ed97cbb8..4c0b2673 100644 --- a/Apps/CLI/Sources/PeekabooCLI/Commands/Interaction/PressCommand+CommanderMetadata.swift +++ b/Apps/CLI/Sources/PeekabooCLI/Commands/Interaction/PressCommand+CommanderMetadata.swift @@ -7,10 +7,15 @@ extension PressCommand: CommanderSignatureProviding { .make( label: "keys", help: "Key(s) to press", - isOptional: false + isOptional: true ), ], options: [ + .commandOption( + "key", + help: "Key to press (alternative to positional argument)", + long: "key" + ), .commandOption( "count", help: "Repeat count for all keys", diff --git a/Apps/CLI/Sources/PeekabooCLI/Commands/Interaction/PressCommand.swift b/Apps/CLI/Sources/PeekabooCLI/Commands/Interaction/PressCommand.swift index a1e7cb03..3e224494 100644 --- a/Apps/CLI/Sources/PeekabooCLI/Commands/Interaction/PressCommand.swift +++ b/Apps/CLI/Sources/PeekabooCLI/Commands/Interaction/PressCommand.swift @@ -218,10 +218,15 @@ extension PressCommand: AsyncRuntimeCommand {} @MainActor extension PressCommand: CommanderBindableCommand { mutating func applyCommanderValues(_ values: CommanderBindableValues) throws { - guard !values.positional.isEmpty else { + let resolvedKeys = if values.positional.isEmpty { + values.singleOption("key").map { [$0] } ?? [] + } else { + values.positional + } + guard !resolvedKeys.isEmpty else { throw CommanderBindingError.missingArgument(label: "keys") } - self.keys = values.positional + self.keys = resolvedKeys self.target = try values.makeInteractionTargetOptions() if let count: Int = try values.decodeOption("count", as: Int.self) { self.count = count diff --git a/Apps/CLI/Sources/PeekabooCLI/Commands/Interaction/SetValueCommand.swift b/Apps/CLI/Sources/PeekabooCLI/Commands/Interaction/SetValueCommand.swift index eeb2b240..a6260cce 100644 --- a/Apps/CLI/Sources/PeekabooCLI/Commands/Interaction/SetValueCommand.swift +++ b/Apps/CLI/Sources/PeekabooCLI/Commands/Interaction/SetValueCommand.swift @@ -132,7 +132,7 @@ extension SetValueCommand: AsyncRuntimeCommand {} @MainActor extension SetValueCommand: CommanderBindableCommand { mutating func applyCommanderValues(_ values: CommanderBindableValues) throws { - self.value = try values.decodeOptionalPositional(0, label: "value") + self.value = try values.decodeOptionalPositional(0, label: "value") ?? values.singleOption("value") self.on = values.singleOption("on") self.snapshot = values.singleOption("snapshot") } @@ -145,6 +145,7 @@ extension SetValueCommand: CommanderSignatureProviding { .make(label: "value", help: "Value to set", isOptional: true), ], options: [ + .commandOption("value", help: "Value to set (alternative to positional argument)", long: "value"), .commandOption("on", help: "Element ID or query to set", long: "on"), .commandOption("snapshot", help: "Snapshot ID (uses latest if not specified)", long: "snapshot"), ] diff --git a/Apps/CLI/Sources/PeekabooCLI/Commands/Interaction/TypeCommand+CommanderMetadata.swift b/Apps/CLI/Sources/PeekabooCLI/Commands/Interaction/TypeCommand+CommanderMetadata.swift index d1ca5093..10ebb3a5 100644 --- a/Apps/CLI/Sources/PeekabooCLI/Commands/Interaction/TypeCommand+CommanderMetadata.swift +++ b/Apps/CLI/Sources/PeekabooCLI/Commands/Interaction/TypeCommand+CommanderMetadata.swift @@ -11,6 +11,11 @@ extension TypeCommand: CommanderSignatureProviding { ), ], options: [ + .commandOption( + "textOption", + help: "Text to type (alternative to positional argument)", + long: "text" + ), .commandOption( "snapshot", help: "Snapshot ID (uses latest if not specified)", diff --git a/Apps/CLI/Tests/CLIAutomationTests/DragCommandTests.swift b/Apps/CLI/Tests/CLIAutomationTests/DragCommandTests.swift index 5c53bae9..09920171 100644 --- a/Apps/CLI/Tests/CLIAutomationTests/DragCommandTests.swift +++ b/Apps/CLI/Tests/CLIAutomationTests/DragCommandTests.swift @@ -83,6 +83,7 @@ struct DragCommandTests { #expect(ErrorCode.NO_POINT_SPECIFIED.rawValue == "NO_POINT_SPECIFIED") #expect(ErrorCode.INVALID_COORDINATES.rawValue == "INVALID_COORDINATES") #expect(ErrorCode.SNAPSHOT_NOT_FOUND.rawValue == "SNAPSHOT_NOT_FOUND") + #expect(ErrorCode.SNAPSHOT_STALE.rawValue == "SNAPSHOT_STALE") } @Test diff --git a/Apps/CLI/Tests/CoreCLITests/CommanderBinderInteractionAliasTests.swift b/Apps/CLI/Tests/CoreCLITests/CommanderBinderInteractionAliasTests.swift new file mode 100644 index 00000000..5fc7e376 --- /dev/null +++ b/Apps/CLI/Tests/CoreCLITests/CommanderBinderInteractionAliasTests.swift @@ -0,0 +1,32 @@ +import Commander +import Testing +@testable import PeekabooCLI + +struct CommanderBinderInteractionAliasTests { + @Test + func `Type command accepts text option alias`() throws { + let parsed = ParsedValues(positional: [], options: ["textOption": ["Hello option"]], flags: []) + let command = try CommanderCLIBinder.instantiateCommand(ofType: TypeCommand.self, parsedValues: parsed) + #expect(command.text == nil) + #expect(command.textOption == "Hello option") + } + + @Test + func `Press command accepts key option alias`() throws { + let parsed = ParsedValues(positional: [], options: ["key": ["return"]], flags: []) + let command = try CommanderCLIBinder.instantiateCommand(ofType: PressCommand.self, parsedValues: parsed) + #expect(command.keys == ["return"]) + } + + @Test + func `Set value command accepts value option alias`() throws { + let parsed = ParsedValues( + positional: [], + options: ["value": ["Hello value"], "on": ["elem_2"]], + flags: [] + ) + let command = try CommanderCLIBinder.instantiateCommand(ofType: SetValueCommand.self, parsedValues: parsed) + #expect(command.value == "Hello value") + #expect(command.on == "elem_2") + } +} diff --git a/Apps/CLI/Tests/CoreCLITests/ErrorHandlingTests.swift b/Apps/CLI/Tests/CoreCLITests/ErrorHandlingTests.swift index f6aa7791..54eb6d5f 100644 --- a/Apps/CLI/Tests/CoreCLITests/ErrorHandlingTests.swift +++ b/Apps/CLI/Tests/CoreCLITests/ErrorHandlingTests.swift @@ -3,7 +3,9 @@ // PeekabooCLI // +import Foundation import Testing +@testable import PeekabooBridge @testable import PeekabooCLI @testable import PeekabooCore @@ -32,4 +34,16 @@ struct FocusErrorMappingTests { let code = errorCode(for: .timeoutWaitingForCondition) #expect(code == .TIMEOUT) } + + @Test + func `bridge timeout maps to TIMEOUT`() { + let code = errorCode(for: PeekabooBridgeErrorEnvelope(code: .timeout, message: "Timed out")) + #expect(code == .TIMEOUT) + } + + @Test + func `POSIX timeout maps to TIMEOUT`() { + let code = errorCode(for: POSIXError(.ETIMEDOUT)) + #expect(code == .TIMEOUT) + } } diff --git a/CHANGELOG.md b/CHANGELOG.md index b5db5545..659d227a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -175,6 +175,10 @@ - `peekaboo set-value` now reports unsupported direct value writes as `INVALID_INPUT` with the target element named instead of surfacing an internal Swift error. - `peekaboo config add-provider --dry-run` and `remove-provider --dry-run` now preserve the config file when invoked through the Commander CLI path. - `peekaboo config add` now exits nonzero when credential validation fails or times out, matching its JSON `success: false` response. +- Explicit stale snapshots now report the JSON error code `SNAPSHOT_STALE` instead of falling through to `UNKNOWN_ERROR`. +- Bridge transport timeouts now report the JSON error code `TIMEOUT` instead of `INTERNAL_SWIFT_ERROR`. +- `peekaboo see --json` now emits a single structured error response for capture and detection failures instead of occasionally printing two JSON objects. +- `peekaboo type --text`, `peekaboo press --key`, and `peekaboo set-value --value` now work as aliases for their positional arguments. - Peekaboo.app no longer crashes at launch on macOS 26 when the hidden Settings helper window is created. - `peekaboo hotkey` now accepts plus-separated shortcuts such as `cmd+s`, matching common CLI shorthand and the help text while still supporting comma and space separators. - `peekaboo type` is more reliable in VM and headless launch paths because printable ASCII input now uses physical key events instead of Unicode-only events.