@l22-io/orchard-mcp 0.3.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +11 -8
  2. package/build/bridge.js +18 -0
  3. package/build/bridge.js.map +1 -1
  4. package/build/index.js +11 -1
  5. package/build/index.js.map +1 -1
  6. package/build/tools/contacts.d.ts +2 -0
  7. package/build/tools/contacts.js +37 -0
  8. package/build/tools/contacts.js.map +1 -0
  9. package/build/tools/files.js +4 -1
  10. package/build/tools/files.js.map +1 -1
  11. package/build/tools/keynote.d.ts +2 -0
  12. package/build/tools/keynote.js +198 -0
  13. package/build/tools/keynote.js.map +1 -0
  14. package/build/tools/mail.js +57 -10
  15. package/build/tools/mail.js.map +1 -1
  16. package/build/tools/notes.d.ts +2 -0
  17. package/build/tools/notes.js +83 -0
  18. package/build/tools/notes.js.map +1 -0
  19. package/build/tools/numbers.d.ts +2 -0
  20. package/build/tools/numbers.js +167 -0
  21. package/build/tools/numbers.js.map +1 -0
  22. package/build/tools/pages.d.ts +2 -0
  23. package/build/tools/pages.js +143 -0
  24. package/build/tools/pages.js.map +1 -0
  25. package/package.json +14 -9
  26. package/scripts/postinstall.sh +77 -8
  27. package/swift/.build/AppleBridge.app/Contents/MacOS/apple-bridge +0 -0
  28. package/swift/.build/AppleBridge.app.sha256 +1 -0
  29. package/swift/Package.resolved +14 -0
  30. package/swift/Package.swift +28 -0
  31. package/swift/Sources/AppleBridge/AppleBridge.swift +846 -0
  32. package/swift/Sources/AppleBridge/Calendar.swift +221 -0
  33. package/swift/Sources/AppleBridge/Contacts.swift +225 -0
  34. package/swift/Sources/AppleBridge/Doctor.swift +252 -0
  35. package/swift/Sources/AppleBridge/Files.swift +474 -0
  36. package/swift/Sources/AppleBridge/JSON.swift +57 -0
  37. package/swift/Sources/AppleBridge/Keynote.swift +599 -0
  38. package/swift/Sources/AppleBridge/Mail.swift +794 -0
  39. package/swift/Sources/AppleBridge/Notes.swift +263 -0
  40. package/swift/Sources/AppleBridge/Numbers.swift +601 -0
  41. package/swift/Sources/AppleBridge/Pages.swift +467 -0
  42. package/swift/Sources/AppleBridge/Reminders.swift +347 -0
@@ -0,0 +1,474 @@
1
+ import Foundation
2
+ import PDFKit
3
+ import UniformTypeIdentifiers
4
+ import Vision
5
+
6
+ enum FilesBridge {
7
+ private static var home: String {
8
+ FileManager.default.homeDirectoryForCurrentUser.path
9
+ }
10
+
11
+ /// Resolve and validate a path is under the home directory.
12
+ /// Accepts relative paths (to ~), ~/... paths, or absolute paths.
13
+ /// Resolves symlinks for existing paths to prevent escape.
14
+ static func validatePath(_ input: String, mustExist: Bool = true) -> String? {
15
+ let expanded: String
16
+ if input.hasPrefix("/") {
17
+ expanded = input
18
+ } else if input.hasPrefix("~/") {
19
+ expanded = home + String(input.dropFirst())
20
+ } else {
21
+ expanded = home + "/" + input
22
+ }
23
+
24
+ let url = URL(fileURLWithPath: expanded)
25
+ let resolved: String
26
+ if FileManager.default.fileExists(atPath: url.path) {
27
+ resolved = url.resolvingSymlinksInPath().path
28
+ } else if mustExist {
29
+ return nil
30
+ } else {
31
+ resolved = url.standardized.path
32
+ }
33
+
34
+ guard resolved == home || resolved.hasPrefix(home + "/") else {
35
+ return nil
36
+ }
37
+ return resolved
38
+ }
39
+
40
+ // MARK: - List
41
+
42
+ static func list(path: String, recursive: Bool, depth: Int) {
43
+ guard let resolved = validatePath(path) else {
44
+ JSONOutput.error("Path is outside home directory or does not exist: \(path)")
45
+ return
46
+ }
47
+
48
+ let fm = FileManager.default
49
+ let url = URL(fileURLWithPath: resolved)
50
+
51
+ var isDir: ObjCBool = false
52
+ guard fm.fileExists(atPath: resolved, isDirectory: &isDir), isDir.boolValue else {
53
+ JSONOutput.error("Not a directory: \(path)")
54
+ return
55
+ }
56
+
57
+ var items: [[String: Any]] = []
58
+
59
+ if recursive {
60
+ if let enumerator = fm.enumerator(
61
+ at: url,
62
+ includingPropertiesForKeys: [.fileSizeKey, .creationDateKey, .contentModificationDateKey, .isDirectoryKey, .contentTypeKey],
63
+ options: [.skipsHiddenFiles]
64
+ ) {
65
+ while let itemURL = enumerator.nextObject() as? URL {
66
+ if enumerator.level > depth {
67
+ enumerator.skipDescendants()
68
+ continue
69
+ }
70
+ if let entry = fileEntry(itemURL) {
71
+ items.append(entry)
72
+ }
73
+ }
74
+ }
75
+ } else {
76
+ do {
77
+ let contents = try fm.contentsOfDirectory(
78
+ at: url,
79
+ includingPropertiesForKeys: [.fileSizeKey, .creationDateKey, .contentModificationDateKey, .isDirectoryKey, .contentTypeKey],
80
+ options: [.skipsHiddenFiles]
81
+ )
82
+ for itemURL in contents.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) {
83
+ if let entry = fileEntry(itemURL) {
84
+ items.append(entry)
85
+ }
86
+ }
87
+ } catch {
88
+ JSONOutput.error("Failed to list directory: \(error.localizedDescription)")
89
+ return
90
+ }
91
+ }
92
+
93
+ JSONOutput.success(items)
94
+ }
95
+
96
+ private static func fileEntry(_ url: URL) -> [String: Any]? {
97
+ do {
98
+ let values = try url.resourceValues(forKeys: [
99
+ .fileSizeKey, .creationDateKey, .contentModificationDateKey,
100
+ .isDirectoryKey, .contentTypeKey
101
+ ])
102
+ var entry: [String: Any] = [
103
+ "name": url.lastPathComponent,
104
+ "path": url.path,
105
+ "isDirectory": values.isDirectory ?? false,
106
+ ]
107
+ if let size = values.fileSize { entry["size"] = size }
108
+ if let created = values.creationDate { entry["created"] = iso8601(created) }
109
+ if let modified = values.contentModificationDate { entry["modified"] = iso8601(modified) }
110
+ if let type = values.contentType { entry["type"] = type.identifier }
111
+ return entry
112
+ } catch {
113
+ return nil
114
+ }
115
+ }
116
+
117
+ // MARK: - Info
118
+
119
+ static func info(path: String) {
120
+ guard let resolved = validatePath(path) else {
121
+ JSONOutput.error("Path is outside home directory or does not exist: \(path)")
122
+ return
123
+ }
124
+
125
+ let fm = FileManager.default
126
+ let url = URL(fileURLWithPath: resolved)
127
+
128
+ var info: [String: Any] = ["path": resolved, "name": url.lastPathComponent]
129
+
130
+ do {
131
+ let attrs = try fm.attributesOfItem(atPath: resolved)
132
+ if let size = attrs[.size] as? Int64 { info["size"] = size }
133
+ if let created = attrs[.creationDate] as? Date { info["created"] = iso8601(created) }
134
+ if let modified = attrs[.modificationDate] as? Date { info["modified"] = iso8601(modified) }
135
+ if let type = attrs[.type] as? FileAttributeType {
136
+ info["fileType"] = type == .typeDirectory ? "directory" : type == .typeSymbolicLink ? "symlink" : "regular"
137
+ }
138
+ if let perms = attrs[.posixPermissions] as? Int {
139
+ info["permissions"] = String(format: "%o", perms)
140
+ }
141
+ } catch {}
142
+
143
+ // Spotlight metadata via mdls
144
+ let process = Process()
145
+ let pipe = Pipe()
146
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/mdls")
147
+ process.arguments = ["-plist", "-", resolved]
148
+ process.standardOutput = pipe
149
+ process.standardError = Pipe()
150
+
151
+ do {
152
+ try process.run()
153
+ process.waitUntilExit()
154
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
155
+ if let plist = try PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] {
156
+ let mdKeys: [String: String] = [
157
+ "kMDItemContentType": "contentType",
158
+ "kMDItemKind": "kind",
159
+ "kMDItemWhereFroms": "whereFroms",
160
+ "kMDItemPixelHeight": "pixelHeight",
161
+ "kMDItemPixelWidth": "pixelWidth",
162
+ "kMDItemDurationSeconds": "duration",
163
+ "kMDItemNumberOfPages": "pageCount",
164
+ "kMDItemAuthors": "authors",
165
+ "kMDItemTitle": "title",
166
+ ]
167
+ var spotlight: [String: Any] = [:]
168
+ for (mdKey, friendlyKey) in mdKeys {
169
+ if let value = plist[mdKey], !(value is NSNull) {
170
+ spotlight[friendlyKey] = value
171
+ }
172
+ }
173
+ if !spotlight.isEmpty {
174
+ info["spotlight"] = spotlight
175
+ }
176
+ }
177
+ } catch {}
178
+
179
+ JSONOutput.success(info)
180
+ }
181
+
182
+ // MARK: - Search (Spotlight)
183
+
184
+ private static func sanitizeSpotlightQuery(_ query: String) -> String {
185
+ let forbidden = CharacterSet(charactersIn: "()'\"\\")
186
+ return query.components(separatedBy: forbidden).joined(separator: " ")
187
+ }
188
+
189
+ static func search(query: String, kind: String?, scope: String?) {
190
+ let scopeDir = scope.flatMap({ validatePath($0) }) ?? home
191
+
192
+ let safeQuery = sanitizeSpotlightQuery(query)
193
+ var mdfindQuery = safeQuery
194
+ if let kind = kind {
195
+ let typeMap: [String: String] = [
196
+ "folder": "public.folder",
197
+ "image": "public.image",
198
+ "pdf": "com.adobe.pdf",
199
+ "document": "public.composite-content",
200
+ "audio": "public.audio",
201
+ "video": "public.movie",
202
+ "presentation": "public.presentation",
203
+ "spreadsheet": "public.spreadsheet",
204
+ ]
205
+ if let uti = typeMap[kind] {
206
+ mdfindQuery = "(\(safeQuery)) && (kMDItemContentTypeTree == '\(uti)')"
207
+ }
208
+ }
209
+
210
+ let process = Process()
211
+ let pipe = Pipe()
212
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/mdfind")
213
+ process.arguments = ["-onlyin", scopeDir, mdfindQuery]
214
+ process.standardOutput = pipe
215
+ process.standardError = Pipe()
216
+
217
+ do {
218
+ try process.run()
219
+ process.waitUntilExit()
220
+
221
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
222
+ guard let output = String(data: data, encoding: .utf8) else {
223
+ JSONOutput.success([Any]())
224
+ return
225
+ }
226
+
227
+ let paths = output.components(separatedBy: "\n").filter { !$0.isEmpty }
228
+ var results: [[String: Any]] = []
229
+
230
+ for path in paths.prefix(50) {
231
+ let url = URL(fileURLWithPath: path)
232
+ var entry: [String: Any] = [
233
+ "path": path,
234
+ "name": url.lastPathComponent,
235
+ ]
236
+ do {
237
+ let values = try url.resourceValues(forKeys: [
238
+ .fileSizeKey, .contentModificationDateKey, .contentTypeKey
239
+ ])
240
+ if let size = values.fileSize { entry["size"] = size }
241
+ if let modified = values.contentModificationDate { entry["modified"] = iso8601(modified) }
242
+ if let type = values.contentType { entry["type"] = type.identifier }
243
+ } catch {}
244
+ results.append(entry)
245
+ }
246
+
247
+ JSONOutput.success(results)
248
+ } catch {
249
+ JSONOutput.error("mdfind failed: \(error.localizedDescription)")
250
+ }
251
+ }
252
+
253
+ // MARK: - Read (text extraction)
254
+
255
+ static func read(path: String) {
256
+ guard let resolved = validatePath(path) else {
257
+ JSONOutput.error("Path is outside home directory or does not exist: \(path)")
258
+ return
259
+ }
260
+
261
+ let url = URL(fileURLWithPath: resolved)
262
+ let fm = FileManager.default
263
+
264
+ guard fm.fileExists(atPath: resolved) else {
265
+ JSONOutput.error("File not found: \(path)")
266
+ return
267
+ }
268
+
269
+ let attrs = try? fm.attributesOfItem(atPath: resolved)
270
+ let byteSize = (attrs?[.size] as? Int64) ?? 0
271
+
272
+ let resourceValues = try? url.resourceValues(forKeys: [.contentTypeKey])
273
+ let contentType = resourceValues?.contentType
274
+
275
+ let maxTextBytes = 1_000_000
276
+ var text: String?
277
+ var method = "unknown"
278
+
279
+ if let ct = contentType {
280
+ if ct.conforms(to: .plainText) || ct.conforms(to: .sourceCode)
281
+ || ct.conforms(to: .shellScript) || ct.conforms(to: .json)
282
+ || ct.conforms(to: .xml) || ct.conforms(to: .yaml) {
283
+ text = try? String(contentsOfFile: resolved, encoding: .utf8)
284
+ method = "direct"
285
+ } else if ct.conforms(to: .pdf) {
286
+ text = extractPDF(url: url)
287
+ method = "pdfkit"
288
+ } else if ct.conforms(to: .image) {
289
+ text = extractImageText(url: url)
290
+ method = "vision-ocr"
291
+ } else {
292
+ text = extractViaTextutil(path: resolved)
293
+ method = "textutil"
294
+ }
295
+ } else {
296
+ text = try? String(contentsOfFile: resolved, encoding: .utf8)
297
+ if text != nil {
298
+ method = "direct"
299
+ } else {
300
+ text = extractViaTextutil(path: resolved)
301
+ method = "textutil"
302
+ }
303
+ }
304
+
305
+ guard var extractedText = text, !extractedText.isEmpty else {
306
+ JSONOutput.error("Could not extract text from file: \(url.lastPathComponent)")
307
+ return
308
+ }
309
+
310
+ var truncated = false
311
+ if extractedText.utf8.count > maxTextBytes {
312
+ extractedText = String(extractedText.prefix(maxTextBytes))
313
+ truncated = true
314
+ }
315
+
316
+ let result: [String: Any] = [
317
+ "path": resolved,
318
+ "contentType": contentType?.identifier ?? "unknown",
319
+ "method": method,
320
+ "text": extractedText,
321
+ "truncated": truncated,
322
+ "byteSize": byteSize,
323
+ ]
324
+ JSONOutput.success(result)
325
+ }
326
+
327
+ private static func extractPDF(url: URL) -> String? {
328
+ guard let doc = PDFDocument(url: url) else { return nil }
329
+ var text = ""
330
+ for i in 0..<doc.pageCount {
331
+ if let page = doc.page(at: i), let pageText = page.string {
332
+ text += pageText + "\n"
333
+ }
334
+ }
335
+ return text.isEmpty ? nil : text
336
+ }
337
+
338
+ private static func extractImageText(url: URL) -> String? {
339
+ guard let imageData = try? Data(contentsOf: url) else { return nil }
340
+
341
+ let requestHandler = VNImageRequestHandler(data: imageData, options: [:])
342
+ let request = VNRecognizeTextRequest()
343
+ request.recognitionLevel = .accurate
344
+
345
+ do {
346
+ try requestHandler.perform([request])
347
+ guard let observations = request.results else { return nil }
348
+ let text = observations
349
+ .compactMap { $0.topCandidates(1).first?.string }
350
+ .joined(separator: "\n")
351
+ return text.isEmpty ? nil : text
352
+ } catch {
353
+ return nil
354
+ }
355
+ }
356
+
357
+ private static func extractViaTextutil(path: String) -> String? {
358
+ let process = Process()
359
+ let pipe = Pipe()
360
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/textutil")
361
+ process.arguments = ["-convert", "txt", "-stdout", path]
362
+ process.standardOutput = pipe
363
+ process.standardError = Pipe()
364
+
365
+ do {
366
+ try process.run()
367
+ process.waitUntilExit()
368
+ guard process.terminationStatus == 0 else { return nil }
369
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
370
+ let text = String(data: data, encoding: .utf8)
371
+ return (text?.isEmpty == true) ? nil : text
372
+ } catch {
373
+ return nil
374
+ }
375
+ }
376
+
377
+ // MARK: - File Operations
378
+
379
+ static func move(itemsJSON: String) {
380
+ guard let data = itemsJSON.data(using: .utf8),
381
+ let items = try? JSONSerialization.jsonObject(with: data) as? [[String: String]] else {
382
+ JSONOutput.error("Invalid JSON. Expected: [{\"source\": \"...\", \"destination\": \"...\"}]")
383
+ return
384
+ }
385
+
386
+ // Validate all paths first
387
+ for item in items {
388
+ guard let src = item["source"], let dst = item["destination"] else {
389
+ JSONOutput.error("Each item must have 'source' and 'destination' keys")
390
+ return
391
+ }
392
+ guard validatePath(src) != nil else {
393
+ JSONOutput.error("Source path outside home directory: \(src)")
394
+ return
395
+ }
396
+ guard validatePath(dst, mustExist: false) != nil else {
397
+ JSONOutput.error("Destination path outside home directory: \(dst)")
398
+ return
399
+ }
400
+ }
401
+
402
+ let fm = FileManager.default
403
+ var results: [[String: Any]] = []
404
+
405
+ for item in items {
406
+ let src = validatePath(item["source"]!)!
407
+ let dst = validatePath(item["destination"]!, mustExist: false)!
408
+ var result: [String: Any] = ["source": src, "destination": dst]
409
+ do {
410
+ try fm.moveItem(atPath: src, toPath: dst)
411
+ result["success"] = true
412
+ } catch {
413
+ result["success"] = false
414
+ result["error"] = error.localizedDescription
415
+ }
416
+ results.append(result)
417
+ }
418
+
419
+ JSONOutput.success(results)
420
+ }
421
+
422
+ static func copy(source: String, destination: String) {
423
+ guard let src = validatePath(source) else {
424
+ JSONOutput.error("Source path outside home directory or does not exist: \(source)")
425
+ return
426
+ }
427
+ guard let dst = validatePath(destination, mustExist: false) else {
428
+ JSONOutput.error("Destination path outside home directory: \(destination)")
429
+ return
430
+ }
431
+
432
+ do {
433
+ try FileManager.default.copyItem(atPath: src, toPath: dst)
434
+ JSONOutput.success(["source": src, "destination": dst, "success": true])
435
+ } catch {
436
+ JSONOutput.error("Copy failed: \(error.localizedDescription)")
437
+ }
438
+ }
439
+
440
+ static func createFolder(path: String) {
441
+ guard let resolved = validatePath(path, mustExist: false) else {
442
+ JSONOutput.error("Path is outside home directory: \(path)")
443
+ return
444
+ }
445
+
446
+ do {
447
+ try FileManager.default.createDirectory(atPath: resolved, withIntermediateDirectories: true)
448
+ JSONOutput.success(["path": resolved, "success": true])
449
+ } catch {
450
+ JSONOutput.error("Create folder failed: \(error.localizedDescription)")
451
+ }
452
+ }
453
+
454
+ static func trash(path: String) {
455
+ guard let resolved = validatePath(path) else {
456
+ JSONOutput.error("Path is outside home directory or does not exist: \(path)")
457
+ return
458
+ }
459
+
460
+ let url = URL(fileURLWithPath: resolved)
461
+ var trashURL: NSURL?
462
+
463
+ do {
464
+ try FileManager.default.trashItem(at: url, resultingItemURL: &trashURL)
465
+ var result: [String: Any] = ["path": resolved, "success": true]
466
+ if let trashPath = trashURL?.path {
467
+ result["trashPath"] = trashPath
468
+ }
469
+ JSONOutput.success(result)
470
+ } catch {
471
+ JSONOutput.error("Trash failed: \(error.localizedDescription)")
472
+ }
473
+ }
474
+ }
@@ -0,0 +1,57 @@
1
+ import Foundation
2
+
3
+ // Reason: Centralised JSON envelope so every subcommand returns a consistent
4
+ // {"status": "ok"|"error", "data": ..., "error": ...} structure.
5
+
6
+ enum JSONOutput {
7
+ /// When set, output is written to this file path instead of stdout.
8
+ /// Used by .app bundle mode where stdout is not capturable.
9
+ static var outputPath: String?
10
+
11
+ static func success(_ data: Any) {
12
+ let envelope: [String: Any] = [
13
+ "status": "ok",
14
+ "data": data
15
+ ]
16
+ printJSON(envelope)
17
+ }
18
+
19
+ static func error(_ message: String) {
20
+ let envelope: [String: Any] = [
21
+ "status": "error",
22
+ "error": message
23
+ ]
24
+ printJSON(envelope)
25
+ }
26
+
27
+ private static func printJSON(_ dict: [String: Any]) {
28
+ do {
29
+ let data = try JSONSerialization.data(
30
+ withJSONObject: dict,
31
+ options: [.prettyPrinted, .sortedKeys]
32
+ )
33
+ if let str = String(data: data, encoding: .utf8) {
34
+ if let path = outputPath {
35
+ try str.write(toFile: path, atomically: true, encoding: .utf8)
36
+ } else {
37
+ print(str)
38
+ }
39
+ }
40
+ } catch {
41
+ // Reason: Last-resort fallback if JSONSerialization itself fails.
42
+ let fallback = "{\"status\":\"error\",\"error\":\"JSON serialization failed: \(error.localizedDescription)\"}"
43
+ if let path = outputPath {
44
+ try? fallback.write(toFile: path, atomically: true, encoding: .utf8)
45
+ } else {
46
+ print(fallback)
47
+ }
48
+ }
49
+ }
50
+ }
51
+
52
+ /// Convert a Date to ISO 8601 string in the local timezone.
53
+ func iso8601(_ date: Date) -> String {
54
+ let formatter = ISO8601DateFormatter()
55
+ formatter.formatOptions = [.withInternetDateTime]
56
+ return formatter.string(from: date)
57
+ }