Skip to content

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 (via CLAUDE.md): stack (Swift 6, SwiftUI, SwiftPM, macOS 15+), workflow (plan in tasks/todo.md → test-first Core → verify → PR → PROJECT-CONTEXT.md), publishing rules (private <name>-dev repo + clean public <name> repo), Panel vs Settings window split.
  • .claude/skills/: our own skills MacAppArchitecture, 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 of rohi.network: Wi-Fi, IP settings via networksetup + admin osascript) 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:

  1. CLI + --json: works with any agent that has a shell. macctl list --json is the discovery call; describe gives the value schema so the agent never guesses a defaults key.
  2. MCP server (macctl mcp): tools macctl_list, macctl_describe, macctl_get, macctl_set, macctl_diff, macctl_apply, macctl_undo, with JSON Schemas generated from the registry. Added to .mcp.json like the existing xcode server. Risk is in each tool's description and annotations (readOnlyHint, destructiveHint) so clients can ask the human.
  3. A skill MacSetup in .claude/skills/: when to use it, "always diff before apply", "never use script run for something the registry covers", and how to report doctor results 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 macctl from Terminal, iTerm, or the Claude app, the prompt says "Terminal wants to control System Events", and the grant goes to Terminal, not to macctl. 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 with open -g. Prompts then name "macctl", grants survive rebuilds (designated-requirement signing, already in our Gotchas), and Info.plist carries NSAppleEventsUsageDescription.
  • Check without prompting. doctor uses AEDeterminePermissionToAutomateTarget(…, askUserIfNeeded: false) for Automation, AXIsProcessTrusted() for Accessibility, and a read probe for Full Disk Access. Missing permission → exit code 2 + the exact x-apple.systempreferences: link, never a surprise prompt in the middle of an apply.
  • 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 tccutil tricks beyond reset for testing.
  • Prefer backends that need no TCC. Most defaults writes 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.

  1. Scaffold mac-ctl-dev (SwiftPM, MacCtlCore + MacCtlCLI + tests), PROJECT-CONTEXT.md.
  2. Core, test-first with a fake Runner: registry, Setting, value parsing/validation, profile diff, snapshot store, restart batching.
  3. Backends: defaults (bool/int/float/string, -g global domain), killall restarts, AppleScript runner with argv-only input (+ injection test).
  4. 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).
  5. Commands: doctor, list, describe, get, set, diff, apply, snapshot list, undo; --json, --dry-run, exit codes.
  6. Sample profile + MacSetup skill in this workspace.
  7. 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.app for TCC identity, wallpaper, login items, hot corners, accent color, theme apply.
  • M3 Whole machine: admin tier (hostname, firewall, pmset), brew bundle integration 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-parser is 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 in plugins.md, using the existing publish and release tools.
  • Could not verify any defaults key 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 in Mapping.md.