macos-apps-mcp 0.6.0__py3-none-any.whl

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.
@@ -0,0 +1,5 @@
1
+ """macos-apps-mcp — one consolidated MCP server for native macOS apps."""
2
+
3
+ from .server import main, mcp
4
+
5
+ __all__ = ["main", "mcp"]
@@ -0,0 +1,5 @@
1
+ """`python -m macos_apps_mcp` — the deterministic launch path used in the MCP config."""
2
+
3
+ from .server import main
4
+
5
+ main()
@@ -0,0 +1,7 @@
1
+ """Per-app adapters — one module per macOS app.
2
+
3
+ Each module owns its access method (EventKit / AppleScript / osxphotos) behind the
4
+ ``contracts.PointerSource`` Protocol (reads) plus its own typed write methods. An
5
+ adapter must not reach into another adapter. Adding an app = add a module here +
6
+ mount its tools in ``server.py``.
7
+ """
@@ -0,0 +1,401 @@
1
+ """Calendar adapter — EventKit via PyObjC.
2
+
3
+ Reads return Pointers; writes take ``CalendarEventData``. All EventKit access goes
4
+ through ``runtime.run_native``; the store is owned by runtime (shared, not
5
+ reached-into).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import datetime, timedelta
11
+
12
+ import EventKit as EK
13
+
14
+ from ..contracts import CalendarEventData, Pointer, parse_datetime
15
+ from ..runtime import (
16
+ SpanRequired,
17
+ VerificationFailed,
18
+ WriteRefused,
19
+ clean_summary,
20
+ epoch_nsdate,
21
+ from_nsdate,
22
+ norm_text,
23
+ persisted_recurrence_signature,
24
+ recurrence_signature,
25
+ resolve_container,
26
+ run_native,
27
+ store,
28
+ to_nsdate,
29
+ to_recurrence_rule,
30
+ verify_persisted,
31
+ )
32
+
33
+
34
+ def _range(query: str) -> tuple[datetime, datetime]:
35
+ q = query.strip().lower()
36
+ today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
37
+ if q == "today":
38
+ return today, today + timedelta(days=1)
39
+ if q == "week":
40
+ return today, today + timedelta(days=7)
41
+ day = parse_datetime(query.strip()).replace(
42
+ hour=0, minute=0, second=0, microsecond=0
43
+ )
44
+ return day, day + timedelta(days=1)
45
+
46
+
47
+ def _event_summary(item) -> str:
48
+ start = from_nsdate(item.startDate())
49
+ if item.isAllDay():
50
+ return f"{item.title()} (all day {start:%Y-%m-%d})"
51
+ end = from_nsdate(item.endDate())
52
+ return f"{item.title()} {start:%H:%M}–{end:%H:%M}"
53
+
54
+
55
+ def _event_deeplink(item) -> str:
56
+ # calshow:<seconds-since-2001> opens Calendar to the event's day/time. macOS has no
57
+ # public scheme to open a *specific* event by id (x-apple-calevent:// is rejected;
58
+ # eventIdentifier isn't URL-addressable, and is occurrence-shared). See Apple Dev
59
+ # Forums #759266. Co-starting events thus share a deeplink; the Pointer summary +
60
+ # occurrence-precise id (see _event_id) disambiguate, not the link.
61
+ secs = int(item.startDate().timeIntervalSinceReferenceDate())
62
+ return f"calshow:{secs}"
63
+
64
+
65
+ # Recurring events share ONE calendarItemIdentifier across every occurrence, so the
66
+ # bare id can't name a single occurrence. Carry the occurrence start (epoch seconds) in
67
+ # the pointer id and re-fetch the concrete EKEvent on write (see _resolve_event), so
68
+ # EKSpanThisEvent targets THAT occurrence.
69
+ _OCC_SEP = "|"
70
+
71
+
72
+ def _event_id(item) -> str:
73
+ base = item.calendarItemIdentifier()
74
+ epoch = int(item.startDate().timeIntervalSince1970())
75
+ return f"{base}{_OCC_SEP}{epoch}"
76
+
77
+
78
+ def _event_pointer(item) -> Pointer:
79
+ return Pointer(
80
+ id=_event_id(item),
81
+ summary=clean_summary(_event_summary(item)),
82
+ deeplink=_event_deeplink(item),
83
+ )
84
+
85
+
86
+ def _calendar_pointer(cal) -> Pointer:
87
+ # A calendar (container) has no public per-calendar URL scheme; id + name (summary)
88
+ # are what a write resolves against — a write may target EITHER (#55). The title is
89
+ # kept RAW (NOT routed through clean_summary, unlike event summaries): the resolver
90
+ # still matches `c.title() == name` exactly for the name path, so the summary IS a
91
+ # write key — sanitizing it would desync the displayed name from the resolvable one
92
+ # and make the calendar name-untargetable (#52 review). deeplink empty by design.
93
+ return Pointer(id=cal.calendarIdentifier(), summary=cal.title(), deeplink="")
94
+
95
+
96
+ def _resolve_calendar(s, name: str | None):
97
+ # Disambiguation rule (#55): accept a Pointer.id OR an exact name; an id is used
98
+ # directly, an ambiguous name is refused loudly (never auto-picked). The shared
99
+ # logic — including listing candidate ids — lives in runtime.resolve_container.
100
+ if name is None:
101
+ return s.defaultCalendarForNewEvents()
102
+ items = [
103
+ (c.calendarIdentifier(), c.title(), c)
104
+ for c in s.calendarsForEntityType_(EK.EKEntityTypeEvent)
105
+ ]
106
+ return resolve_container(items, name, noun="calendar")
107
+
108
+
109
+ def _all_day_bounds(start: datetime, end: datetime) -> tuple[datetime, datetime]:
110
+ """Snap an all-day event's bounds to date-only midnight so a stored time can't drift
111
+ on CalDAV roundtrips. EventKit's all-day end date is INCLUSIVE (verified on-device:
112
+ the event covers start's day through end's day), so a same-day event keeps
113
+ ``end == start`` as one day; only a reversed span clamps back to a single day.
114
+
115
+ An all-day event is a calendar date, not an instant — so tzinfo is dropped too:
116
+ that keeps date-only math well-defined and stops a mixed naive/aware (start, end)
117
+ pair from the tool boundary raising on the comparison below."""
118
+ floor = {"hour": 0, "minute": 0, "second": 0, "microsecond": 0, "tzinfo": None}
119
+ s, e = start.replace(**floor), end.replace(**floor)
120
+ if e < s: # reversed input only: clamp to a single day (end inclusive == start)
121
+ e = s
122
+ return s, e
123
+
124
+
125
+ def _apply_event(s, e, data: CalendarEventData) -> None:
126
+ e.setTitle_(data.title)
127
+ e.setAllDay_(data.all_day)
128
+ start, end = data.start, data.end
129
+ if data.all_day: # date-only bounds — EventKit/CalDAV must not see a stray time
130
+ start, end = _all_day_bounds(start, end)
131
+ e.setStartDate_(to_nsdate(start))
132
+ e.setEndDate_(to_nsdate(end))
133
+ e.setLocation_(data.location) # full-replace: None clears
134
+ e.setNotes_(data.notes) # full-replace: None clears
135
+ # Recurrence is the exception to full-replace: only SET it when provided. Clearing a
136
+ # series needs EKSpanFutureEvents (see _resolve_span), but an omitted recurrence
137
+ # means "edit this occurrence" (EKSpanThisEvent) — so clearing-on-None would detach
138
+ # one occurrence and leave the series recurring. Leave the rule untouched.
139
+ if data.recurrence is not None:
140
+ e.setRecurrenceRules_([to_recurrence_rule(data.recurrence)])
141
+ e.setCalendar_(_resolve_calendar(s, data.calendar))
142
+ # ponytail: all-day alarm 1440-gotcha (#51) — EKAlarm relativeOffset for an all-day
143
+ # event is measured from MIDNIGHT, so "9am the day before" is -1440+540 = -900 min,
144
+ # NOT -900 from an implicit 9am. We set no alarms yet (no alarm field on
145
+ # CalendarEventData), so nothing can go wrong; wire the -1440 base in with the alarm
146
+ # field, and test it then.
147
+
148
+
149
+ # EKSpanThisEvent == 0 (falsy!), EKSpanFutureEvents == 1 — so map via membership, never
150
+ # a truthy test.
151
+ _SPANS = {
152
+ "this-event": EK.EKSpanThisEvent,
153
+ "future-events": EK.EKSpanFutureEvents,
154
+ }
155
+
156
+
157
+ def _resolve_span(e, span: str | None, *, adds_recurrence: bool = False) -> int:
158
+ """The EKSpan for an update/delete — requiring an explicit choice for a recurring
159
+ target (#51).
160
+
161
+ Editing/deleting one occurrence vs rewriting the whole series is destructive to get
162
+ wrong (mcp-ical silently rewrote users' series with a hardcoded EKSpanFutureEvents),
163
+ so a **recurring** event demands an explicit ``span``; a **single** event ignores it
164
+ (span is moot — EventKit has no other occurrences to span). Adding a rule to a
165
+ single event is inherently series-defining, so it saves future-events.
166
+ """
167
+ if not e.recurrenceRules(): # single event — span is moot
168
+ return EK.EKSpanFutureEvents if adds_recurrence else EK.EKSpanThisEvent
169
+ if span is None:
170
+ raise SpanRequired(
171
+ "This is a recurring event — specify span='this-event' (only this "
172
+ "occurrence) or span='future-events' (this and all later), then retry. "
173
+ "No change was made."
174
+ )
175
+ if span not in _SPANS:
176
+ raise SpanRequired(
177
+ "span must be 'this-event' or 'future-events' for a recurring event; got "
178
+ f"{span!r}. No change was made."
179
+ )
180
+ if adds_recurrence and _SPANS[span] == EK.EKSpanThisEvent:
181
+ raise SpanRequired(
182
+ "a recurrence change rewrites the series, so span='this-event' cannot "
183
+ "apply it — use span='future-events', or omit recurrence to edit only "
184
+ "this occurrence. No change was made."
185
+ )
186
+ return _SPANS[span]
187
+
188
+
189
+ def _resolve_event(s, ident: str):
190
+ """Resolve a pointer id to the concrete EKEvent (specific occurrence if recurring).
191
+
192
+ Pointer ids are ``<calendarItemIdentifier>|<occurrence-start-epoch>``.
193
+ ``calendarItemWithIdentifier_`` returns the series *master* (shared across
194
+ occurrences), so editing/deleting it with EKSpanThisEvent hits the wrong occurrence.
195
+ Re-fetch via a tight date-range predicate and match on (calendarItemIdentifier,
196
+ start) so the write targets exactly the cited occurrence.
197
+ """
198
+ base, sep, occ = ident.rpartition(_OCC_SEP)
199
+ if (
200
+ not sep
201
+ ): # legacy/plain id (no occurrence suffix) — fall back to the master lookup
202
+ e = s.calendarItemWithIdentifier_(ident)
203
+ if e is None:
204
+ raise ValueError(f"no event with id {ident!r}")
205
+ return e
206
+ occ_epoch = int(occ)
207
+ # ±1s window built straight from the epoch: datetime±timedelta resets the PEP-495
208
+ # fold, shifting DST-repeated-hour instants by 1h — epoch_nsdate is fold-proof.
209
+ pred = s.predicateForEventsWithStartDate_endDate_calendars_(
210
+ epoch_nsdate(occ_epoch - 1),
211
+ epoch_nsdate(occ_epoch + 1),
212
+ None,
213
+ )
214
+ for e in s.eventsMatchingPredicate_(pred) or []:
215
+ if (
216
+ e.calendarItemIdentifier() == base
217
+ and int(e.startDate().timeIntervalSince1970()) == occ_epoch
218
+ ):
219
+ return e
220
+ raise ValueError(f"no event occurrence for id {ident!r}")
221
+
222
+
223
+ def _verify_event(fresh, ident: str, data: CalendarEventData, cal_id: str) -> None:
224
+ """Re-fetch-by-id verify (#49): fail loudly if the saved event didn't persist as
225
+ requested. `fresh` is a fresh re-resolve of the occurrence we're about to return
226
+ (from _refetch_event — never None; a missed re-fetch raised there); `cal_id` is the
227
+ requested calendar's identifier (of the calendar _apply_event set).
228
+
229
+ The calendar is verified by IDENTIFIER, not title (#55 review): with id-targeting a
230
+ write can name a SPECIFIC one of several same-named calendars, so a title compare
231
+ would falsely pass if the store re-homed the event to a different calendar that
232
+ happens to share the name — exactly the re-home this #49 guard exists to catch."""
233
+ # free text through norm_text: NFC/NFD + CRLF folds are the store normalizing, not
234
+ # a dropped field; "" and None both mean "unset" (norm_text folds "" → None).
235
+ expected = {
236
+ "title": norm_text(data.title),
237
+ "all_day": data.all_day,
238
+ "location": norm_text(data.location),
239
+ "notes": norm_text(data.notes),
240
+ "calendar": cal_id, # opaque UUID handle — compared raw, not norm_text
241
+ }
242
+ actual = {
243
+ "title": norm_text(fresh.title()),
244
+ "all_day": bool(fresh.isAllDay()),
245
+ "location": norm_text(fresh.location()),
246
+ "notes": norm_text(fresh.notes()),
247
+ "calendar": fresh.calendar().calendarIdentifier(),
248
+ }
249
+ fresh_start = from_nsdate(fresh.startDate())
250
+ if data.all_day:
251
+ # EventKit's internal all-day END representation is ambiguous (inclusive vs
252
+ # stored-as-next-midnight) — an end-epoch check would false-fail every all-day
253
+ # event, so verify the start *date* + the flag only. End stays on-device
254
+ # calibration (integration), like the deeplinks.
255
+ s_exp, _ = _all_day_bounds(data.start, data.end)
256
+ expected["start_date"] = (s_exp.year, s_exp.month, s_exp.day)
257
+ actual["start_date"] = (fresh_start.year, fresh_start.month, fresh_start.day)
258
+ else:
259
+ expected["start"] = int(data.start.timestamp())
260
+ expected["end"] = int(data.end.timestamp())
261
+ actual["start"] = int(fresh_start.timestamp())
262
+ actual["end"] = int(from_nsdate(fresh.endDate()).timestamp())
263
+ if data.recurrence is not None: # None = "leave series untouched" (_apply_event)
264
+ # verify the exact cadence, not just presence — a wrong-frequency series is a
265
+ # changed field #49 must name (UNTIL deferred; see recurrence_signature).
266
+ expected["recurs"] = recurrence_signature(data.recurrence)
267
+ actual["recurs"] = persisted_recurrence_signature(fresh.recurrenceRules())
268
+ verify_persisted("event", expected, actual)
269
+
270
+
271
+ def _refetch_event(s, ident: str):
272
+ """Re-resolve the occurrence by id after a write; a not-found is a persistence
273
+ failure (fabricated id / rollback), not a plain lookup miss."""
274
+ try:
275
+ return _resolve_event(s, ident)
276
+ except ValueError as e:
277
+ raise VerificationFailed(
278
+ f"event {ident!r} could not be re-fetched after save — the write did not "
279
+ "persist (a fabricated id or an iCloud rollback). Do not trust the id."
280
+ ) from e
281
+
282
+
283
+ class CalendarAdapter:
284
+ def get_pointers(self, query: str) -> list[Pointer]:
285
+ """query: 'today' | 'week' | 'YYYY-MM-DD'."""
286
+
287
+ def work():
288
+ s = store()
289
+ start, end = _range(query)
290
+ pred = s.predicateForEventsWithStartDate_endDate_calendars_(
291
+ to_nsdate(start), to_nsdate(end), None
292
+ )
293
+ return [_event_pointer(e) for e in (s.eventsMatchingPredicate_(pred) or [])]
294
+
295
+ return run_native(work)
296
+
297
+ def get_calendars(self) -> list[Pointer]:
298
+ """Enumerate calendars as Pointers (id + name) for resolving write targets."""
299
+
300
+ def work():
301
+ s = store()
302
+ return [
303
+ _calendar_pointer(c)
304
+ for c in s.calendarsForEntityType_(EK.EKEntityTypeEvent)
305
+ ]
306
+
307
+ return run_native(work)
308
+
309
+ def create_event(self, data: CalendarEventData) -> Pointer:
310
+ def work():
311
+ s = store()
312
+ e = EK.EKEvent.eventWithEventStore_(s)
313
+ # A fresh event has no rules → single-event path; a create defining a series
314
+ # saves future-events. No span param — create is never an ambiguous edit.
315
+ span = _resolve_span(e, None, adds_recurrence=data.recurrence is not None)
316
+ _apply_event(s, e, data)
317
+ # read before save — the commit may re-home the object, and post-save it
318
+ # would tautologically equal the actual. cal may be nil (no writable
319
+ # calendar account); let the save below surface the WriteRefused. Capture
320
+ # the IDENTIFIER (not title) — that's what verify keys on (#55 review).
321
+ cal = e.calendar()
322
+ cal_id = cal.calendarIdentifier() if cal is not None else None
323
+ ok, err = s.saveEvent_span_commit_error_(e, span, True, None)
324
+ if not ok:
325
+ raise WriteRefused(
326
+ f"the event write was refused by the store: {err}. The target "
327
+ "calendar may be read-only (a subscribed calendar) or the account "
328
+ "rejected the change — do not retry the same target; tell the "
329
+ "user."
330
+ )
331
+ # Re-resolve by the occurrence id we'll return — never trust the in-memory
332
+ # event (#49): prove the id resolves and the fields persisted.
333
+ ident = _event_id(e)
334
+ fresh = _refetch_event(s, ident)
335
+ _verify_event(fresh, ident, data, cal_id)
336
+ return _event_pointer(fresh)
337
+
338
+ return run_native(work)
339
+
340
+ def update_event(
341
+ self, ident: str, data: CalendarEventData, span: str | None = None
342
+ ) -> Pointer:
343
+ def work():
344
+ s = store()
345
+ e = _resolve_event(s, ident)
346
+ # Resolve span BEFORE saving: a recurring target with no span raises here,
347
+ # so no write happens (#51). Checks the ORIGINAL event's rules, so a
348
+ # single→recurring conversion is series-defining, not ambiguous.
349
+ ek_span = _resolve_span(
350
+ e, span, adds_recurrence=data.recurrence is not None
351
+ )
352
+ _apply_event(s, e, data)
353
+ # read before save — the commit may re-home the object, and post-save it
354
+ # would tautologically equal the actual. cal may be nil (no writable
355
+ # calendar account); let the save below surface the WriteRefused. Capture
356
+ # the IDENTIFIER (not title) — that's what verify keys on (#55 review).
357
+ cal = e.calendar()
358
+ cal_id = cal.calendarIdentifier() if cal is not None else None
359
+ ok, err = s.saveEvent_span_commit_error_(e, ek_span, True, None)
360
+ if not ok:
361
+ raise WriteRefused(
362
+ f"the event write was refused by the store: {err}. The target "
363
+ "calendar may be read-only (a subscribed calendar) or the account "
364
+ "rejected the change — do not retry the same target; tell the "
365
+ "user."
366
+ )
367
+ # Re-key from the post-apply event: if start changed, _event_id(e) carries
368
+ # the new occurrence epoch, so we re-resolve (and cite) it as persisted.
369
+ ident_after = _event_id(e)
370
+ fresh = _refetch_event(s, ident_after)
371
+ _verify_event(fresh, ident_after, data, cal_id)
372
+ return _event_pointer(fresh)
373
+
374
+ return run_native(work)
375
+
376
+ def delete_event(
377
+ self, ident: str, span: str | None = None, dry_run: bool = False
378
+ ) -> Pointer | None:
379
+ """Delete an event by id. ``dry_run=True`` resolves the target — and its span,
380
+ so a recurring event still surfaces ``SpanRequired`` exactly as the real delete
381
+ would — then returns the pointer that WOULD be deleted, no mutation (#54)."""
382
+
383
+ def work():
384
+ s = store()
385
+ e = _resolve_event(s, ident)
386
+ ek_span = _resolve_span(
387
+ e, span
388
+ ) # recurring + no span → SpanRequired, no write
389
+ if dry_run:
390
+ return _event_pointer(e) # preview only — nothing is removed
391
+ ok, err = s.removeEvent_span_commit_error_(e, ek_span, True, None)
392
+ if not ok:
393
+ raise WriteRefused(
394
+ f"the event delete was refused by the store: {err}. The target "
395
+ "calendar may be read-only (a subscribed calendar) or the account "
396
+ "rejected the change — do not retry the same target; tell the "
397
+ "user."
398
+ )
399
+ return None
400
+
401
+ return run_native(work)
@@ -0,0 +1,196 @@
1
+ """Contacts adapter — Contacts.app via AppleScript/osascript (Automation TCC).
2
+
3
+ Reads return Pointers; writes take ``ContactData``. Uses the osascript escape hatch
4
+ (``runtime.run_osascript``), NOT the Contacts framework: a non-bundled server can't get
5
+ ``CNContactStore`` TCC (no usage-description bundle), but Automation TCC — scripting
6
+ Contacts.app — works, attributed to the responsible host app. See issue #15.
7
+
8
+ User input is passed as ``osascript`` argv (``on run argv``), not interpolated into
9
+ the script, so a name or id can't break out of the AppleScript.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from ..contracts import ContactData, Pointer
15
+ from ..runtime import (
16
+ VerificationFailed,
17
+ clean_summary,
18
+ norm_text,
19
+ run_osascript,
20
+ verify_persisted,
21
+ )
22
+
23
+ MAX_CONTACTS = 50 # cap a broad name match
24
+
25
+ # Field/record delimiters for the osascript payload: control chars instead of
26
+ # tab/newline, so a tab or newline *inside* a field can't split or spoof a row. _parse
27
+ # splits on these; the AppleScript emits them via `character id`.
28
+ # ponytail: US/RS are effectively absent from your own Contacts (trusted input), so this
29
+ # is treated as an invariant, not sanitized. A field carrying a literal 0x1f/0x1e (e.g.
30
+ # an adversarial vCard import) would still mis-split — strip them if seen in the wild.
31
+ _FIELD = "\x1f" # ASCII Unit Separator — between fields
32
+ _RECORD = "\x1e" # ASCII Record Separator — between people
33
+
34
+ # Each matching person -> "id|name|org|phone|email" (US-delimited, RS-terminated). Unset
35
+ # fields (`missing value` / no phones / no emails) normalize to "". Phone/email are the
36
+ # first of each — a citable handle to *reach* the person, not the full card.
37
+ # maxN bounds the work IN AppleScript: stop after maxN matches so a broad query ("a")
38
+ # doesn't fetch phone/email for thousands of people just to have Python drop all but the
39
+ # first MAX_CONTACTS. (The `whose name contains` scan itself is one cheap Apple Events
40
+ # call; the per-person property fetches below are the expensive part this `exit repeat`
41
+ # caps.) The cap is passed via argv, not interpolated.
42
+ _SEARCH = """on run argv
43
+ set q to item 1 of argv
44
+ set maxN to (item 2 of argv) as integer
45
+ set uSep to character id 31
46
+ set rSep to character id 30
47
+ set out to ""
48
+ set n to 0
49
+ with timeout of 120 seconds
50
+ tell application "Contacts"
51
+ repeat with p in (people whose name contains q)
52
+ set theOrg to organization of p
53
+ if theOrg is missing value then set theOrg to ""
54
+ set thePhone to ""
55
+ if (count of phones of p) > 0 then set thePhone to value of item 1 of phones of p
56
+ set theEmail to ""
57
+ if (count of emails of p) > 0 then set theEmail to value of item 1 of emails of p
58
+ set out to out & (id of p) & uSep & (name of p) & uSep & theOrg
59
+ set out to out & uSep & thePhone & uSep & theEmail & rSep
60
+ set n to n + 1
61
+ if n >= maxN then exit repeat
62
+ end repeat
63
+ end tell
64
+ end timeout
65
+ return out
66
+ end run"""
67
+
68
+ _CREATE = """on run argv
69
+ set fn to item 1 of argv
70
+ set ln to item 2 of argv
71
+ set org to item 3 of argv
72
+ with timeout of 120 seconds
73
+ tell application "Contacts"
74
+ set p to make new person with properties {first name:fn}
75
+ if ln is not "" then set last name of p to ln
76
+ if org is not "" then set organization of p to org
77
+ save
78
+ return id of p
79
+ end tell
80
+ end timeout
81
+ end run"""
82
+
83
+ # Re-read a person by the id we're about to return (#49) — proves the create persisted
84
+ # and didn't silently drop a field. `person id` is the direct O(1) specifier (the form
85
+ # notes.py already uses); a whose-filter would scan the whole address book. Emits
86
+ # "firstname|lastname|org" (US-delimited); an empty return means the id resolves to
87
+ # nothing (fabricated / not saved). id via argv.
88
+ _VERIFY = """on run argv
89
+ set pid to item 1 of argv
90
+ set uSep to character id 31
91
+ with timeout of 120 seconds
92
+ tell application "Contacts"
93
+ try
94
+ set p to person id pid
95
+ on error
96
+ return ""
97
+ end try
98
+ set fn to first name of p
99
+ if fn is missing value then set fn to ""
100
+ set ln to last name of p
101
+ if ln is missing value then set ln to ""
102
+ set org to organization of p
103
+ if org is missing value then set org to ""
104
+ return fn & uSep & ln & uSep & org
105
+ end tell
106
+ end timeout
107
+ end run"""
108
+
109
+
110
+ def _verify_contact(raw: str, ident: str, data: ContactData) -> None:
111
+ """Re-query verify (#49): fail loudly if the created contact can't be re-read or a
112
+ requested field didn't persist. `raw` is the ``_VERIFY`` payload for ``ident``."""
113
+ # run_osascript already strips osascript's single terminating newline, so `raw` is
114
+ # the payload as-is — never str.strip() it: the \x1f/\x1e separators are Unicode
115
+ # whitespace, so strip() would eat a leading empty given_name's delimiter and shift
116
+ # every field left. _VERIFY returns "" (empty) for a not-found id.
117
+ if not raw:
118
+ raise VerificationFailed(
119
+ f"contact {ident!r} could not be re-read after save — the write did not "
120
+ "persist (a fabricated id or an unsaved record). Do not trust the id."
121
+ )
122
+ parts = raw.split(_FIELD)
123
+ fn = parts[0] if parts else ""
124
+ ln = parts[1] if len(parts) > 1 else ""
125
+ org = parts[2] if len(parts) > 2 else ""
126
+ expected = {
127
+ # norm_text folds "" ↔ None on BOTH sides (an org-only contact has an empty
128
+ # given_name) and NFC/LF-normalizes, so a correct save can't false-fail (#49).
129
+ "given_name": norm_text(data.given_name),
130
+ "family_name": norm_text(data.family_name),
131
+ "organization": norm_text(data.organization),
132
+ }
133
+ actual = {
134
+ "given_name": norm_text(fn),
135
+ "family_name": norm_text(ln),
136
+ "organization": norm_text(org),
137
+ }
138
+ verify_persisted("contact", expected, actual)
139
+
140
+
141
+ def _summary(name: str, org: str, phone: str = "", email: str = "") -> str:
142
+ name, org = name.strip(), org.strip()
143
+ head = f"{name} — {org}" if (name and org) else (name or org)
144
+ contact = " · ".join(x for x in (phone.strip(), email.strip()) if x)
145
+ if head and contact:
146
+ return f"{head} · {contact}"
147
+ return head or contact or "(unnamed contact)"
148
+
149
+
150
+ def _deeplink(ident: str) -> str:
151
+ # addressbook://<id> opens Contacts to the card (best-effort; verify on-device).
152
+ return f"addressbook://{ident}"
153
+
154
+
155
+ def _parse(raw: str) -> list[Pointer]:
156
+ out = []
157
+ for rec in raw.split(_RECORD):
158
+ if not rec.strip():
159
+ continue
160
+ parts = rec.split(_FIELD)
161
+ ident = parts[0]
162
+ name = parts[1] if len(parts) > 1 else ""
163
+ org = parts[2] if len(parts) > 2 else ""
164
+ phone = parts[3] if len(parts) > 3 else ""
165
+ email = parts[4] if len(parts) > 4 else ""
166
+ out.append(
167
+ Pointer(
168
+ id=ident,
169
+ summary=clean_summary(_summary(name, org, phone, email)),
170
+ deeplink=_deeplink(ident),
171
+ )
172
+ )
173
+ return out
174
+
175
+
176
+ class ContactsAdapter:
177
+ def get_pointers(self, query: str) -> list[Pointer]:
178
+ """query: a name (substring) to match, e.g. 'jane' or 'Jane Doe'."""
179
+ name = query.strip()
180
+ if not name:
181
+ raise ValueError("contacts read needs a name to match (got an empty query)")
182
+ # AppleScript caps at MAX_CONTACTS; the slice is a cheap backstop on its output.
183
+ return _parse(run_osascript(_SEARCH, name, str(MAX_CONTACTS)))[:MAX_CONTACTS]
184
+
185
+ def create_contact(self, data: ContactData) -> Pointer:
186
+ ident = run_osascript(
187
+ _CREATE, data.given_name, data.family_name or "", data.organization or ""
188
+ ).strip()
189
+ # Re-read by the id we're about to return — never trust the create's echo (#49).
190
+ _verify_contact(run_osascript(_VERIFY, ident), ident, data)
191
+ full = f"{data.given_name} {data.family_name or ''}"
192
+ return Pointer(
193
+ id=ident,
194
+ summary=clean_summary(_summary(full, data.organization or "")),
195
+ deeplink=_deeplink(ident),
196
+ )