Historical archive
This page records a dated plan, audit, or research context, not a current product contract. Project status distinguishes current source behavior, published releases, and unfinished acceptance gates.
Plan: macctl, a macOS setup CLI for AI agents¶
Status: draft for Rohi's review (2026-10-16). Nothing built yet.
Working name macctl is a placeholder; rename freely.
1. What the repo is today¶
RohiRIK/macos-plugins is a private workspace, not an app. It holds the shared know-how for building
small native macOS apps ("plugins"), most of them ports of Omarchy/Linux bar widgets:
AGENTS.md(viaCLAUDE.md): stack (Swift 6, SwiftUI, SwiftPM, macOS 15+), workflow (plan intasks/todo.md→ test-first Core → verify → PR →PROJECT-CONTEXT.md), publishing rules (private<name>-devrepo + clean public<name>repo), Panel vs Settings window split..claude/skills/: our own skillsMacAppArchitecture,MacAppBuild,MacUX,PortLinuxWidget, plus two vendored MIT skills (swiftui-expert-skill,swift-concurrency-pro).tools/:xcode-mcp.ts(Xcode MCP client),render-previews.sh,publish-public.sh(strips private data before a public snapshot),release.sh(universal zips to GitHub Releases).plugins.md: two shipped plugins, each in its own repo: mac-network (port ofrohi.network: Wi-Fi, IP settings vianetworksetup+ adminosascript) and mac-calendar (EventKit calendar, Hebrew dates, hotkey).tasks/: plans and lessons from corrections.
Already-learned lessons that matter directly for this CLI:
- MacAppArchitecture/System.md: prefer native framework → built-in CLI tool → AppleScript/JXA →
Homebrew → privileged helper. Absolute tool paths, argv arrays, injected Runner for tests.
- Admin actions: one prompt via osascript … with administrator privileges, values passed as argv after
-- with quoted form of, never spliced into script text; -128 = user cancelled.
- TCC grants are tied to the code signature; ad-hoc rebuilds lose them unless signed with a fixed
designated requirement (MacAppBuild Gotchas).
- PortLinuxWidget/Mapping.md: verified Linux→macOS command mappings and quirks.
2. Goal¶
One command-line tool that an AI agent (Claude Code, Codex, any MCP client) can call to read, change, and roll back macOS configuration natively, and to apply an opinionated whole-machine profile, the way Omarchy sets up a whole Arch system from scripts.
| Omarchy idea | macctl equivalent |
|---|---|
omarchy-* scripts in bin/ |
one binary with subcommands, every action in a typed registry |
install/*.sh steps run in order |
a profile (JSON): desired state, applied idempotently |
| migrations | profile version + macctl apply only changes what differs |
themes (omarchy-theme-set) |
macctl theme apply <name>: appearance, accent, wallpaper, dock |
omarchy-refresh-* |
macctl apply --only <domain> |
Difference on purpose: Omarchy scripts are imperative. Agents need inspectable, reversible steps, so every action declares how to read the current value, set it, verify it, and undo it.
3. Architecture¶
Swift package, same shape as our plugins (MacAppBuild/Scaffold.md), new repo pair mac-ctl-dev /
mac-ctl, bundle id local.macplugins.macctl.
MacCtlCore registry, Action protocol, backends, snapshot store, profile diff (no I/O in tests)
MacCtlCLI argument parsing, --json output, exit codes → `macctl` binary
MacCtlMCP stdio MCP server over the same registry → `macctl mcp`
MacCtlHelper tiny LSUIElement .app that owns the TCC identity (milestone 2)
The Action (the one core abstraction)¶
struct Setting: Sendable {
let id: String // "dock.autohide"
let summary: String // one line, for agents
let type: ValueType // bool, int, double, enum([...]), string, path
let risk: Risk // .read, .user, .admin, .destructive
let permissions: [Permission] // .automation("com.apple.systemevents"), .accessibility, .fullDiskAccess
let backend: Backend // how to get/set
let after: [SideEffect] // .restart("Dock"), .logout, .none
let verifiedOn: [String] // macOS versions a human checked it on
}
Backends, in the repo's preference order:
1. Native API in-process: NSWorkspace.setDesktopImageURL (wallpaper), SMAppService, CFPreferences.
2. Built-in tools via Process: defaults, pmset, scutil, networksetup, systemsetup,
killall, shortcuts, osascript.
3. AppleScript / JXA in-process (NSAppleScript / OSAScript) or osascript -l JavaScript, always
as a fixed script with an on run argv handler; values only ever arrive as argv.
4. Shortcuts: shortcuts run <name> --input-path … for things only Shortcuts can do (Focus modes).
5. Homebrew only if installed: detect, report, never auto-install brew itself.
6. Admin: the existing osascript … with administrator privileges pattern. One prompt per apply.
Facts the backends must handle (all to re-verify on a real Mac before an action ships):
- Write through defaults / CFPreferences, never edit plist files (cfprefsd caches them).
- Many prefs need killall Dock|Finder|SystemUIServer or a logout; the action declares it and apply
batches restarts once at the end.
- Some domains are sandbox containers (Safari, Mail) and need Full Disk Access to write.
- Some System Settings toggles have no working defaults key on macOS 15+; those either use
AppleScript/System Events or are marked unsupported. No GUI clicking in milestone 1.
State, snapshots, undo¶
Before any change, macctl records the old value of each touched setting to
~/Library/Application Support/macctl/snapshots/<timestamp>.json. macctl undo restores the last
snapshot (or a given id). Actions without a readable old value cannot be --yes'd by an agent.
4. Command surface¶
Every command supports --json (stable schema, the default when stdout is not a TTY) and returns
meaningful exit codes: 0 ok, 1 error, 2 needs permission, 3 needs confirmation, 4 unsupported on this OS.
macctl doctor OS version, which permissions are granted, what to fix + Settings deep link
macctl list [--domain dock] catalog: id, summary, type, risk, current value
macctl describe dock.autohide full schema for one setting (agents read this before set)
macctl get dock.autohide [...] current values
macctl set dock.autohide true change one (snapshot first); --dry-run shows the plan
macctl diff profile.json what apply would change, nothing written
macctl apply profile.json idempotent desired state; --only <domain>; --dry-run
macctl snapshot [list|show <id>]
macctl undo [<id>]
macctl theme apply <name> bundle of settings (appearance, accent, wallpaper, dock)
macctl shortcut run <name> wrapper over `shortcuts run`, JSON result
macctl script run <file> [args…] raw AppleScript/JXA escape hatch, OFF unless enabled in config
macctl mcp MCP server (stdio) exposing list/describe/get/set/diff/apply/undo
Example profile (opinionated, Omarchy-style defaults; we ship one, users fork it):
{
"name": "rohi-default",
"version": 1,
"settings": {
"appearance.mode": "dark",
"dock.autohide": true,
"dock.tilesize": 40,
"dock.show-recents": false,
"finder.show-extensions": true,
"finder.show-pathbar": true,
"keyboard.key-repeat": 2,
"keyboard.initial-key-repeat": 15,
"screenshots.location": "~/Pictures/Screenshots",
"screenshots.format": "png"
}
}
5. How agents discover and call it¶
Three layers, same registry underneath, so they never drift:
- CLI +
--json: works with any agent that has a shell.macctl list --jsonis the discovery call;describegives the value schema so the agent never guesses adefaultskey. - MCP server (
macctl mcp): toolsmacctl_list,macctl_describe,macctl_get,macctl_set,macctl_diff,macctl_apply,macctl_undo, with JSON Schemas generated from the registry. Added to.mcp.jsonlike the existingxcodeserver. Risk is in each tool's description and annotations (readOnlyHint,destructiveHint) so clients can ask the human. - A skill
MacSetupin.claude/skills/: when to use it, "alwaysdiffbeforeapply", "never usescript runfor something the registry covers", and how to reportdoctorresults to the user.
Agents should not call defaults/osascript directly for things macctl covers. That is the whole
point: one audited path with snapshots, instead of ad-hoc shell lines.
6. Safety and permissions¶
Risk tiers¶
| Tier | Examples | Agent behaviour |
|---|---|---|
read |
get, list, diff, doctor |
always allowed |
user |
dock, finder, keyboard, appearance | allowed; snapshot first; listed in result |
admin |
hostname, firewall, pmset, power | needs --yes; macOS shows the password prompt itself |
destructive |
deleting files, resetting TCC, removing login items without snapshot | CLI refuses without --confirm <token> that diff prints; MCP marks destructiveHint |
Hard rules: no secrets on argv (stdin only); no value spliced into AppleScript/shell text (argv + quoted
form of, with the injection test from mac-network); raw script run disabled by default; every apply
writes a log line to ~/Library/Logs/macctl.log (and os_log subsystem local.macplugins.macctl).
TCC (the hard part)¶
- Who gets the prompt. macOS attributes Apple Events, Accessibility, and Full Disk Access to the
responsible process: when an agent runs
macctlfrom Terminal, iTerm, or the Claude app, the prompt says "Terminal wants to control System Events", and the grant goes to Terminal, not tomacctl. That works, but it grants the whole terminal and differs per host app. - Fix (milestone 2):
MacCtlHelper.app. A tiny signed, LSUIElement app with a fixed bundle id runs the AppleScript/AX work. The CLI talks to it over XPC or by launching it withopen -g. Prompts then name "macctl", grants survive rebuilds (designated-requirement signing, already in our Gotchas), and Info.plist carriesNSAppleEventsUsageDescription. - Check without prompting.
doctorusesAEDeterminePermissionToAutomateTarget(…, askUserIfNeeded: false)for Automation,AXIsProcessTrusted()for Accessibility, and a read probe for Full Disk Access. Missing permission → exit code 2 + the exactx-apple.systempreferences:link, never a surprise prompt in the middle of anapply. - Cannot be scripted away. TCC grants cannot be given programmatically (only MDM PPPC profiles do
that on managed Macs). The CLI says so; it never tries
tccutiltricks beyondresetfor testing. - Prefer backends that need no TCC. Most
defaultswrites need none. Milestone 1 sticks to those, plus System Events for dark mode (one Automation grant).
7. Milestone 1 (first PR)¶
Goal: an agent can run macctl doctor, list, diff, apply, undo against a real Mac with the sample
profile, end to end, with JSON output.
- Scaffold
mac-ctl-dev(SwiftPM,MacCtlCore+MacCtlCLI+ tests),PROJECT-CONTEXT.md. - Core, test-first with a fake
Runner: registry,Setting, value parsing/validation, profile diff, snapshot store, restart batching. - Backends:
defaults(bool/int/float/string,-gglobal domain),killallrestarts, AppleScript runner with argv-only input (+ injection test). - About 15 settings, each verified on a real Mac and stamped
verifiedOn: dock (autohide, tilesize, show-recents, orientation, minimize effect), finder (extensions, pathbar, statusbar, default view), keyboard (key repeat, initial repeat, press-and-hold), screenshots (location, format), appearance (dark mode via System Events). - Commands:
doctor,list,describe,get,set,diff,apply,snapshot list,undo;--json,--dry-run, exit codes. - Sample profile +
MacSetupskill in this workspace. - Verify on the Mac: apply profile, check Dock/Finder changed,
undo, check restored. Then PR + report.
Explicitly not in milestone 1: MCP server, helper app, admin actions, Homebrew, Shortcuts, themes, wallpaper, login items, GUI scripting.
8. Later milestones¶
- M2 Agent-native:
macctl mcp,MacCtlHelper.appfor TCC identity, wallpaper, login items, hot corners, accent color,theme apply. - M3 Whole machine: admin tier (hostname, firewall, pmset),
brew bundleintegration for apps, Shortcuts wrapper, default-app handlers, "first boot" profile that takes a new Mac to Rohi's setup. - M4 UI: a mac-plugins menu bar app showing the current profile drift and one-click undo (follows the Panel vs Settings window rule).
9. Decisions taken by default (cheap to change)¶
- Swift binary, not shell scripts. Matches the repo's stack and lessons, gives typed values, in-process AppleScript, and real TCC identity later. Shell scripts would be faster to start but can't own a permission identity or a schema.
swift-argument-parseris the one dependency (Apple-maintained). Approved by Rohi on 2026-10-16 as an exception to the AGENTS.md no-dependency rule.- JSON profiles, not TOML/YAML (Foundation decodes JSON; no dependency).
- Separate repo pair
mac-ctl-dev/mac-ctl, listed inplugins.md, using the existing publish and release tools. - Could not verify any
defaultskey here (this plan was written on Linux); every setting is verified on a real Mac before it ships, per the "verify a row on a real Mac" rule inMapping.md.