Skip to content
DnsLister Forum

Where domain hunters compare notes

I built a simple CLI for interacting with mac apps

Bit of a longer post this time around, hope you enjoy!

I've been building an open source CLI called mac that drives 15 native macOS apps from the terminal, mostly so AI coding agents can actually touch my calendar and notes instead of just my code. It's my own project, MIT, and I'll drop the link at the bottom. The part worth posting here is everything that went wrong.

Calendar, Reminders and Contacts run on EventKit and Contacts directly. Everything else has no public API, so it's AppleScript, plus a read-only SQLite reader for Messages history. That split is where most of the pain lives.

Mail and the `whose` clause

My first Mail implementation used the obvious thing: messages of inbox whose read status is false.

On my machine that is 97,418 messages across five accounts. It timed out at 90 seconds and left Mail sitting at 98% CPU for over nine minutes before I force quit it. Scoping the whose to a single account's mailbox timed out too.

What actually works is boring: iterate real per-account mailboxes, take a bounded window of the newest N messages, do five bulk range fetches for the properties you need, and filter in Swift. Cost scales with messages touched, roughly 0.15s per message on a 1,700 message mailbox and about 1.5s per message on a 52,000 message one. A per-account inbox count is free, so I sort accounts smallest first and exitearly once the limit is satisfied. Went from a hang to about two seconds.

If you take one thing from this post: do not use whose against Mail.

chat.db lies about deleted messages

Messages history comes from ~/Library/Messages/chat.db, opened read only. I filtered noise with date_retracted IS NULL, which looked right and returned zero rows. Turns out the column stores 0, not NULL, for messages that were never retracted. That one predicate silently excluded all 575,830 messages in my database.

Also worth knowing: modern macOS puts many message bodies in an attributedBody typedstream blob with a NULL text column, so you have to decode the blob if you want the actual text.

Small things that cost me hours

mailbox "Inbox" is case sensitive and Gmail accounts name it INBOX, so a direct lookup fails on exactly those accounts. AppleScript's own is comparison is case insensitive, which makes it extra confusing. Scan mailboxes and compare names instead.

folders of account in Notes returns the tree already flattened. I wrote a defensive recursive walk on top of that and every subfolder got visited twice, which surfaced as a folder cheerfully reporting it appeared two times. Reserved words that are not obvious until osacompile yells at you: st in Music, names in TV, it in Finder. Rename your locals.

Object specifier ranges do not coerce with as list. tracks 1 thru 1 as list gives you a bare reference, not a one element list, so index the container directly. Property value ranges do coerce, which is why this took a while to spot.

Permission prompts block before AppleScript timeouts fire, so a first run can look like a hang when it is actually just waiting on a consent dialog you cannot see.

TCC is attached to the responsible app, not your binary

This is the one that will waste your afternoon. Automation and privacy grants attach to the app responsible for the process, so Terminal and an agent-hosted shell have completely separate grants, and driving Terminal via AppleScript does not transfer attribution. I embed usage strings with a linker section rather than shipping a bundle:

-sectcreate __TEXT __info_plist Sources/MacCLI/Info.plist

and there's a doctor command that probes each capability with AEDeterminePermissionToAutomateTarget(askUserIfNeeded: false) so it can report status without triggering 14 prompts.

The thing I'd tell past me

Every single one of these was found by running against real data, never by a unit test. My fixtures encoded what I assumed macOS did. Three separate times the fixture was confidently wrong and the tests were green. 512 tests now, and the live pass still finds things.

macoscli.sh (macOS 14+, MIT)
https://github.com/31Carlton7/mac-cli
https://x.com/31Carlton7/status/2093458196438643145

P.S. Has anyone found a sane way to search Mail bodies at scale? That's the one I still have no good answer for, the AppleScript path is unusably slow and I've been staring at Spotlight metadata as a workaround.

https://macoscli.sh/

Source: r/appledevelopers · by /u/31Carlton7

Leave a Reply

Your email address will not be published. Required fields are marked *