惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

博客园_首页
N
Netflix TechBlog - Medium
V
Visual Studio Blog
博客园 - Franky
小众软件
小众软件
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Apple Machine Learning Research
Apple Machine Learning Research
博客园 - 三生石上(FineUI控件)
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
宝玉的分享
宝玉的分享
量子位
大猫的无限游戏
大猫的无限游戏
人人都是产品经理
人人都是产品经理
V
V2EX
The Cloudflare Blog
月光博客
月光博客
Last Week in AI
Last Week in AI
雷峰网
雷峰网
WordPress大学
WordPress大学
博客园 - 【当耐特】
博客园 - 聂微东
IT之家
IT之家
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻

Peter Steinberger

OpenClaw, OpenAI and the future | Peter Steinberger Shipping at Inference-Speed | Peter Steinberger The Signature Flicker | Peter Steinberger Just Talk To It - the no-bs Way of Agentic Engineering | Peter Steinberger Claude Code Anonymous | Peter Steinberger Live Coding Session: Building Arena | Peter Steinberger My Current AI Dev Workflow | Peter Steinberger Essential Reading for Agentic Engineers - August 2025 | Peter Steinberger Just One More Prompt | Peter Steinberger Poltergeist: The Ghost That Keeps Your Builds Fresh | Peter Steinberger Don't read this Startup Slop | Peter Steinberger Essential Reading for Agentic Engineers - July 2025 | Peter Steinberger Self-Hosting AI Models After Claude's Usage Limits | Peter Steinberger Logging Privacy Shenanigans | Peter Steinberger VibeTunnel's first AI-anniversary | Peter Steinberger Peekaboo 2.0 – Free the CLI from its MCP shackles | Peter Steinberger Command your Claude Code Army, Reloaded | Peter Steinberger Essential Reading for Agentic Engineers | Peter Steinberger Slot Machines for Programmers: How Peter Builds Apps 20x Faster with AI | Peter Steinberger My AI Workflow for Understanding Any Codebase | Peter Steinberger stats.store: Privacy-First Sparkle Analytics | Peter Steinberger Showing Settings from macOS Menu Bar Items: A 5-Hour Journey | Peter Steinberger VibeTunnel: Turn Any Browser into Your Mac's Terminal | Peter Steinberger Vibe Meter 2.0: Calculating Claude Code Usage with Token Counting | Peter Steinberger llm.codes: Make Apple Docs AI-Readable | Peter Steinberger Automatic Observation Tracking in UIKit and AppKit: The Feature Apple Forgot to Mention | Peter Steinberger Peekaboo MCP – lightning-fast macOS screenshots for AI agents | Peter Steinberger Migrating 700+ Tests to Swift Testing: A Real-World Experience | Peter Steinberger Commanding Your Claude Code Army | Peter Steinberger Code Signing and Notarization: Sparkle and Tears | Peter Steinberger
Making AppleScript Work in macOS CLI Tools: The Undocumen...
Peter Steinberger · 2025-07-03 · via Peter Steinberger

Or: How I Learned to Stop Fighting TCC and Embrace the Info.plist

TL;DR: If you’re building a macOS CLI that uses AppleScript, you need to embed an Info.plist into your binary, sign it with proper entitlements, and optionally use the undocumented responsibility_spawnattrs_setdisclaim API to avoid permission dialogs that blames the hosting app.

This all started with Cursor being annoying. You know how it goes - you’re in the zone, AI is looping through its tasks, and then bam! The inline terminal opens something blocking (file watcher, dev server, …) and the whole loop stops. I have to manually click around to get things moving again.

My solution? Build an MCP that controls an external terminal. That way, even when commands block, Cursor’s loop keeps running. I called it Terminator - ‘cause who doesn’t love a good terminal/Terminator pun?

The Evolution of a Hack

My first attempt was pure AppleScript - simple, direct terminal automation. It worked! Well, sort of… The script needed window focus to function, which meant it would constantly steal focus while Cursor was running.

The focus-stealing got so bad that at one point, the AI started writing its own AppleScript to detect which app was in the foreground. When it discovered Chrome was blocking its terminal access, it simply… killed all my Chrome windows. That was the first time I apologized to an AI agent. Clearly, I needed a better solution.

That’s when I fell down the rabbit hole. Getting AppleScript to work in a CLI tool turned out to be a maze of undocumented APIs, security permissions, and macOS quirks that nobody warns you about.

Sure, I could have used Apple’s osascript command and called it a day. But where’s the fun in that? Plus, AppKit’s AppleScript API gives you much better error handling - if you can get it working.

Plot Twist: Enter Claude Code

Here’s the thing - I never actually finished Terminator. Why? Because Claude Code came along and made it obsolete. As a massive Claude Code fanboy (seriously, check out my posts), I ditched Cursor. Claude Code doesn’t have these inline terminal blocking issues.

I’m writing this here since future you or me will stumble into the same issue eventually, and hopefully you can just drag this URL into Claude Code and it’ll fix everything up. Let Terminator lurk unfinished in my GitHub.

The Problem: Terminal Gets All the Blame

Here’s what happens when you naively use NSAppleScript in a CLI tool:

let script = NSAppleScript(source: "tell application \"Finder\" to activate")
script?.executeAndReturnError(nil)  // Spoiler: This won't work as expected

You’ll either get:

  1. Silent failure (no error, no result, no nothing)
  2. A permission dialog that says “Terminal wants to control Finder/Cursor” (not your cli)
  3. Error -1750 (errOSASystemError) with zero helpful context

The root cause? macOS’s security model requires proper app identification through bundle IDs, code signing, and entitlements. Without these, your CLI tool is just an anonymous process hiding behind Terminal.

The Solution: Give Your CLI an Identity

Step 1: The Magic Info.plist

First revelation: CLI tools can have Info.plist files! Here’s the minimal version that makes everything work:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>CFBundleIdentifier</key>
    <string>com.yourcompany.yourcli</string>
    <key>CFBundleName</key>
    <string>YourCLI</string>
    <key>CFBundleVersion</key>
    <string>1.0.0</string>
    <key>NSAppleEventsUsageDescription</key>
    <string>YourCLI needs to send AppleEvents to control other applications for automation.</string>
</dict>
</plist>

That NSAppleEventsUsageDescription is crucial - it’s what users see in the permission dialog. Make it clear and specific.

Step 2: Embedding the Info.plist (The Secret Sauce)

Here’s where it gets interesting. You need to embed this Info.plist into your binary’s __TEXT/__info_plist section. For Swift Package Manager:

// Package.swift
.executableTarget(
    name: "yourcli",
    dependencies: [/* ... */],
    linkerSettings: [
        .unsafeFlags([
            "-Xlinker", "-sectcreate",
            "-Xlinker", "__TEXT",
            "-Xlinker", "__info_plist",
            "-Xlinker", "Sources/Resources/Info.plist"
        ])
    ]
)

This creates a special section in your binary that macOS reads to identify your app. You can verify it worked:

otool -s __TEXT __info_plist yourcli | xxd -r -p | plutil -p -

Step 3: Entitlements for the Paranoid OS

Create an entitlements file - yes, CLI tools can have these too:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>com.apple.security.automation.apple-events</key>
    <true/>
</dict>
</plist>

Step 4: Code Signing (The Final Boss)

Here’s my battle-tested signing script that handles both development and production:

#!/bin/bash
set -e

BINARY_PATH=".build/release/yourcli"

# Check for Developer ID certificate
if security find-identity -p codesigning -v | grep -q "Developer ID Application"; then
    # Production signing
    SIGNING_IDENTITY=$(security find-identity -p codesigning -v | \
        grep "Developer ID Application" | head -1 | awk '{print $2}')
    
    codesign --force \
        --sign "$SIGNING_IDENTITY" \
        --options runtime \
        --entitlements "yourcli.entitlements" \
        --identifier "com.yourcompany.yourcli" \
        --timestamp \
        "$BINARY_PATH"
else
    # Ad-hoc signing for development
    codesign --force \
        --sign - \
        --entitlements "yourcli.entitlements" \
        --identifier "com.yourcompany.yourcli" \
        "$BINARY_PATH"
fi

The Advanced Stuff: Escaping Cursor’s Shadow

Remember how permission dialogs blame the hosting app (e.g. Cursor when you build an MCP) instead of your app? There’s an undocumented API to fix that: responsibility_spawnattrs_setdisclaim. I discovered this gem from Qt’s excellent blog post about the responsible process problem.

// Bridge the private API
@_silgen_name("responsibility_spawnattrs_setdisclaim")
func responsibility_spawnattrs_setdisclaim(
    _ attr: UnsafeMutablePointer<posix_spawnattr_t?>,
    _ disclaim: Int32
) -> Int32

// Use it to launch a subprocess that owns its permissions
func launchWithOwnPermissions(path: String, arguments: [String]) throws {
    var attr: posix_spawnattr_t?
    posix_spawnattr_init(&attr)
    defer { posix_spawnattr_destroy(&attr) }
    
    // The magic happens here
    responsibility_spawnattrs_setdisclaim(&attr, 1)
    
    var pid: pid_t = 0
    let argv = ([path] + arguments).map { strdup($0) }
    defer { argv.forEach { free($0) } }
    
    let status = posix_spawn(&pid, path, nil, &attr, argv + [nil], environ)
    guard status == 0 else {
        throw POSIXError(POSIXError.Code(rawValue: status) ?? .ENODEV)
    }
}

This makes your CLI tool responsible for its own permissions, not its parent process. The permission dialog will now correctly show “Your CLI wants to control…”

Testing Your Implementation

Reset permissions to test the flow:

tccutil reset AppleEvents com.yourcompany.yourcli

Check your work:

# Verify Info.plist embedding
otool -s __TEXT __info_plist yourcli

# Check entitlements
codesign -d --entitlements - yourcli

# Verify signature
codesign -dv yourcli

The Bottom Line

Making AppleScript work in CLI tools requires:

  1. An embedded Info.plist for identity
  2. Proper entitlements for permissions
  3. Correct code signing
  4. Optional: responsibility_spawnattrs_setdisclaim for cleaner permission dialogs

Building native is hard mode. Every day you learn a new insanity. May your permission dialogs always show the right app name.