import Foundation import Darwin import CairnquireSync // MARK: - CLI enum CLIError: Error { case invalidArguments case missingServerURL case missingToken case fileNotFound } final class TerminalProgress: @unchecked Sendable { private let lock = NSLock() private var lastPercent = -1 func update(_ progress: Double) { let percent = min(max(Int(progress * 100), 0), 100) lock.lock() defer { lock.unlock() } guard percent != lastPercent else { return } lastPercent = percent print("\rUploading... \(percent)%", terminator: "") fflush(stdout) } func finishLine() { print("") } } func readClipboard() -> String? { let task = Process() task.launchPath = "/usr/bin/pbpaste" let pipe = Pipe() task.standardOutput = pipe do { try task.run() let data = pipe.fileHandleForReading.readDataToEndOfFile() return String(data: data, encoding: .utf8) } catch { return nil } } struct CLI { static func run() async { let args = Array(CommandLine.arguments.dropFirst()) do { try await execute(args: args) } catch { print("Error: \(error.localizedDescription)") exit(1) } } static func execute(args: [String]) async throws { guard let command = args.first else { printUsage() return } switch command { case "upload", "up": try await uploadCommand(args: Array(args.dropFirst())) case "note", "n": try await noteCommand(args: Array(args.dropFirst())) case "sync": try await syncCommand(args: Array(args.dropFirst())) case "config": try await configCommand(args: Array(args.dropFirst())) case "help", "-h", "--help": printHelp(for: args.dropFirst().first) default: print("Unknown command: \(command)") printUsage() } } // MARK: - Commands static func uploadCommand(args: [String]) async throws { if args.contains("--help") || args.contains("-h") { printUploadUsage() return } guard let filePath = args.first else { printUploadUsage() throw CLIError.invalidArguments } let config = ConfigStore.shared.load() guard let token = config.apiToken else { print("No API token configured. Run: cairnquire-cli config --token ") throw CLIError.missingToken } guard let baseURL = config.serverBaseURL else { print("No server URL configured. Run: cairnquire-cli config --server ") throw CLIError.missingServerURL } let fileURL = URL(fileURLWithPath: filePath) guard FileManager.default.fileExists(atPath: filePath) else { print("File not found: \(filePath)") throw CLIError.fileNotFound } let client = APIClient(baseURL: baseURL, apiToken: token) let manager = UploadManager(client: client, config: config) print("Uploading \(fileURL.lastPathComponent)...") let progress = TerminalProgress() let result = try await uploadFileResolvingDuplicates(manager: manager, fileURL: fileURL, progress: progress) progress.finishLine() print("✓ Uploaded successfully") print(" URL: \(result.url)") print(" Hash: \(result.hash)") print(" Copied to clipboard") } static func noteCommand(args: [String]) async throws { if args.contains("--help") || args.contains("-h") { printNoteUsage() return } let config = ConfigStore.shared.load() guard let token = config.apiToken else { print("No API token configured. Run: cairnquire-cli config --token ") throw CLIError.missingToken } guard let baseURL = config.serverBaseURL else { print("No server URL configured. Run: cairnquire-cli config --server ") throw CLIError.missingServerURL } let content: String if args.contains("--clipboard") || args.isEmpty { guard let clipboard = readClipboard() else { print("Clipboard is empty") throw CLIError.invalidArguments } content = clipboard } else { // Read from stdin or args if args.first == "-" { content = String(data: FileHandle.standardInput.availableData, encoding: .utf8) ?? "" } else { content = args.joined(separator: " ") } } guard !content.isEmpty else { print("No content provided") throw CLIError.invalidArguments } let title = args.first(where: { $0.hasPrefix("--title=") })?.replacingOccurrences(of: "--title=", with: "") let client = APIClient(baseURL: baseURL, apiToken: token) let manager = UploadManager(client: client, config: config) print("Creating note...") let result = try await manager.createNote(from: content, title: title) print("✓ Note created") print(" Path: \(result.path)") print(" Title: \(result.title)") print(" Hash: \(result.hash)") } static func syncCommand(args: [String]) async throws { if args.contains("--help") || args.contains("-h") { printSyncUsage() return } let config = ConfigStore.shared.load() guard let token = config.apiToken else { print("No API token configured") throw CLIError.missingToken } guard let baseURL = config.serverBaseURL else { print("No server URL configured") throw CLIError.missingServerURL } guard let syncFolder = config.syncFolder else { print("No sync folder configured") throw CLIError.invalidArguments } let client = APIClient(baseURL: baseURL, apiToken: token) let engine = SyncEngine(client: client, config: config) print("Syncing \(syncFolder)...") try await engine.performSync() print("✓ Sync complete") } static func configCommand(args: [String]) async throws { if args.contains("--help") || args.contains("-h") { printConfigUsage() return } var config = ConfigStore.shared.load() var i = 0 while i < args.count { let arg = args[i] guard i + 1 < args.count else { print("Missing value for \(arg)") throw CLIError.invalidArguments } let value = args[i + 1] switch arg { case "--server": config.serverURL = value case "--token": config.apiToken = value case "--folder": config.syncFolder = value case "--upload-folder": config.defaultUploadFolder = value default: print("Unknown config option: \(arg)") throw CLIError.invalidArguments } i += 2 } await ConfigStore.shared.save(config) print("Configuration saved:") print(" Server: \(config.serverURL)") print(" Sync Folder: \(config.syncFolder ?? "(none)")") print(" Upload Folder: \(config.defaultUploadFolder ?? "(none)")") print(" Token: \(config.apiToken != nil ? "(set)" : "(not set)")") } static func uploadFileResolvingDuplicates( manager: UploadManager, fileURL: URL, progress: TerminalProgress ) async throws -> UploadResult { var duplicateAction: UploadDuplicateAction? while true { do { return try await manager.uploadFile( fileURL, duplicateAction: duplicateAction, onProgress: { value in progress.update(value) } ) } catch let error as APIError where error.isUploadConflict { progress.finishLine() duplicateAction = try promptForDuplicate(error, filename: fileURL.lastPathComponent) print("Uploading \(fileURL.lastPathComponent)...") } } } static func promptForDuplicate(_ error: APIError, filename: String) throws -> UploadDuplicateAction { if error.conflictType == "document" { print("A document named \(filename) already exists in the inbox.") if let existingPath = error.existingPath, !existingPath.isEmpty { print("Existing document: \(existingPath)") } print("[k] Keep both [r] Replace existing [c] Cancel") print("Choice: ", terminator: "") fflush(stdout) switch readLine()?.lowercased() { case "k", "keep", "keep-both": return .rename case "r", "replace", "overwrite": return .overwrite default: throw UploadCancelledError() } } print("\(filename) was already uploaded.") if let existingURL = error.existingUrl, !existingURL.isEmpty { print("Existing URL: \(existingURL)") } print("[u] Use existing URL [c] Cancel") print("Choice: ", terminator: "") fflush(stdout) switch readLine()?.lowercased() { case "u", "use", "reuse": return .reuse default: throw UploadCancelledError() } } static func printUsage() { print(""" Cairnquire CLI - macOS sync and upload tool USAGE: cairnquire-cli [options] COMMANDS: upload, up Upload a file and copy URL to clipboard note, n [text] Create a new note from clipboard or arguments sync Sync the configured folder once config [options] Configure the shared app and CLI settings help [command] Show general or command-specific help EXAMPLES: cairnquire-cli upload ~/Documents/report.pdf cairnquire-cli note --clipboard --title="Meeting Notes" echo "Hello World" | cairnquire-cli note - cairnquire-cli config --server http://localhost:8080 --token cq_pat_xxx cairnquire-cli sync Run 'cairnquire-cli help ' for details. """) } static func printHelp(for command: String?) { switch command { case "upload", "up": printUploadUsage() case "note", "n": printNoteUsage() case "sync": printSyncUsage() case "config": printConfigUsage() default: printUsage() } } static func printUploadUsage() { print(""" USAGE: cairnquire-cli upload Upload one file and copy its canonical Cairnquire URL to the clipboard. Readable text files become rendered documents. Other files remain attachments. If the inbox name or attachment content already exists, the CLI asks what to do. """) } static func printNoteUsage() { print(""" USAGE: cairnquire-cli note [--clipboard] [--title="Title"] cairnquire-cli note cairnquire-cli note - Create a markdown note from clipboard content, arguments, or stdin. """) } static func printSyncUsage() { print(""" USAGE: cairnquire-cli sync Run one bidirectional sync for the folder configured with: cairnquire-cli config --folder The menubar app also watches this folder when auto-sync is enabled in Settings. """) } static func printConfigUsage() { print(""" USAGE: cairnquire-cli config [options] OPTIONS: --server Set the Cairnquire server URL --token Set the API token --folder Set the local folder used by sync --upload-folder Set the server folder for readable uploads The menubar app and CLI use the same saved configuration. """) } } // MARK: - Entry Point @main struct CairnquireCLI { static func main() async { await CLI.run() } }