test-repo/scripts/skrybe.swift

203 lines
6.2 KiB
Swift

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)
}