abctl Β· abgui
GitOps for Apple Business β now with a native macOS app. abctl keeps your organization's built-in-MDM
Configurations (custom .mobileconfig profiles) and Blueprint membership in sync with a
git-declarative desired state β read-only by default, every write gated, with an archive-on-overwrite audit
trail. abgui is a native SwiftUI desktop app that drives the same engine.
π€ Built by AI
abctl was designed, written, tested, and documented end-to-end by an autonomous AI coding agent
(Anthropic's Claude), directed by Gigaion, LLC. That includes reverse-engineering the Apple Business
API and verifying every behavior live against a real production tenant. Essentially every line β code,
tests, and docs β is AI-authored. Review it with that in mind: it is thoroughly tested and live-verified,
but it is not a substitute for your own judgment before you point it at a production tenant. This is the
same "AI-built, human-directed, openly disclosed" approach Gigaion, LLC uses across its open-source projects.
Status: pre-1.0. Auth + read + plan are live-verified. Config CRUD and blueprint config-membership
GitOps are built, unit-tested, and their core write operations verified live against a production Apple
Business tenant. The abgui desktop app (browse Β· diff/drift Β· gated apply Β· archive rollback) is built
and CI-green on macOS. See HANDOFF.md for exact state and TODO.md for the roadmap.
Why abctl
Apple Business's built-in MDM has no batch/declarative API β only per-resource POST/PATCH/DELETE.
abctl is the reconcile engine: it diffs your git desired state against the live tenant and converges
them, so your MDM profiles and blueprint membership live in version control like any other infrastructure.
- Read-only by default. Every mutating operation is gated behind
--apply + an interactive confirm.
- Bidirectional, newest-wins. Edit in git or the Apple Business console;
sync reconciles both,
with the more-recently-changed side winning a conflict.
- Archive-on-overwrite. Before overwriting or deleting any live config,
abctl downloads and files the
current version to gitops/archive/ β a permanent, greppable record of everything ever replaced.
- Exact drift detection. Apple stores custom profiles byte-for-byte (verified live), so drift is a plain
SHA-256 of the profile XML.
- Safe blueprint GitOps. Declare which profiles each Blueprint carries;
abctl attaches/detaches to
match (detach gated behind --prune), and never touches native/console-only configs it doesn't own.
- Enterprise-grade engineering. Cobra CLI, AGPL-3.0-or-later, race-tested unit +
httptest suite,
golangci-lint clean, gated live integration tests, and a Makefile β Linux/macOS CI.
abgui β native macOS app

abgui puts a native Swift / SwiftUI control plane on top of abctl. It ships as one
self-contained .app with a universal abctl embedded inside it (no separate install, no PATH),
reuses your abctl connection contexts, and re-implements none of the API β it shells out to the embedded
CLI, decodes its JSON, and renders it.
- Connect from the GUI (no shell needed): a Settings window (β,) to enter your Apple Business API
Client ID + EC private key (paste the PEM or point at a
.pem) and save it as an abctl context β
so a Finder-launched app that inherits no AB_* environment still authenticates. The key is stored 0600
and handed to abctl as a file path (never on argv/logs); switch tenants from the same picker.
Credentials live outside the app bundle β the context in ~/.abctl/contexts.yaml and a pasted key
under ~/Library/Application Support/abgui/keys/ β so they survive app updates (replacing abgui.app
doesn't touch them). Only actively deleting those files, or an uninstaller that does, removes them.
- Browse β read-only where the API is: Configurations & Blueprints, plus clearly-badged read-only
views of Devices, Users (with roles), User Groups, Apps (catalog), Packages, MDM Servers, and the audit
log β each with a full-resource inspector, grouped in the sidebar as GitOps vs Read-only. (The separate
VPP content-token Apps & Books screen is disabled β see the built-in-MDM note below.)
- The GitOps hero: a visual 3-way diff / drift view and a gated
sync --apply. abgui defaults
to Git source-of-truth with deletes/detaches enabled, uses smart Apple refresh by default, and exposes
--prune, --limit-writes, refresh mode, and verification mode in the apply sheet.
- Write, gated: create / edit / delete configs and attach / detach blueprint membership β each behind an
in-app confirm (abctl is still invoked with its own
--yes gate and archive-on-overwrite).
- Archive / rollback: browse every pre-overwrite live version abctl archived and restore one in a click.
- Unsigned, zero-cost distribution: an ad-hoc-signed universal app you launch after one
xattr command
β no Apple Developer account. Build it with make gui-app (macOS 14+).
See abgui/README.md and the design plan in docs/abgui-design.md.
The image above is a UI preview. abgui is macOS-only and builds on a macOS runner; like the rest of this
repo it is AI-authored under Gigaion, LLC's direction.
Install & build
Requires Go 1.26+ (the race-tested suite needs a C compiler / CGO).
make build # β bin/abctl (version injected via ldflags)
make test # go test -race ./...
make lint # golangci-lint
./bin/abctl --help
No production credentials are needed to build or test β the unit suite mocks the API with httptest.
Prerequisites (Apple Business)
- Enable built-in ("Included") MDM for your organization in Apple Business β Settings. Without it,
every
/configurations and /blueprints request returns 403 INCLUDED_MDM_NOT_ENABLED β this is an org
capability toggle, not a key permission (verified live).
- Create an API account: Apple Business β Settings β Integrations β API β create an account and
download the private key once (it cannot be re-downloaded). SEC1 or PKCS#8 EC P-256 both work.
- If the account was migrated from ABM/Essentials, grant it Manage Blueprints + Create/edit device
configurations and regenerate the key (otherwise
403).
Users and user groups are read-only via the API (create/edit only in the console or via
federation/SCIM). abctl never writes identity.
Copy the template to a gitignored .env and fill it in:
cp .env.example .env
AB_CLIENT_ID=BUSINESSAPI.<uuid>
AB_PRIVATE_KEY=secrets/key.pem # unencrypted EC P-256 β SEC1 or PKCS#8, abctl reads either
There is no Key ID: the client-assertion JWT deliberately omits kid (a kid β 400 invalid_client),
and aud is the /v2/token form β both handled for you. Verify with:
abctl auth whoami
Quick start (the GitOps loop)
abctl seed # 1. download the live tenant β gitops/ tree + committed baseline
git add gitops/ # review, then commit the tree as your source of truth
# 2. edit gitops/lib/**/*.mobileconfig and gitops/blueprints/*.yml in git
abctl diff # 3. see the 3-way plan (configs + blueprint membership); no writes
abctl sync # 4. same plan (dry-run is the default) β nothing is written
abctl sync --apply # 5. execute it: archive-before-overwrite, confirm before any write
sync --apply runs in two phases β configs first (create/update/pull/delete), then blueprint
membership (so a profile created in step 5 can be attached in the same run). It asks you to type yes
before touching the tenant unless --yes (or $ABCTL_APPROVE=1 for CI) is set.
For a one-way GitOps converge where the repo is authoritative, use:
abctl diff --git-source-of-truth
abctl sync --apply --git-source-of-truth --prune
--git-source-of-truth means live-only Apple configs are deleted instead of pulled into git; on apply it
implies prune in the engine, and spelling --prune keeps the intent obvious. The default live-read behavior
is --refresh=smart: abctl performs a cheap Apple metadata list, reuses cached profile hashes when the Apple
ID and updatedDateTime match the committed baseline, and fetches profile XML only when comparison,
pulling, pruning, or archive-before-overwrite safety requires it.
On-disk layout (gitops/)
gitops/
βββ lib/β¦/<name>.mobileconfig # CUSTOM_SETTING profiles β the desired state, one file per config
βββ blueprints/<slug>.yml # per-blueprint: name, id, and its list of attached config names
βββ state/sync-state.json # COMMITTED baseline: name β {id, hash, updatedDateTime}
βββ archive/<name>/<UTC-ts>--<reason>.mobileconfig (+ .json sidecar) # pre-overwrite live versions
The tree is gitignored by default (seeded profiles can carry secrets); un-ignore it deliberately once
you adopt it as your committed source of truth.
A blueprint manifest is just its desired config membership:
name: Sales Team
id: 1c6f7213-236f-4f5b-837c-71acb16a0b9b
description: field sales macs
configurations:
- wifi-corp.mobileconfig
- vpn-always-on.mobileconfig
abctl attaches configs listed in git but not in ABM, and (only with --prune) detaches configs
attached in ABM but removed from git. Blueprint create/delete and device/user/group membership are managed
in the console; abctl reports but doesn't act on them.
Command reference
abctl has two modes β a GitOps engine and imperative commands β in one binary.
Global flags: -o/--output table|json|yaml, --context <name> (see abctl context). Every write is gated
(confirm unless --yes/$ABCTL_APPROVE); imperative writes also update the local gitops/ tree + baseline
inline (--no-write-tree to skip). Full design: docs/imperative-cli.md.
# read
abctl auth whoami # verify auth + reachability
abctl get configurations|blueprints|devices|audit # + users|usergroups|apps|mdmservers (--filter key=substr)
abctl get configuration <name|id> [--profile] # show one (--profile dumps raw .mobileconfig XML)
# GitOps (declarative, whole-tree)
abctl seed # live tenant β gitops/ tree + baseline
abctl validate # validate lib/ profiles
abctl diff | sync [--exit-on-diff] [--refresh smart|full|metadata-only]
abctl sync --apply [--git-source-of-truth] [--prune] [--yes] [--limit-writes N] [--verify targeted|full|none]
# imperative (one resource at a time)
abctl create config <name> -f profile.mobileconfig # POST a new CUSTOM_SETTING config
abctl replace config <name|id> -f profile.mobileconfig # archive live, then PATCH
abctl edit config <name|id> # fetch β $EDITOR β PATCH on save
abctl delete config <name|id> [--yes] # archive live, then DELETE
abctl apply -f a.yml [-f b.yml] [--dry-run] # upsert abctl/v1 Configuration|Blueprint specs (bulk)
abctl attach|detach config <name> --blueprint <bp> # add/remove a config from a blueprint
abctl attach|detach app <name|id> --blueprint <bp> # built-in-MDM Apps & Books: assign an owned app to a blueprint
abctl pull [config <name>] # adopt a console edit into git (scoped seed)
# status (honest proxies β NOT on-device install verification)
abctl status config <name> # which blueprints carry it + devices targeted
abctl status audit [--since 24h --type β¦ --actor β¦] # config/device change history
# contexts + escape hatch
abctl context set|use|get|list|current # named-connection tenants (~/.abctl/contexts.yaml)
abctl api <path> [-X POST -F k=v --input body.json] # raw request (non-GET is gated)
abctl version | completion | help
Flags that matter: --apply (execute; default is dry-run) Β· --prune (allow deletes/detaches; off by
default) Β· --yes / $ABCTL_APPROVE=1 (skip the confirm, for CI) Β· --limit-writes N (circuit breaker on
tenant writes, shared across configs + blueprints) Β· --exit-on-diff (exit 3 when changes are pending β
for PR gating).
Current sync controls also include --git-source-of-truth (treat gitops/ as authoritative),
--refresh smart|full|metadata-only (smart is the default cheap-list/cache mode), and
--verify targeted|full|none after apply (targeted is the default).
Exit codes: 0 ok Β· 1 error Β· 2 usage Β· 3 changes pending (with --exit-on-diff).
Data β stdout, diagnostics β stderr; --json for machine output.
Safety model
- Read-only by default β
diff and sync (no --apply) never write.
- Every write gated behind
--apply + confirmation; --prune off by default (nothing is deleted or
detached unless you ask).
- Archive-before-overwrite β a failed archive skips the write it protects, so the audit trail is never
bypassed.
- Newest-wins conflicts compare the live
updatedDateTime against the git file's commit time (else its
mtime); an ambiguous case is skipped, never guessed.
- Never commit secrets β
.env, secrets/, keys, tokens, and the generated gitops/ tree are all
gitignored. See SECURITY.md.
CI/CD
Works on both GitHub and GitLab β .github/workflows/ and
.gitlab-ci.yml drive the identical flow (abctl is CI-agnostic: reads AB_* from
the environment, signals via exit codes). Full setup β including the GitLab job map β in
docs/cicd.md.
CI runs build/vet/race-test on Linux + macOS, golangci-lint, and gated live integration tests
(read-only + a strictly-gated write round-trip) that self-skip without secrets.
CD is three GitOps pipelines (also self-skipping); the GitHub workflows:
| Workflow |
Trigger |
Does |
Plan (cd-plan.yml) |
PR touching gitops/** |
sync --dry-run β posts the plan as a PR comment (no writes) |
Apply (cd-apply.yml) |
merge to main (or manual) |
gated sync --apply behind a protected production environment β reconciles + commits the baseline back |
Drift (cd-drift.yml) |
daily cron |
sync --dry-run --exit-on-diff β alerts if the console drifted from git |
On GitLab the same three run as the plan / apply / drift jobs in .gitlab-ci.yml (MR / manual on a
protected production environment / pipeline schedule). In CI either way, abctl reads config from the
environment (AB_CLIENT_ID + AB_PRIVATE_KEY) β no .env needed.
Run it locally too. scripts/pipeline.sh drives the identical stages on your
own machine β no CI required β so you can plan/apply/drift against the current branch by hand:
./scripts/pipeline.sh ci # build + gofmt + vet + test (+ lint) β no secrets
./scripts/pipeline.sh plan # READ-ONLY: what `apply` would change
./scripts/pipeline.sh drift # READ-ONLY: exit 3 if git and the tenant diverge
./scripts/pipeline.sh apply # LIVE, gated writes (--commit to mirror CI's baseline commit-back)
It's the same logic the workflows run, so local and CI never disagree.
Verified API facts (from live testing β trust these)
- Auth omits
kid; aud = β¦/oauth2/v2/token; bearer TTL 60 min.
- Only
CUSTOM_SETTING configs are API-writable; their profile XML is raw (not base64) and GET
round-trips it byte-identically β drift = raw SHA-256. POST 201 / PATCH 200 / DELETE 204.
- The API validates uploads: a malformed profile (e.g. empty
PayloadContent) β 400 PARAMETER_ERROR.
- A Blueprint create requires both a member (device/user/group) and content (app/package/config).
- Blueprint membership
POST is additive (merges); DELETE-with-body removes a specific member β so
abctl converges membership per-member.
- Users/userGroups are read-only via the API; built-in MDM must be enabled in the console.
- The API rate-limits hard β
abctl backs off (Retry-After aware); avoid rapid loops.
Docs
License
AGPL-3.0-or-later β Copyright Β© 2026 Gigaion, LLC.