@l22-io/orchard-mcp 0.3.2 → 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 +11 -8
  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,221 @@
1
+ import EventKit
2
+ import Foundation
3
+
4
+ // Reason: All calendar access goes through EventKit, which properly handles
5
+ // recurring event expansion via predicateForEvents -- something AppleScript cannot do.
6
+
7
+ enum CalendarBridge {
8
+ private static let store = EKEventStore()
9
+
10
+ /// Request full calendar access. Returns true if granted.
11
+ static func requestAccess() async -> Bool {
12
+ do {
13
+ return try await store.requestFullAccessToEvents()
14
+ } catch {
15
+ return false
16
+ }
17
+ }
18
+
19
+ /// Check current authorization status without prompting.
20
+ static func authorizationStatus() -> EKAuthorizationStatus {
21
+ return EKEventStore.authorizationStatus(for: .event)
22
+ }
23
+
24
+ /// List all calendars with account and metadata.
25
+ static func listCalendars() async {
26
+ guard await requestAccess() else {
27
+ JSONOutput.error("Calendar access denied. Grant access in System Settings > Privacy & Security > Calendars.")
28
+ return
29
+ }
30
+
31
+ let calendars = store.calendars(for: .event)
32
+ let result: [[String: Any]] = calendars.map { cal in
33
+ var dict: [String: Any] = [
34
+ "id": cal.calendarIdentifier,
35
+ "title": cal.title,
36
+ "type": calendarTypeName(cal.type),
37
+ "allowsModify": cal.allowsContentModifications
38
+ ]
39
+ if let source = cal.source {
40
+ dict["account"] = source.title
41
+ dict["accountType"] = sourceTypeName(source.sourceType)
42
+ }
43
+ if let color = cal.cgColor {
44
+ // Reason: Convert CGColor to hex for easy display/filtering.
45
+ let components = color.components ?? []
46
+ if components.count >= 3 {
47
+ let r = Int(components[0] * 255)
48
+ let g = Int(components[1] * 255)
49
+ let b = Int(components[2] * 255)
50
+ dict["color"] = String(format: "#%02x%02x%02x", r, g, b)
51
+ }
52
+ }
53
+ return dict
54
+ }
55
+
56
+ JSONOutput.success(result)
57
+ }
58
+
59
+ /// Fetch events in a date range, optionally filtered by calendar ID.
60
+ /// Recurring events are properly expanded via predicateForEvents.
61
+ static func listEvents(startISO: String, endISO: String, calendarID: String?) async {
62
+ guard await requestAccess() else {
63
+ JSONOutput.error("Calendar access denied.")
64
+ return
65
+ }
66
+
67
+ let formatter = ISO8601DateFormatter()
68
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
69
+
70
+ guard let startDate = formatter.date(from: startISO) ?? parseFlexibleISO(startISO) else {
71
+ JSONOutput.error("Invalid start date: \(startISO). Use ISO 8601 format.")
72
+ return
73
+ }
74
+ guard let endDate = formatter.date(from: endISO) ?? parseFlexibleISO(endISO) else {
75
+ JSONOutput.error("Invalid end date: \(endISO). Use ISO 8601 format.")
76
+ return
77
+ }
78
+
79
+ var calendars: [EKCalendar]? = nil
80
+ if let calID = calendarID {
81
+ if let cal = store.calendar(withIdentifier: calID) {
82
+ calendars = [cal]
83
+ } else {
84
+ JSONOutput.error("Calendar not found: \(calID)")
85
+ return
86
+ }
87
+ }
88
+
89
+ let predicate = store.predicateForEvents(withStart: startDate, end: endDate, calendars: calendars)
90
+ let events = store.events(matching: predicate)
91
+
92
+ let result: [[String: Any]] = events.map { evt in
93
+ var dict: [String: Any] = [
94
+ "id": evt.eventIdentifier ?? "",
95
+ "title": evt.title ?? "(no title)",
96
+ "start": iso8601(evt.startDate),
97
+ "end": iso8601(evt.endDate),
98
+ "isAllDay": evt.isAllDay,
99
+ "calendar": evt.calendar.title,
100
+ "calendarId": evt.calendar.calendarIdentifier
101
+ ]
102
+ if let location = evt.location, !location.isEmpty {
103
+ dict["location"] = location
104
+ }
105
+ if let notes = evt.notes, !notes.isEmpty {
106
+ dict["notes"] = notes
107
+ }
108
+ if let url = evt.url {
109
+ dict["url"] = url.absoluteString
110
+ }
111
+ if evt.hasRecurrenceRules {
112
+ dict["isRecurring"] = true
113
+ }
114
+ if let attendees = evt.attendees, !attendees.isEmpty {
115
+ dict["attendees"] = attendees.map { a in
116
+ var info: [String: Any] = [
117
+ "name": a.name ?? a.url.absoluteString,
118
+ "status": participantStatusName(a.participantStatus)
119
+ ]
120
+ if a.isCurrentUser {
121
+ info["isMe"] = true
122
+ }
123
+ return info
124
+ }
125
+ }
126
+ return dict
127
+ }
128
+
129
+ JSONOutput.success(result)
130
+ }
131
+
132
+ /// Search events by title within a date range.
133
+ static func searchEvents(query: String, startISO: String, endISO: String) async {
134
+ guard await requestAccess() else {
135
+ JSONOutput.error("Calendar access denied.")
136
+ return
137
+ }
138
+
139
+ let formatter = ISO8601DateFormatter()
140
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
141
+
142
+ guard let startDate = formatter.date(from: startISO) ?? parseFlexibleISO(startISO) else {
143
+ JSONOutput.error("Invalid start date: \(startISO)")
144
+ return
145
+ }
146
+ guard let endDate = formatter.date(from: endISO) ?? parseFlexibleISO(endISO) else {
147
+ JSONOutput.error("Invalid end date: \(endISO)")
148
+ return
149
+ }
150
+
151
+ let predicate = store.predicateForEvents(withStart: startDate, end: endDate, calendars: nil)
152
+ let events = store.events(matching: predicate)
153
+
154
+ let lowered = query.lowercased()
155
+ let filtered = events.filter { evt in
156
+ let title = (evt.title ?? "").lowercased()
157
+ let notes = (evt.notes ?? "").lowercased()
158
+ let location = (evt.location ?? "").lowercased()
159
+ return title.contains(lowered) || notes.contains(lowered) || location.contains(lowered)
160
+ }
161
+
162
+ let result: [[String: Any]] = filtered.map { evt in
163
+ [
164
+ "id": evt.eventIdentifier ?? "",
165
+ "title": evt.title ?? "(no title)",
166
+ "start": iso8601(evt.startDate),
167
+ "end": iso8601(evt.endDate),
168
+ "isAllDay": evt.isAllDay,
169
+ "calendar": evt.calendar.title
170
+ ]
171
+ }
172
+
173
+ JSONOutput.success(result)
174
+ }
175
+
176
+ // MARK: - Helpers
177
+
178
+ private static func parseFlexibleISO(_ str: String) -> Date? {
179
+ // Reason: Accept date-only strings like "2026-02-17" without time component.
180
+ let formatter = ISO8601DateFormatter()
181
+ formatter.formatOptions = [.withFullDate]
182
+ return formatter.date(from: str)
183
+ }
184
+
185
+ private static func calendarTypeName(_ type: EKCalendarType) -> String {
186
+ switch type {
187
+ case .local: return "local"
188
+ case .calDAV: return "caldav"
189
+ case .exchange: return "exchange"
190
+ case .subscription: return "subscription"
191
+ case .birthday: return "birthday"
192
+ @unknown default: return "unknown"
193
+ }
194
+ }
195
+
196
+ private static func sourceTypeName(_ type: EKSourceType) -> String {
197
+ switch type {
198
+ case .local: return "local"
199
+ case .exchange: return "exchange"
200
+ case .calDAV: return "caldav"
201
+ case .mobileMe: return "mobileme"
202
+ case .subscribed: return "subscribed"
203
+ case .birthdays: return "birthdays"
204
+ @unknown default: return "unknown"
205
+ }
206
+ }
207
+
208
+ private static func participantStatusName(_ status: EKParticipantStatus) -> String {
209
+ switch status {
210
+ case .unknown: return "unknown"
211
+ case .pending: return "pending"
212
+ case .accepted: return "accepted"
213
+ case .declined: return "declined"
214
+ case .tentative: return "tentative"
215
+ case .delegated: return "delegated"
216
+ case .completed: return "completed"
217
+ case .inProcess: return "in_process"
218
+ @unknown default: return "unknown"
219
+ }
220
+ }
221
+ }
@@ -0,0 +1,225 @@
1
+ import Contacts
2
+ import Foundation
3
+
4
+ enum ContactsBridge {
5
+ private static let store = CNContactStore()
6
+
7
+ static func requestAccess() async -> Bool {
8
+ await withCheckedContinuation { cont in
9
+ store.requestAccess(for: .contacts) { granted, _ in
10
+ cont.resume(returning: granted)
11
+ }
12
+ }
13
+ }
14
+
15
+ static func authorizationStatus() -> CNAuthorizationStatus {
16
+ CNContactStore.authorizationStatus(for: .contacts)
17
+ }
18
+
19
+ // MARK: - Keys
20
+
21
+ // Reason: CNContactNoteKey requires a restricted entitlement since macOS 11
22
+ // and is deliberately omitted here — requesting it would fail the fetch.
23
+ private static var defaultKeys: [CNKeyDescriptor] {
24
+ let stringKeys: [CNKeyDescriptor] = [
25
+ CNContactIdentifierKey,
26
+ CNContactGivenNameKey,
27
+ CNContactFamilyNameKey,
28
+ CNContactMiddleNameKey,
29
+ CNContactNicknameKey,
30
+ CNContactOrganizationNameKey,
31
+ CNContactJobTitleKey,
32
+ CNContactEmailAddressesKey,
33
+ CNContactPhoneNumbersKey,
34
+ CNContactPostalAddressesKey,
35
+ CNContactBirthdayKey,
36
+ CNContactUrlAddressesKey,
37
+ CNContactImageDataAvailableKey
38
+ ].map { $0 as CNKeyDescriptor }
39
+ return stringKeys + [
40
+ CNContactFormatter.descriptorForRequiredKeys(for: .fullName)
41
+ ]
42
+ }
43
+
44
+ // MARK: - Public API
45
+
46
+ static func listGroups() async {
47
+ guard await requestAccess() else {
48
+ JSONOutput.error("Contacts access denied. Grant access in System Settings > Privacy & Security > Contacts.")
49
+ return
50
+ }
51
+ do {
52
+ let groups = try store.groups(matching: nil)
53
+ let result: [[String: Any]] = try groups.map { g in
54
+ let predicate = CNContact.predicateForContactsInGroup(withIdentifier: g.identifier)
55
+ let members = try store.unifiedContacts(
56
+ matching: predicate,
57
+ keysToFetch: [CNContactIdentifierKey as CNKeyDescriptor]
58
+ )
59
+ return [
60
+ "id": g.identifier,
61
+ "name": g.name,
62
+ "memberCount": members.count
63
+ ]
64
+ }
65
+ JSONOutput.success(result)
66
+ } catch {
67
+ JSONOutput.error("Failed to list groups: \(error.localizedDescription)")
68
+ }
69
+ }
70
+
71
+ static func search(query: String, limit: Int) async {
72
+ guard await requestAccess() else {
73
+ JSONOutput.error("Contacts access denied. Grant access in System Settings > Privacy & Security > Contacts.")
74
+ return
75
+ }
76
+ let results = findMatches(query: query)
77
+ let trimmed = Array(results.prefix(limit))
78
+ let summaries = trimmed.map { summary(for: $0) }
79
+ JSONOutput.success([
80
+ "contacts": summaries,
81
+ "total": results.count,
82
+ "limit": limit,
83
+ "hasMore": results.count > trimmed.count
84
+ ] as [String: Any])
85
+ }
86
+
87
+ static func readContact(id: String) async {
88
+ guard await requestAccess() else {
89
+ JSONOutput.error("Contacts access denied. Grant access in System Settings > Privacy & Security > Contacts.")
90
+ return
91
+ }
92
+ do {
93
+ let contact = try store.unifiedContact(
94
+ withIdentifier: id,
95
+ keysToFetch: defaultKeys
96
+ )
97
+ JSONOutput.success(fullDetail(for: contact))
98
+ } catch {
99
+ JSONOutput.error("Contact not found: \(id)")
100
+ }
101
+ }
102
+
103
+ // MARK: - Search
104
+
105
+ // Runs name / email / phone predicates, then falls back to a digits-only
106
+ // substring scan over every contact's phone numbers when the query looks
107
+ // phone-ish (CNContact.predicateForContacts(matching: CNPhoneNumber) only
108
+ // matches normalized full numbers — partials like "+4917" would otherwise
109
+ // return empty). Deduplicated by identifier.
110
+ private static func findMatches(query: String) -> [CNContact] {
111
+ var seen = Set<String>()
112
+ var results: [CNContact] = []
113
+ for predicate in predicates(for: query) {
114
+ do {
115
+ let batch = try store.unifiedContacts(matching: predicate, keysToFetch: defaultKeys)
116
+ for c in batch where !seen.contains(c.identifier) {
117
+ seen.insert(c.identifier)
118
+ results.append(c)
119
+ }
120
+ } catch {
121
+ continue
122
+ }
123
+ }
124
+ if looksLikePhone(query) {
125
+ for c in phoneSubstringMatches(query: query) where !seen.contains(c.identifier) {
126
+ seen.insert(c.identifier)
127
+ results.append(c)
128
+ }
129
+ }
130
+ return results
131
+ }
132
+
133
+ private static func predicates(for query: String) -> [NSPredicate] {
134
+ var list: [NSPredicate] = [CNContact.predicateForContacts(matchingName: query)]
135
+ if query.contains("@") {
136
+ list.append(CNContact.predicateForContacts(matchingEmailAddress: query))
137
+ }
138
+ if looksLikePhone(query) {
139
+ list.append(CNContact.predicateForContacts(matching: CNPhoneNumber(stringValue: query)))
140
+ }
141
+ return list
142
+ }
143
+
144
+ private static func looksLikePhone(_ query: String) -> Bool {
145
+ query.first.map { "+0123456789".contains($0) } ?? false
146
+ }
147
+
148
+ private static func phoneSubstringMatches(query: String) -> [CNContact] {
149
+ // Normalize to digits only, then also strip any leading zeros so that
150
+ // national-format queries (e.g. trunk-prefixed local numbers) match
151
+ // international-format stored phone numbers that omit the trunk digit.
152
+ let digits = query.filter(\.isNumber)
153
+ let trimmed = String(digits.drop(while: { $0 == "0" }))
154
+ let candidates = Set([digits, trimmed].filter { $0.count >= 3 })
155
+ guard !candidates.isEmpty else { return [] }
156
+
157
+ let request = CNContactFetchRequest(keysToFetch: defaultKeys)
158
+ request.unifyResults = true
159
+ var results: [CNContact] = []
160
+ do {
161
+ try store.enumerateContacts(with: request) { contact, _ in
162
+ for phone in contact.phoneNumbers {
163
+ let phoneDigits = phone.value.stringValue.filter(\.isNumber)
164
+ if candidates.contains(where: { phoneDigits.contains($0) }) {
165
+ results.append(contact)
166
+ return
167
+ }
168
+ }
169
+ }
170
+ } catch {
171
+ return results
172
+ }
173
+ return results
174
+ }
175
+
176
+ // MARK: - Shaping
177
+
178
+ private static func summary(for c: CNContact) -> [String: Any] {
179
+ [
180
+ "id": c.identifier,
181
+ "name": CNContactFormatter.string(from: c, style: .fullName) ?? "",
182
+ "organization": c.organizationName,
183
+ "emails": c.emailAddresses.map { $0.value as String },
184
+ "phones": c.phoneNumbers.map { $0.value.stringValue }
185
+ ]
186
+ }
187
+
188
+ private static func fullDetail(for c: CNContact) -> [String: Any] {
189
+ var dict = summary(for: c)
190
+ dict["givenName"] = c.givenName
191
+ dict["middleName"] = c.middleName
192
+ dict["familyName"] = c.familyName
193
+ dict["nickname"] = c.nickname
194
+ dict["jobTitle"] = c.jobTitle
195
+ dict["emails"] = c.emailAddresses.map { labeled($0.label, $0.value as String) }
196
+ dict["phones"] = c.phoneNumbers.map { labeled($0.label, $0.value.stringValue) }
197
+ dict["addresses"] = c.postalAddresses.map { postalDict($0) }
198
+ dict["urls"] = c.urlAddresses.map { labeled($0.label, $0.value as String) }
199
+ if let bday = c.birthday, let date = Calendar(identifier: .gregorian).date(from: bday) {
200
+ dict["birthday"] = ISO8601DateFormatter().string(from: date)
201
+ }
202
+ dict["hasImage"] = c.imageDataAvailable
203
+ return dict
204
+ }
205
+
206
+ private static func labeled(_ label: String?, _ value: String) -> [String: Any] {
207
+ var d: [String: Any] = ["value": value]
208
+ if let l = label {
209
+ d["label"] = CNLabeledValue<NSString>.localizedString(forLabel: l)
210
+ }
211
+ return d
212
+ }
213
+
214
+ private static func postalDict(_ entry: CNLabeledValue<CNPostalAddress>) -> [String: Any] {
215
+ let a = entry.value
216
+ return [
217
+ "label": entry.label.map { CNLabeledValue<NSString>.localizedString(forLabel: $0) } ?? "",
218
+ "street": a.street,
219
+ "city": a.city,
220
+ "state": a.state,
221
+ "postalCode": a.postalCode,
222
+ "country": a.country
223
+ ]
224
+ }
225
+ }
@@ -0,0 +1,252 @@
1
+ import Contacts
2
+ import EventKit
3
+ import Foundation
4
+
5
+ // Reason: The doctor subcommand provides a single entry point to verify
6
+ // all permissions are granted and list accessible resources. Useful for
7
+ // first-run setup and debugging.
8
+
9
+ enum DoctorBridge {
10
+ static func run() async {
11
+ var report: [String: Any] = [
12
+ "version": "0.5.0",
13
+ "platform": "macOS",
14
+ "systemVersion": ProcessInfo.processInfo.operatingSystemVersionString
15
+ ]
16
+
17
+ // Calendar permissions
18
+ let calStatus = EKEventStore.authorizationStatus(for: .event)
19
+ let calStatusStr = authStatusName(calStatus)
20
+ report["calendar"] = [
21
+ "status": calStatusStr,
22
+ "granted": calStatus == .fullAccess
23
+ ]
24
+
25
+ // Reason: If not determined, attempt to request so the user gets prompted.
26
+ if calStatus == .notDetermined {
27
+ let granted = await CalendarBridge.requestAccess()
28
+ report["calendar"] = [
29
+ "status": granted ? "fullAccess" : "denied",
30
+ "granted": granted,
31
+ "note": "Permission was just requested."
32
+ ]
33
+ }
34
+
35
+ // If we have calendar access, list calendar count and accounts
36
+ if calStatus == .fullAccess || calStatus == .notDetermined {
37
+ let store = EKEventStore()
38
+ _ = try? await store.requestFullAccessToEvents()
39
+ let calendars = store.calendars(for: .event)
40
+ let accounts = Set(calendars.compactMap { $0.source?.title })
41
+ report["calendarSummary"] = [
42
+ "count": calendars.count,
43
+ "accounts": Array(accounts).sorted()
44
+ ]
45
+ }
46
+
47
+ // Reminders permissions
48
+ let remStatus = EKEventStore.authorizationStatus(for: .reminder)
49
+ report["reminders"] = [
50
+ "status": authStatusName(remStatus),
51
+ "granted": remStatus == .fullAccess
52
+ ]
53
+
54
+ if remStatus == .notDetermined {
55
+ let granted = await RemindersBridge.requestAccess()
56
+ report["reminders"] = [
57
+ "status": granted ? "fullAccess" : "denied",
58
+ "granted": granted,
59
+ "note": "Permission was just requested."
60
+ ]
61
+ }
62
+
63
+ if remStatus == .fullAccess || remStatus == .notDetermined {
64
+ let store = EKEventStore()
65
+ _ = try? await store.requestFullAccessToReminders()
66
+ let lists = store.calendars(for: .reminder)
67
+ report["remindersSummary"] = [
68
+ "count": lists.count,
69
+ "lists": lists.map { $0.title }.sorted()
70
+ ]
71
+ }
72
+
73
+ // Mail -- we can only check if osascript is available
74
+ // Reason: Mail access is via AppleScript, no programmatic permission check exists.
75
+ let mailCheck = checkMailAccess()
76
+ report["mail"] = mailCheck
77
+
78
+ // iWork apps
79
+ let numbersCheck = checkIWorkApp("Numbers")
80
+ let pagesCheck = checkIWorkApp("Pages")
81
+ let keynoteCheck = checkIWorkApp("Keynote")
82
+ report["numbers"] = numbersCheck
83
+ report["pages"] = pagesCheck
84
+ report["keynote"] = keynoteCheck
85
+
86
+ // Notes -- AppleScript-based, same shape as Mail
87
+ report["notes"] = checkNotesAccess()
88
+
89
+ // Contacts -- native CNContactStore
90
+ let contactsStatus = CNContactStore.authorizationStatus(for: .contacts)
91
+ report["contacts"] = [
92
+ "status": contactsAuthName(contactsStatus),
93
+ "granted": contactsStatus == .authorized
94
+ ]
95
+ if contactsStatus == .notDetermined {
96
+ let granted = await ContactsBridge.requestAccess()
97
+ report["contacts"] = [
98
+ "status": granted ? "authorized" : "denied",
99
+ "granted": granted,
100
+ "note": "Permission was just requested."
101
+ ]
102
+ }
103
+
104
+ // Guidance
105
+ var actions: [String] = []
106
+ if calStatus != .fullAccess {
107
+ actions.append("Calendar: Grant access in System Settings > Privacy & Security > Calendars")
108
+ }
109
+ if remStatus != .fullAccess {
110
+ actions.append("Reminders: Grant access in System Settings > Privacy & Security > Reminders")
111
+ }
112
+ if !(mailCheck["accessible"] as? Bool ?? false) {
113
+ actions.append("Mail: Run apple-bridge with a mail subcommand to trigger the Automation permission dialog")
114
+ }
115
+ if !(numbersCheck["installed"] as? Bool ?? false) {
116
+ actions.append("Numbers: Install from App Store for spreadsheet tools")
117
+ }
118
+ if !(pagesCheck["installed"] as? Bool ?? false) {
119
+ actions.append("Pages: Install from App Store for document tools")
120
+ }
121
+ if !(keynoteCheck["installed"] as? Bool ?? false) {
122
+ actions.append("Keynote: Install from App Store for presentation tools")
123
+ }
124
+ let notesAccessible = (report["notes"] as? [String: Any])?["accessible"] as? Bool ?? false
125
+ if !notesAccessible {
126
+ actions.append("Notes: Run apple-bridge with a notes subcommand to trigger the Automation permission dialog")
127
+ }
128
+ if contactsStatus != .authorized {
129
+ actions.append("Contacts: Grant access in System Settings > Privacy & Security > Contacts")
130
+ }
131
+ if !actions.isEmpty {
132
+ report["requiredActions"] = actions
133
+ }
134
+
135
+ JSONOutput.success(report)
136
+ }
137
+
138
+ private static func authStatusName(_ status: EKAuthorizationStatus) -> String {
139
+ switch status {
140
+ case .notDetermined: return "notDetermined"
141
+ case .restricted: return "restricted"
142
+ case .denied: return "denied"
143
+ case .fullAccess: return "fullAccess"
144
+ case .writeOnly: return "writeOnly"
145
+ @unknown default: return "unknown"
146
+ }
147
+ }
148
+
149
+ private static func checkIWorkApp(_ appName: String) -> [String: Any] {
150
+ let task = Process()
151
+ task.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
152
+ task.arguments = ["-e", "tell application \"\(appName)\" to return name"]
153
+ let outPipe = Pipe()
154
+ let errPipe = Pipe()
155
+ task.standardOutput = outPipe
156
+ task.standardError = errPipe
157
+
158
+ do {
159
+ try task.run()
160
+ task.waitUntilExit()
161
+ if task.terminationStatus == 0 {
162
+ return ["installed": true, "accessible": true]
163
+ } else {
164
+ let errData = errPipe.fileHandleForReading.readDataToEndOfFile()
165
+ let errStr = String(data: errData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
166
+ if errStr.contains("-600") || errStr.contains("not running") {
167
+ return ["installed": true, "accessible": false, "note": "\(appName) is not running."]
168
+ }
169
+ return ["installed": false, "accessible": false, "note": "\(appName) may not be installed."]
170
+ }
171
+ } catch {
172
+ return ["installed": false, "accessible": false, "note": "Could not check \(appName): \(error.localizedDescription)"]
173
+ }
174
+ }
175
+
176
+ private static func contactsAuthName(_ status: CNAuthorizationStatus) -> String {
177
+ switch status {
178
+ case .notDetermined: return "notDetermined"
179
+ case .restricted: return "restricted"
180
+ case .denied: return "denied"
181
+ case .authorized: return "authorized"
182
+ case .limited: return "limited"
183
+ @unknown default: return "unknown"
184
+ }
185
+ }
186
+
187
+ private static func checkNotesAccess() -> [String: Any] {
188
+ let task = Process()
189
+ task.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
190
+ task.arguments = ["-e", "tell application \"Notes\" to count of accounts"]
191
+ let pipe = Pipe()
192
+ task.standardOutput = pipe
193
+ task.standardError = Pipe()
194
+
195
+ do {
196
+ try task.run()
197
+ task.waitUntilExit()
198
+ if task.terminationStatus == 0 {
199
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
200
+ let output = String(data: data, encoding: .utf8)?
201
+ .trimmingCharacters(in: .whitespacesAndNewlines)
202
+ return [
203
+ "accessible": true,
204
+ "accountCount": Int(output ?? "0") ?? 0
205
+ ]
206
+ }
207
+ return [
208
+ "accessible": false,
209
+ "note": "Notes automation permission not yet granted or Notes.app not running."
210
+ ]
211
+ } catch {
212
+ return [
213
+ "accessible": false,
214
+ "note": "Could not run osascript: \(error.localizedDescription)"
215
+ ]
216
+ }
217
+ }
218
+
219
+ private static func checkMailAccess() -> [String: Any] {
220
+ // Reason: Try a minimal AppleScript to see if Mail.app is accessible.
221
+ // This doesn't send the permission prompt -- it just checks if we can talk to Mail.
222
+ let task = Process()
223
+ task.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
224
+ task.arguments = ["-e", "tell application \"Mail\" to count of accounts"]
225
+ let pipe = Pipe()
226
+ task.standardOutput = pipe
227
+ task.standardError = Pipe()
228
+
229
+ do {
230
+ try task.run()
231
+ task.waitUntilExit()
232
+ if task.terminationStatus == 0 {
233
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
234
+ let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
235
+ return [
236
+ "accessible": true,
237
+ "accountCount": Int(output ?? "0") ?? 0
238
+ ]
239
+ } else {
240
+ return [
241
+ "accessible": false,
242
+ "note": "Mail automation permission not yet granted or Mail.app not running."
243
+ ]
244
+ }
245
+ } catch {
246
+ return [
247
+ "accessible": false,
248
+ "note": "Could not run osascript: \(error.localizedDescription)"
249
+ ]
250
+ }
251
+ }
252
+ }