import Foundation import AppKit import CoreGraphics import ApplicationServices enum SkrybeError: LocalizedError { case invalidUsage(String) case unsupportedSubcommand(String) case unsupportedQuotesOption(String) case apiURLInvalid case apiUnreachable(String) case invalidHTTPStatus(Int) case invalidResponse case pasteboardWriteFailed case accessibilityPermissionMissing case eventCreationFailed var errorDescription: String? { switch self { case .invalidUsage(let message): return message case .unsupportedSubcommand(let command): return "Unsupported subcommand: \(command)" case .unsupportedQuotesOption(let option): return "Unsupported quotes option: \(option)" case .apiURLInvalid: return "Invalid quotes API URL." case .apiUnreachable(let reason): return "Unable to reach quotes API: \(reason)" case .invalidHTTPStatus(let status): return "Quotes API returned HTTP status \(status)." case .invalidResponse: return "Quotes API returned an invalid response format." case .pasteboardWriteFailed: return "Failed to write quote text to the macOS pasteboard." case .accessibilityPermissionMissing: return "Accessibility permission is required to simulate paste (Cmd+V). Enable it in System Settings > Privacy & Security > Accessibility." case .eventCreationFailed: return "Unable to create keyboard events for paste operation." } } } struct QuoteResponse: Decodable { let quote: String let author: String } struct QuoteService { private let endpoint = "http://localhost:3001/quote" func fetchRandomQuote(timeout: TimeInterval = 5.0) throws -> QuoteResponse { guard let url = URL(string: endpoint) else { throw SkrybeError.apiURLInvalid } var request = URLRequest(url: url) request.httpMethod = "GET" request.timeoutInterval = timeout let semaphore = DispatchSemaphore(value: 0) var responseData: Data? var response: URLResponse? var requestError: Error? let task = URLSession.shared.dataTask(with: request) { data, urlResponse, error in responseData = data response = urlResponse requestError = error semaphore.signal() } task.resume() _ = semaphore.wait(timeout: .now() + timeout + 1.0) if let requestError { throw SkrybeError.apiUnreachable(requestError.localizedDescription) } guard let http = response as? HTTPURLResponse else { throw SkrybeError.invalidResponse } guard (200...299).contains(http.statusCode) else { throw SkrybeError.invalidHTTPStatus(http.statusCode) } guard let responseData else { throw SkrybeError.invalidResponse } do { let decoded = try JSONDecoder().decode(QuoteResponse.self, from: responseData) return decoded } catch { throw SkrybeError.invalidResponse } } } struct QuotePaster { func paste(_ text: String) throws { let pasteboard = NSPasteboard.general pasteboard.clearContents() guard pasteboard.setString(text, forType: .string) else { throw SkrybeError.pasteboardWriteFailed } guard AXIsProcessTrusted() else { throw SkrybeError.accessibilityPermissionMissing } try sendCommandV() } private func sendCommandV() throws { guard let source = CGEventSource(stateID: .hidSystemState), let keyDown = CGEvent(keyboardEventSource: source, virtualKey: 9, keyDown: true), // 9 = 'v' let keyUp = CGEvent(keyboardEventSource: source, virtualKey: 9, keyDown: false) else { throw SkrybeError.eventCreationFailed } keyDown.flags = .maskCommand keyUp.flags = .maskCommand keyDown.post(tap: .cghidEventTap) keyUp.post(tap: .cghidEventTap) } } struct SkrybeCLI { private let quoteService = QuoteService() private let quotePaster = QuotePaster() func run(arguments: [String]) throws { guard arguments.count >= 2 else { throw SkrybeError.invalidUsage(usage()) } let command = arguments[1] switch command { case "quotes": try runQuotes(arguments: Array(arguments.dropFirst(2))) case "--help", "-h", "help": print(usage()) default: throw SkrybeError.unsupportedSubcommand(command) } } private func runQuotes(arguments: [String]) throws { guard let option = arguments.first else { throw SkrybeError.invalidUsage(quotesUsage()) } switch option { case "--paste": let quote = try quoteService.fetchRandomQuote() let rendered = "\(quote.quote) — \(quote.author)" try quotePaster.paste(rendered) print("✅ Pasted quote at cursor position") case "--list", "--add": throw SkrybeError.invalidUsage("\(option) is not implemented yet. Use `skrybe quotes --paste`.") case "--help", "-h", "help": print(quotesUsage()) default: throw SkrybeError.unsupportedQuotesOption(option) } } private func usage() -> String { return """ Usage: skrybe quotes --paste skrybe quotes --list (planned) skrybe quotes --add (planned) """ } private func quotesUsage() -> String { return """ Quotes subcommand: skrybe quotes --paste Fetch random quote from localhost:3001/quote and paste at current cursor skrybe quotes --list Planned skrybe quotes --add Planned """ } } do { try SkrybeCLI().run(arguments: CommandLine.arguments) } catch { if let localized = error as? LocalizedError, let message = localized.errorDescription { fputs("❌ \(message)\n", stderr) } else { fputs("❌ \(error.localizedDescription)\n", stderr) } exit(EXIT_FAILURE) }