From ba7cc775e4e04216143c8b4b466409d46362e3c2 Mon Sep 17 00:00:00 2001 From: Alan Norton Date: Tue, 25 Aug 2026 10:02:45 -0700 Subject: [PATCH 01/26] Design: declarative macOS defaults with drift detection Records the design for issue #8. A read-only probe of all 217 `defaults write` lines in .macos against this machine found four settings already untrue, including mouse acceleration being on when .macos disables it. Key constraints the probe surfaced: - 40 rows (Safari, Mail) live in TCC-protected containers and cannot be audited from a shell without Full Disk Access, which CI can never have. - Two keys contain spaces, so the table must be tab-delimited rather than whitespace-columned like `manifest`. - Bash treats tab as IFS whitespace, so `IFS=$'\t' read` collapses empty columns and shifts every field left. The probe hit this and reported 215 of 217 keys missing before the bug was found. - Root-owned /Library/Preferences plists are world-readable, so audit never needs sudo. Only apply does. Co-Authored-By: Claude Opus 5 (1M context) --- ...08-25-macos-defaults-declarative-design.md | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-25-macos-defaults-declarative-design.md diff --git a/docs/superpowers/specs/2026-08-25-macos-defaults-declarative-design.md b/docs/superpowers/specs/2026-08-25-macos-defaults-declarative-design.md new file mode 100644 index 00000000..cbb090fc --- /dev/null +++ b/docs/superpowers/specs/2026-08-25-macos-defaults-declarative-design.md @@ -0,0 +1,194 @@ +# Declarative macOS Defaults — Design + +**Issue:** https://github.com/nonrational/dotfiles/issues/8 \ +**Date:** 2026-08-25 \ +**Kind:** architectural + +## Problem + +`.macos` is a one-way imperative script. It writes 217 `defaults write` lines and keeps no record of desired state, so nothing detects when macOS or an application rewrites a setting after an OS update, a manual change in System Settings, or an app's own housekeeping. + +A read-only probe of all 217 lines against this machine (nyx, macOS 26) found four settings already untrue: + +| Row | `.macos` declares | Live | +|---|---|---| +| `NSGlobalDomain AppleLocale` | `en_US@currency=USD` | `en_US@currency=usd` | +| `com.apple.ActivityMonitor ShowCategory` | `0` | `100` | +| `com.apple.ActivityMonitor OpenMainWindow` | `true` | `0` | +| `.GlobalPreferences com.apple.mouse.scaling` | `-1` | `3` | + +The last one matters: `.macos` disables mouse acceleration and the machine has it on. Nothing would ever have reported that. + +## Goal + +**Drift detection is the primary job.** A `make macos-audit` that tells you what your machine changed out from under you, and that you trust enough to act on. Apply and accept exist to resolve what audit reports, not as ends in themselves. + +Success looks like: an audit that exits zero on a converged machine, reports genuine drift when it happens, and never cries wolf. An audit you learn to ignore has failed. + +## Probe findings + +Every `defaults write` in `.macos`, parsed with a shell shim and compared against live values. Read-only. + +| Bucket | Count | Meaning | +|---|---|---| +| Auditable, matches live | 153 | The healthy core | +| Genuine drift | 4 | Listed above | +| TCC-blocked | 40 | Safari 35, Mail 5 | +| Actually unset | 9 | Key absent from a readable domain | +| Genuinely complex | 11 | `array` / `dict` / `dict-add` / `date` | + +Four of the complex rows (`com.apple.mail NSUserKeyEquivalents`, `DraftsViewerAttributes` ×3) are blocked by TCC *and* by their container types. They are marked `noaudit=complex`, because that is the binding constraint: granting Full Disk Access would still leave them uncomparable. + +### TCC is a hard constraint + +`~/Library/Containers/com.apple.Safari/Data/Library/Preferences/com.apple.Safari.plist` exists and is written regularly, but `ls` on that directory returns `Operation not permitted` and `defaults read com.apple.Safari` reports the domain does not exist. That is TCC, not a missing key. Those 40 rows cannot be audited from a shell without granting Full Disk Access to the terminal, and CI can never have it. + +Granting FDA was considered and rejected: it makes audit results depend on a machine-configuration step this repo cannot enforce or verify. + +### Root-owned domains need no sudo to read + +`/Library/Preferences/com.apple.loginwindow.plist` is `-rw-r--r-- root wheel`. Audit reads it fine. **Only `apply` ever needs sudo**, which is what makes `make macos-audit` cheap enough to run casually. + +## The table + +`macos-defaults` at the repo root, beside `manifest`. Tab-delimited, four or five columns. + +``` +# domain key type value status +NSGlobalDomain NSWindowResizeTime float 0.001 +com.apple.print.PrintingPrefs Quit When Finished bool true +currentHost:com.apple.ImageCapture disableHotPlug bool true +/Library/Preferences/com.apple.loginwindow showInputMenu bool true +com.apple.Safari AlwaysRestoreSessionAtLaunch bool true noaudit=tcc +``` + +- **domain** — a bundle id, `NSGlobalDomain`, `.GlobalPreferences`, an absolute plist path, or a `currentHost:` prefix (two rows use it). +- **key** — verbatim; may contain spaces. `com.apple.print.PrintingPrefs "Quit When Finished"` and `com.apple.BluetoothAudioAgent "Apple Bitpool Min (editable)"` are why this file is tab-delimited rather than whitespace-columned like `manifest`. +- **type** — `bool`, `int`, `float`, `string`, `raw` (written with no type flag), plus `array`, `dict`, `dict-add`, `date` on `noaudit=complex` rows. +- **value** — human form. Booleans read `true`/`false` in the file; normalization happens at compare time. +- **status** — omitted on the 157 live rows. Otherwise `noaudit=tcc`, `noaudit=unset`, or `noaudit=complex`. The parser also accepts `os=` and `host=`, unused today, so a second Mac needs no format change. + +### Why `noaudit=` and not `skip=` + +Those 60 rows still have to be *written* on a fresh Mac, or `.macos` cannot retire. Only the *audit* cannot check them. `noaudit=` means apply writes the row and audit does not check it, uniformly across all three reasons. + +### Parsing rules + +Three of these are deliberate departures from `deploy.sh`. + +1. **A line is a comment only if its first non-whitespace character is `#`.** `deploy.sh` uses `${line%%#*}`, which would eat a `#` inside a value. No value contains one today; the parser should not depend on that staying true. +2. **Split tabs by parameter expansion, never `IFS=$'\t' read`.** Bash treats tab as IFS *whitespace*, so it collapses runs of tabs and drops a leading one. An empty column silently shifts every field left and the run reports plausible nonsense. The probe that produced this design hit exactly this and reported 215 of 217 keys missing before the bug was found. +3. **Four or five fields, nothing else, or a hard error naming the line — and nothing runs.** Only the trailing `status` field may be omitted, so there is no shift risk. It is omitted rather than left empty because `.editorconfig` sets `trim_trailing_whitespace = true` and a five-field row with an empty status would end in a tab. +4. Validate the whole file before acting on any row, matching `deploy.sh`. + +Comments above rows carry the *why* over from `.macos`, all 44 section banners included. That commentary is the most valuable content in the file. + +## The applier + +`scripts/macos-defaults.sh audit|apply|accept [--dry-run]` + +### audit + +Read-only always; never invokes sudo. One line per row: + +| Outcome | Line | +|---|---| +| Live value matches | `ok: ` | +| Live value differs | `drift: want=X live=Y` | +| Key absent | `missing: ` | +| `noaudit=` row | `skip: ()` | + +Exits non-zero on any `drift` or `missing`. Skips never affect the exit code. + +`missing` is reported separately from `drift` on purpose. Conflating them is what made the first probe run unreadable. + +### apply + +Writes only rows whose live value differs. `noaudit=` rows are always written, since audit cannot tell whether they need it. + +`sudo` is used only when the target plist exists and is not writable by the current user, decided at apply time. Inferring it from the path shape would break the test sandbox, which uses absolute-path domains under a temp directory. + +`--dry-run` prints the same decisions prefixed `would:` and touches nothing. + +### accept + +`accept [domain key ...]` rewrites the table's `value` and `type` from what is live. With no arguments it takes every drifting row. This is also the day-one seeding tool, so seeding and blessing share one code path. + +A row marked `noaudit=unset` that has since become readable is promoted to a live row and the marker cleared. + +### Comparison rules + +The naive `[ "$want" = "$live" ]` is wrong in four ways: + +- **bool** normalizes both sides: `{true,TRUE,YES,yes,1}` → 1, `{false,FALSE,NO,no,0}` → 0. +- **string** and **raw** compare byte-exact. `AppleLocale`'s `USD` versus `usd` is real drift macOS created; `accept` blesses it once and it stays green. +- **Type drift is drift.** `defaults read-type` disagreeing with the table's `type` is reported. +- **A missing key is not a mismatched key.** `defaults read` exiting non-zero yields `missing`. + +### Applying complex rows + +The 11 `array`/`dict`/`dict-add`/`date` rows cannot express their value as a `type` + `value` pair. Those rows hold the literal argument tail, and the applier runs `eval defaults write "$domain" "$key" -dict-add $value`. Scalar rows never touch `eval`. + +This is the same trust level as `sh .macos` — a shell script from this repo, run deliberately by its owner — and the alternative is keeping a second writer alive forever. + +## Migration + +`scripts/migrate-macos-defaults.sh` generates the table by parsing `.macos` with a `defaults` shell shim. Committed rather than run and discarded, following the `scripts/migrate-to-home.sh` precedent, so the transcription is reviewable rather than trusted. + +**Verification:** re-parse `.macos` at its pre-change commit, re-parse the generated table, and diff the `domain/key/type/value` sets. They must be identical except for rows explicitly accepted. That is a one-command proof that nothing was dropped across 217 lines. + +Seeding fills values from live. The four drifting rows are held back as a decision rather than auto-accepted: + +| Row | Decision | +|---|---| +| `NSGlobalDomain AppleLocale` | accept — macOS canonicalized the currency code | +| `com.apple.ActivityMonitor ShowCategory` | accept — `100` is what a current Activity Monitor writes for the "all processes" view the comment asks for; confirm against the app | +| `com.apple.ActivityMonitor OpenMainWindow` | accept. If it drifts back, the app rewrites it on quit and the row wants a `noaudit=` marker | +| `.GlobalPreferences com.apple.mouse.scaling` | **reapply** — the table wins; mouse acceleration should be off | + +The 9 unset rows enter as `noaudit=unset` so the baseline is green: `helpviewer DevMode`, `addressbook ABShowDebugMenu`, `TextEdit` ×3, `QuickTimePlayerX MGPlayMovieOnOpen`, `Siri` ×2, `GameCenter GKInviteAlertEnabled`. + +## What `.macos` keeps + +All 217 `defaults write` lines leave. Roughly 45 lines remain: the System Settings quit, the sudo keepalive, `nvram SystemAudioVolume`, `systemsetup -settimezone`, the nine `PlistBuddy` Finder-view calls, both `chflags`, `lsregister`, the Dock `find -delete`, `tmutil disable`, the closing `killall` loop, and the final echo. + +It keeps its filename and gains a header comment pointing at `macos-defaults`. Nothing is symlinked to it — it has no `manifest` entry — so a later rename to `scripts/macos-imperative.sh` costs nothing but muscle memory. + +## Make targets + +```text +macos-audit -> ./scripts/macos-defaults.sh audit +macos-apply -> ./scripts/macos-defaults.sh apply +macos-accept -> ./scripts/macos-defaults.sh accept +macos -> apply the table, then sh .macos, then the restart osascript +``` + +`make macos` keeps its current behavior, restart included. `make macos-audit` is the one meant for casual use, and it needs no sudo. + +`make check-macos-defaults` validates that the table parses, joining the existing `check-*` family so a malformed row fails in review rather than on the next `make macos`. `make preflight` already aggregates `test` plus every `check-*` target and CI calls `preflight`, so this needs no edit to `ci.yml`. + +## Tests + +`test/test_macos_defaults.sh`, sandboxed the way `test_deploy.sh` is. + +**The sandbox works because `defaults` accepts an absolute plist path as a domain.** Tests point at domains under `mktemp -d` and never touch a real preference. + +- Parser tests run on any platform: field counts, an omitted status column (the tab trap), `#` inside a value, line-numbered errors on malformed rows, exit codes. +- `defaults`-backed tests run only on Darwin and print a skip line elsewhere. +- Added to `make test`, so both CI legs pick it up. + +`.editorconfig` gains a `[macos-defaults]` stanza documenting that the tabs are data separators rather than indentation. + +## Open risk + +**Whether `defaults write` to a TCC-blocked container domain still succeeds is unverified.** Reads definitely fail. If writes fail too, those 35 Safari settings have not been applied on a fresh Mac in years and `.macos` has been quietly lying about them. + +Finding out requires a real write to a real Safari preference. It belongs in the implementation plan as an explicit, owner-approved step. + +## Out of scope + +- Comparing `array` and `dict` values. Normalizing plist container output is a larger job than drift detection warrants; those rows stay `noaudit=complex`. +- Granting the terminal Full Disk Access to recover the Safari and Mail rows. +- Per-host rows. The `os=` and `host=` vocabulary parses but no row uses it. +- Moving the imperative tail (`PlistBuddy`, `nvram`, `chflags`, `systemsetup`) into any declarative form. +- Renaming `.macos`. From 2cfbc24072040227a0e7acc476894e7e5842b127 Mon Sep 17 00:00:00 2001 From: Alan Norton Date: Tue, 25 Aug 2026 11:22:51 -0700 Subject: [PATCH 02/26] Plan: implement declarative macOS defaults Seven tasks, each with its own tests and commit. Tasks 1-4 build the parser and the check/audit/apply/accept modes against a sandbox that points `defaults` at absolute plist paths under mktemp, so nothing touches a real preference. Task 5 generates the 217-row table from .macos, Task 6 seeds it from this machine, Task 7 strips .macos and wires the Makefile. Two deliberate deviations from the spec, both noted in the tasks that make them: os=/host= conditions are implemented rather than merely parsed, since a status the parser accepts and then ignores would apply an os=Linux row on Darwin; and the accept filter is [domain [key]] rather than a list of pairs, because expanding an empty array under set -u is an error in bash 3.2. All 47 bash blocks pass bash -n under 3.2, and Task 1's script passes its own 13 tests verbatim. Co-Authored-By: Claude Opus 5 (1M context) --- .../2026-08-25-macos-defaults-declarative.md | 1773 +++++++++++++++++ 1 file changed, 1773 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-25-macos-defaults-declarative.md diff --git a/docs/superpowers/plans/2026-08-25-macos-defaults-declarative.md b/docs/superpowers/plans/2026-08-25-macos-defaults-declarative.md new file mode 100644 index 00000000..b011f75a --- /dev/null +++ b/docs/superpowers/plans/2026-08-25-macos-defaults-declarative.md @@ -0,0 +1,1773 @@ +# Declarative macOS Defaults Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the 217 `defaults write` lines in `.macos` with a reviewable table and a script that reports when macOS has drifted away from it. + +**Architecture:** A tab-delimited table (`macos-defaults`) holds one row per setting. `scripts/macos-defaults.sh` reads it in four modes: `check` validates the file, `audit` compares each row against the live machine, `apply` writes rows that differ, and `accept` rewrites rows to match what is live. Rows that cannot be compared (TCC-protected containers, unset keys, `array`/`dict` values) carry a `noaudit=` marker: they are still written by `apply`, but `audit` reports them as `skip` and they never affect the exit code. + +**Tech Stack:** bash 3.2 (macOS `/bin/bash`), `defaults(1)`, GNU make. No external dependencies, matching `deploy.sh`. + +**Spec:** `docs/superpowers/specs/2026-08-25-macos-defaults-declarative-design.md` + +## Global Constraints + +- **bash 3.2.** `/bin/bash` on macOS is 3.2.57. No associative arrays, no `${var,,}`, no `mapfile`. Expanding an empty array under `set -u` is an error, so avoid empty-array expansion entirely. +- **`set -euf -o pipefail`** at the top of every script, matching `deploy.sh`. Note this enables `nounset`. A bare `[ cond ] && assign` as a standalone statement fires `errexit` when the test fails; use `if` blocks. +- **Validate the whole table before acting on any row.** Same posture as `deploy.sh`: a malformed row means nothing runs. +- **Comments must be a comment only when the first non-whitespace character is `#`.** Do not copy `deploy.sh`'s `${line%%#*}`, which would truncate a value containing `#`. +- **Never split table rows with `IFS=$'\t' read`.** Bash treats tab as IFS *whitespace*, so it collapses runs of tabs and drops a leading one, shifting every field left. Split with parameter expansion. +- **Tests never touch real preferences or the real `$HOME`.** Everything lives under `mktemp -d`. `defaults` accepts an absolute plist path as a domain, which is what makes this possible. +- **`.editorconfig` applies:** LF endings, final newline, no trailing whitespace, UTF-8. `indent_style = space` for `[*]`, so scripts indent with 4 spaces like `deploy.sh`. +- **`make preflight`** (`test` plus every `check-*` target) is the gate. CI calls it directly, so a new `check-*` target needs no `ci.yml` edit. +- **Commit messages carry no Conventional Commit prefixes.** Plain descriptive subject lines. +- **This is a public repo.** No private hostnames, repo names, or identifiers in any committed file. + +## File Structure + +| File | Responsibility | +|---|---| +| `macos-defaults` (create) | The table. One row per setting; `#` comments carry the *why* over from `.macos`. | +| `scripts/macos-defaults.sh` (create) | Parser plus the four modes. The only thing that knows the table format. | +| `scripts/migrate-macos-defaults.sh` (create) | One-shot generator: parses `.macos` into the table. Committed so the transcription is reviewable. | +| `test/test_macos_defaults.sh` (create) | Parser tests run everywhere; `defaults`-backed tests are Darwin-gated. | +| `.macos` (modify) | Loses all 217 `defaults write` lines; keeps the imperative tail. | +| `Makefile` (modify) | `macos-audit`, `macos-apply`, `macos-accept`, `check-macos-defaults`; `macos` gains the apply step. | +| `.editorconfig` (modify) | A `[macos-defaults]` stanza declaring the tabs as data separators. | +| `CLAUDE.md` (modify) | Commands and Architecture entries for the new table. | + +--- + +### Task 1: Table parser and `check` mode + +Produces a script that can read and validate the table but does nothing to the machine. Everything here runs on Linux and macOS alike, so the whole task is testable in CI on both legs. + +**Files:** +- Create: `scripts/macos-defaults.sh` +- Create: `test/test_macos_defaults.sh` + +**Interfaces:** +- Consumes: nothing. +- Produces: `scripts/macos-defaults.sh` honoring `MACOS_DEFAULTS_TABLE` (absolute path to the table; defaults to `$DOTS/macos-defaults`). Modes `check|audit|apply|accept`, flag `--dry-run`, optional positional `domain [key]` filter. Parsed rows land in the globals `t_domain`, `t_key`, `t_type`, `t_value`, `t_status` (parallel indexed arrays) via `parse_table`. Helper `split_row "$line"` fills the global array `ROW`. Later tasks add `audit_row`, `apply_row`, `run_accept`. + +- [ ] **Step 1: Write the failing test file** + +Create `test/test_macos_defaults.sh`: + +```bash +#!/bin/bash +# Tests for scripts/macos-defaults.sh. Table parsing runs on any platform; +# anything that shells out to `defaults` is Darwin-only and skipped elsewhere. +set -euf -o pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BASE="$(mktemp -d "${TMPDIR:-/tmp}/test-macos-defaults.XXXXXX")" +BASE="$(cd "$BASE" && pwd)" +trap 'rm -rf "$BASE"' EXIT + +pass=0 +fail=0 +skipped=0 +sb_count=0 + +ok() { pass=$((pass + 1)); echo "PASS: $1"; } +bad() { fail=$((fail + 1)); echo "FAIL: $1"; } +note() { skipped=$((skipped + 1)); echo "SKIP: $1"; } + +# `defaults` accepts an absolute plist path as a domain, so $DOMAIN keeps every +# write inside the sandbox instead of a real preference domain. +sandbox() { + sb_count=$((sb_count + 1)) + SB="$BASE/$sb_count" + mkdir -p "$SB" + TABLE="$SB/macos-defaults" + DOMAIN="$SB/com.example.test" +} + +mdefaults() { + set +e + out="$(MACOS_DEFAULTS_TABLE="$TABLE" "$ROOT/scripts/macos-defaults.sh" "$@" 2>&1)" + status=$? + set -e +} + +# Emit one tab-delimited row. Written field by field so an empty trailing +# field survives; that case is what the malformed-row tests exercise. +row() { + local first=1 f + for f in "$@"; do + if [ "$first" = 1 ]; then + printf '%s' "$f" + first=0 + else + printf '\t%s' "$f" + fi + done + printf '\n' +} + +darwin_only() { + if [ "$(uname)" != "Darwin" ]; then + note "$1 (not Darwin)" + return 1 + fi + return 0 +} + +test_rejects_unknown_flag() { + sandbox + row NSGlobalDomain SomeKey bool true > "$TABLE" + mdefaults --bogus + if [ "$status" = 2 ] && grep -q "usage:" <<<"$out"; then + ok "unknown flag exits 2 with usage" + else + bad "unknown flag exits 2 with usage (status=$status, out=$out)" + fi +} + +test_rejects_missing_table() { + sandbox + mdefaults check + if [ "$status" = 1 ] && grep -q "table not found" <<<"$out"; then + ok "missing table exits 1" + else + bad "missing table exits 1 (status=$status, out=$out)" + fi +} + +test_rejects_three_columns() { + sandbox + row NSGlobalDomain SomeKey bool > "$TABLE" + mdefaults check + if [ "$status" = 1 ] && grep -q "line 1" <<<"$out"; then + ok "three-column row exits 1 naming the line" + else + bad "three-column row exits 1 naming the line (status=$status, out=$out)" + fi +} + +test_rejects_six_columns() { + sandbox + row NSGlobalDomain SomeKey bool true noaudit=tcc surprise > "$TABLE" + mdefaults check + if [ "$status" = 1 ] && grep -q "line 1" <<<"$out"; then + ok "six-column row exits 1 naming the line" + else + bad "six-column row exits 1 naming the line (status=$status, out=$out)" + fi +} + +# A trailing tab makes an empty fifth field. The format reserves the trailing +# position for `status` precisely so no interior field is ever empty; rejecting +# this keeps that invariant, and .editorconfig forbids the trailing whitespace. +test_rejects_empty_status_column() { + sandbox + printf 'NSGlobalDomain\tSomeKey\tbool\ttrue\t\n' > "$TABLE" + mdefaults check + if [ "$status" = 1 ] && grep -q "status" <<<"$out"; then + ok "empty trailing status column exits 1" + else + bad "empty trailing status column exits 1 (status=$status, out=$out)" + fi +} + +test_rejects_unknown_type() { + sandbox + row NSGlobalDomain SomeKey number 4 > "$TABLE" + mdefaults check + if [ "$status" = 1 ] && grep -q "unknown type" <<<"$out"; then + ok "unknown type exits 1" + else + bad "unknown type exits 1 (status=$status, out=$out)" + fi +} + +test_rejects_unknown_status() { + sandbox + row NSGlobalDomain SomeKey bool true arch=arm64 > "$TABLE" + mdefaults check + if [ "$status" = 1 ] && grep -q "unknown status" <<<"$out"; then + ok "unknown status exits 1" + else + bad "unknown status exits 1 (status=$status, out=$out)" + fi +} + +# `audit` has no way to compare a container value, so a container row without a +# noaudit marker would report drift forever. +test_rejects_container_type_without_noaudit() { + sandbox + row com.apple.terminal StringEncodings array 4 > "$TABLE" + mdefaults check + if [ "$status" = 1 ] && grep -q "noaudit" <<<"$out"; then + ok "container type without noaudit exits 1" + else + bad "container type without noaudit exits 1 (status=$status, out=$out)" + fi +} + +test_rejects_empty_table() { + sandbox + printf '# comments only\n\n' > "$TABLE" + mdefaults check + if [ "$status" = 1 ] && grep -q "no rows" <<<"$out"; then + ok "comment-only table exits 1" + else + bad "comment-only table exits 1 (status=$status, out=$out)" + fi +} + +# deploy.sh strips from the first `#` to end of line. That would truncate this +# value, which is why this parser only treats a leading `#` as a comment. +test_hash_inside_value_is_not_a_comment() { + sandbox + row com.example.app Greeting string "hello # world" > "$TABLE" + mdefaults check + if [ "$status" = 0 ] && grep -q "1 row" <<<"$out"; then + ok "a # inside a value does not start a comment" + else + bad "a # inside a value does not start a comment (status=$status, out=$out)" + fi +} + +test_indented_comment_is_a_comment() { + sandbox + { + printf ' # indented\n' + row NSGlobalDomain SomeKey bool true + } > "$TABLE" + mdefaults check + if [ "$status" = 0 ] && grep -q "1 row" <<<"$out"; then + ok "an indented # line is a comment" + else + bad "an indented # line is a comment (status=$status, out=$out)" + fi +} + +# `com.apple.print.PrintingPrefs "Quit When Finished"` is a real row, and it is +# the reason this file is tab-delimited rather than whitespace-columned. +test_key_with_spaces_parses() { + sandbox + row com.apple.print.PrintingPrefs "Quit When Finished" bool true > "$TABLE" + mdefaults check + if [ "$status" = 0 ] && grep -q "1 row" <<<"$out"; then + ok "a key containing spaces parses" + else + bad "a key containing spaces parses (status=$status, out=$out)" + fi +} + +test_check_counts_rows() { + sandbox + { + printf '# a banner\n' + row NSGlobalDomain FirstKey bool true + printf '\n' + row com.apple.Safari SecondKey bool true noaudit=tcc + } > "$TABLE" + mdefaults check + if [ "$status" = 0 ] && grep -q "2 rows" <<<"$out"; then + ok "check counts data rows, ignoring comments and blanks" + else + bad "check counts data rows, ignoring comments and blanks (status=$status, out=$out)" + fi +} + +# --- runner ----------------------------------------------------------------- +test_rejects_unknown_flag +test_rejects_missing_table +test_rejects_three_columns +test_rejects_six_columns +test_rejects_empty_status_column +test_rejects_unknown_type +test_rejects_unknown_status +test_rejects_container_type_without_noaudit +test_rejects_empty_table +test_hash_inside_value_is_not_a_comment +test_indented_comment_is_a_comment +test_key_with_spaces_parses +test_check_counts_rows + +echo +echo "$pass passed, $fail failed, $skipped skipped" +[ "$fail" -eq 0 ] +``` + +- [ ] **Step 2: Make it executable and run it to verify it fails** + +```bash +chmod +x test/test_macos_defaults.sh +./test/test_macos_defaults.sh +``` + +Expected: every test FAILs, because `scripts/macos-defaults.sh` does not exist yet. + +- [ ] **Step 3: Write the script** + +Create `scripts/macos-defaults.sh`: + +```bash +#!/bin/bash +# Declarative macOS defaults: check/audit/apply/accept the table in ./macos-defaults. +# Spec: docs/superpowers/specs/2026-08-25-macos-defaults-declarative-design.md +set -euf -o pipefail + +DOTS="$(cd "$(dirname "$0")/.." && pwd)" +# Overridable so the test suite can point at a sandboxed table. +TABLE="${MACOS_DEFAULTS_TABLE:-$DOTS/macos-defaults}" +TAB=$'\t' + +dry_run=0 +mode=audit +filter_domain="" +filter_key="" +failures=0 + +usage() { + echo "usage: $0 [--dry-run] [check|audit|apply|accept] [domain [key]]" >&2 +} + +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) + dry_run=1 + shift + ;; + check | audit | apply | accept) + mode="$1" + shift + filter_domain="${1:-}" + if [ $# -gt 0 ]; then shift; fi + filter_key="${1:-}" + if [ $# -gt 0 ]; then shift; fi + if [ $# -gt 0 ]; then + usage + exit 2 + fi + ;; + *) + usage + exit 2 + ;; + esac +done + +t_domain=() +t_key=() +t_type=() +t_value=() +t_status=() + +# Splits on tabs into the global ROW, preserving empty fields. `IFS=$'\t' read` +# cannot be used here: bash classes tab as IFS whitespace, so it collapses runs +# of tabs and drops a leading one, shifting every field left without an error. +split_row() { + local rest="$1" + ROW=() + while :; do + case "$rest" in + *"$TAB"*) + ROW+=("${rest%%"$TAB"*}") + rest="${rest#*"$TAB"}" + ;; + *) + ROW+=("$rest") + break + ;; + esac + done +} + +parse_table() { + local lineno=0 line trimmed status i + if [ ! -f "$TABLE" ]; then + echo "error: table not found at $TABLE" >&2 + exit 1 + fi + while IFS= read -r line || [ -n "$line" ]; do + lineno=$((lineno + 1)) + trimmed="${line#"${line%%[![:space:]]*}"}" + case "$trimmed" in + "" | "#"*) continue ;; + esac + split_row "$line" + if [ "${#ROW[@]}" -lt 4 ] || [ "${#ROW[@]}" -gt 5 ]; then + echo "error: $TABLE line $lineno: expected 4 or 5 tab-separated columns, got ${#ROW[@]}" >&2 + exit 1 + fi + i=0 + while [ "$i" -lt 4 ]; do + if [ -z "${ROW[$i]}" ]; then + echo "error: $TABLE line $lineno: column $((i + 1)) is empty" >&2 + exit 1 + fi + i=$((i + 1)) + done + status="" + if [ "${#ROW[@]}" = 5 ]; then + status="${ROW[4]}" + if [ -z "$status" ]; then + echo "error: $TABLE line $lineno: status column is empty; omit it instead" >&2 + exit 1 + fi + fi + case "${ROW[2]}" in + bool | int | float | string | raw) ;; + array | dict | dict-add | date | data) + case "$status" in + noaudit=*) ;; + *) + echo "error: $TABLE line $lineno: type '${ROW[2]}' cannot be compared; it needs a noaudit= status" >&2 + exit 1 + ;; + esac + ;; + *) + echo "error: $TABLE line $lineno: unknown type '${ROW[2]}'" >&2 + exit 1 + ;; + esac + case "$status" in + "" | noaudit=tcc | noaudit=unset | noaudit=complex | os=?* | host=?*) ;; + *) + echo "error: $TABLE line $lineno: unknown status '$status'" >&2 + exit 1 + ;; + esac + t_domain+=("${ROW[0]}") + t_key+=("${ROW[1]}") + t_type+=("${ROW[2]}") + t_value+=("${ROW[3]}") + t_status+=("$status") + done <"$TABLE" + if [ "${#t_domain[@]}" -eq 0 ]; then + echo "error: $TABLE has no rows" >&2 + exit 1 + fi +} + +row_selected() { + local i="$1" + if [ -n "$filter_domain" ] && [ "${t_domain[$i]}" != "$filter_domain" ]; then + return 1 + fi + if [ -n "$filter_key" ] && [ "${t_key[$i]}" != "$filter_key" ]; then + return 1 + fi + return 0 +} + +main() { + local i n + parse_table + n="${#t_domain[@]}" + if [ "$mode" = check ]; then + if [ "$n" = 1 ]; then + echo "ok: 1 row in $TABLE" + else + echo "ok: $n rows in $TABLE" + fi + return 0 + fi + i=0 + while [ "$i" -lt "$n" ]; do + if row_selected "$i"; then + case "$mode" in + audit) audit_row "$i" ;; + apply) apply_row "$i" ;; + esac + fi + i=$((i + 1)) + done + if [ "$failures" -gt 0 ]; then + exit 1 + fi +} + +main +``` + +- [ ] **Step 4: Make it executable and run the tests** + +```bash +chmod +x scripts/macos-defaults.sh +./test/test_macos_defaults.sh +``` + +Expected: all 13 tests PASS on both macOS and Linux. `check` never reaches `audit_row`/`apply_row`, which do not exist yet, so this is a complete slice. + +- [ ] **Step 5: Verify the parser rejects the exact shapes `deploy.sh` would mishandle** + +```bash +printf 'com.example.app\tGreeting\tstring\thello # world\n' > /tmp/t1 +MACOS_DEFAULTS_TABLE=/tmp/t1 ./scripts/macos-defaults.sh check +``` + +Expected: `ok: 1 row in /tmp/t1`. Confirm the value was not truncated at the `#` by adding a temporary `echo "${t_value[0]}"` if you want to see it, then remove it. + +- [ ] **Step 6: Commit** + +```bash +git add scripts/macos-defaults.sh test/test_macos_defaults.sh +git commit -m "Add macos-defaults table parser and check mode + +Parses the tab-delimited table and validates it, with no machine access +yet. Two departures from deploy.sh's parser, both tested: a # only starts +a comment at the start of a line, and rows are split by parameter +expansion rather than IFS=\$'\\t' read, which collapses tab runs." +``` + +--- + +### Task 2: `audit` mode + +Adds the comparison engine. This is the mode the whole change exists for, so its correctness bar is the highest in the plan: the four normalization rules below are each a way the naive `[ "$want" = "$live" ]` gets it wrong. + +**Files:** +- Modify: `scripts/macos-defaults.sh` (add helpers before `main`; add the condition check inside `main`'s loop) +- Modify: `test/test_macos_defaults.sh` (add tests, extend the runner) + +**Interfaces:** +- Consumes: `parse_table`, the `t_*` arrays, `row_selected`, and the `failures` counter from Task 1. +- Produces: `defaults_read ` (echoes the live value, exit 1 if the key is absent), `defaults_read_type ` (echoes a plist type name with `Type is ` stripped, exit 1 if absent), `table_type_of ` (maps `boolean`→`bool`, `integer`→`int`, `dictionary`→`dict`, else passthrough), `normalize `, `condition_matches `, `audit_row `. Task 3 reuses `defaults_read` and `normalize`; Task 4 reuses `defaults_read` and `defaults_read_type`. + +**Deviation from the spec, deliberate:** the spec calls `os=` and `host=` "parsed but unused". A parser that accepts a token and then ignores its meaning would silently apply a `os=Linux` row on Darwin. This task implements the condition instead, copying `deploy.sh`'s `condition_matches` and its shared `scripts/host-id.sh`. Ten lines, one test, and the reserved vocabulary stops being a lie. + +- [ ] **Step 1: Write the failing tests** + +Append to `test/test_macos_defaults.sh`, above the runner block: + +```bash +# A row whose condition does not match is skipped before any `defaults` call, +# so this case needs no Darwin gate. +test_audit_skips_unmatched_condition() { + sandbox + row NSGlobalDomain SomeKey bool true os=NoSuchOS > "$TABLE" + mdefaults audit + if [ "$status" = 0 ] && grep -q "^skip: " <<<"$out"; then + ok "unmatched os condition is skipped" + else + bad "unmatched os condition is skipped (status=$status, out=$out)" + fi +} + +test_audit_ok_when_value_matches() { + darwin_only "audit reports ok when the live value matches" || return 0 + sandbox + defaults write "$DOMAIN" Flag -bool true + row "$DOMAIN" Flag bool true > "$TABLE" + mdefaults audit + if [ "$status" = 0 ] && grep -q "^ok: " <<<"$out"; then + ok "audit reports ok when the live value matches" + else + bad "audit reports ok when the live value matches (status=$status, out=$out)" + fi +} + +# `defaults` stores booleans as 0/1 but the table keeps them readable as +# true/false, so every bool comparison depends on normalizing both sides. +test_audit_normalizes_bools() { + darwin_only "audit normalizes true against a stored 1" || return 0 + sandbox + defaults write "$DOMAIN" Flag -bool true + row "$DOMAIN" Flag bool YES > "$TABLE" + mdefaults audit + if [ "$status" = 0 ] && grep -q "^ok: " <<<"$out"; then + ok "audit normalizes true/YES/1 to the same value" + else + bad "audit normalizes true/YES/1 to the same value (status=$status, out=$out)" + fi +} + +test_audit_reports_drift() { + darwin_only "audit reports drift with both values" || return 0 + sandbox + defaults write "$DOMAIN" Count -int 3 + row "$DOMAIN" Count int 7 > "$TABLE" + mdefaults audit + if [ "$status" = 1 ] && grep -q "^drift: .*want=7 live=3" <<<"$out"; then + ok "audit reports drift with want and live" + else + bad "audit reports drift with want and live (status=$status, out=$out)" + fi +} + +# An absent key and a mismatched key need different fixes, so they get +# different labels; conflating them made the design probe unreadable. +test_audit_reports_missing() { + darwin_only "audit reports an absent key as missing" || return 0 + sandbox + defaults write "$DOMAIN" Other -int 1 + row "$DOMAIN" Count int 7 > "$TABLE" + mdefaults audit + if [ "$status" = 1 ] && grep -q "^missing: " <<<"$out" && ! grep -q "^drift: " <<<"$out"; then + ok "audit reports an absent key as missing, not drift" + else + bad "audit reports an absent key as missing, not drift (status=$status, out=$out)" + fi +} + +test_audit_reports_type_drift() { + darwin_only "audit reports a changed storage type as drift" || return 0 + sandbox + defaults write "$DOMAIN" Count -string 7 + row "$DOMAIN" Count int 7 > "$TABLE" + mdefaults audit + if [ "$status" = 1 ] && grep -q "^drift: .*type want=int live=string" <<<"$out"; then + ok "audit reports a changed storage type as drift" + else + bad "audit reports a changed storage type as drift (status=$status, out=$out)" + fi +} + +# noaudit rows must never influence the exit code, or every audit on a machine +# with Safari settings would exit non-zero forever. +test_audit_skips_noaudit_rows_without_failing() { + darwin_only "audit skips noaudit rows and still exits 0" || return 0 + sandbox + row "$DOMAIN" Missing bool true noaudit=tcc > "$TABLE" + mdefaults audit + if [ "$status" = 0 ] && grep -q "^skip: .*(tcc)" <<<"$out"; then + ok "audit skips noaudit rows and still exits 0" + else + bad "audit skips noaudit rows and still exits 0 (status=$status, out=$out)" + fi +} + +test_audit_filters_by_domain_and_key() { + darwin_only "audit honors the domain and key filter" || return 0 + sandbox + defaults write "$DOMAIN" First -bool true + defaults write "$DOMAIN" Second -bool true + { + row "$DOMAIN" First bool true + row "$DOMAIN" Second bool true + } > "$TABLE" + mdefaults audit "$DOMAIN" Second + if [ "$status" = 0 ] && grep -q "Second" <<<"$out" && ! grep -q "First" <<<"$out"; then + ok "audit honors the domain and key filter" + else + bad "audit honors the domain and key filter (status=$status, out=$out)" + fi +} +``` + +Add to the runner block, before the summary lines: + +```bash +test_audit_skips_unmatched_condition +test_audit_ok_when_value_matches +test_audit_normalizes_bools +test_audit_reports_drift +test_audit_reports_missing +test_audit_reports_type_drift +test_audit_skips_noaudit_rows_without_failing +test_audit_filters_by_domain_and_key +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +./test/test_macos_defaults.sh +``` + +Expected: the 13 Task 1 tests still PASS. The 8 new ones FAIL — on macOS because `audit_row` is undefined, on Linux the condition test FAILs and the other 7 report SKIP. + +- [ ] **Step 3: Add the helpers** + +In `scripts/macos-defaults.sh`, insert after `row_selected` and before `main`: + +```bash +source "$DOTS/scripts/host-id.sh" +os="$(os_id)" +host="$(host_id)" + +condition_matches() { + case "$1" in + "" | noaudit=*) return 0 ;; + os=*) [ "${1#os=}" = "$os" ] ;; + host=*) [ "${1#host=}" = "$host" ] ;; + *) return 1 ;; + esac +} + +defaults_read() { + local domain="$1" key="$2" + case "$domain" in + currentHost:*) defaults -currentHost read "${domain#currentHost:}" "$key" 2>/dev/null ;; + *) defaults read "$domain" "$key" 2>/dev/null ;; + esac +} + +defaults_read_type() { + local domain="$1" key="$2" out + case "$domain" in + currentHost:*) + out="$(defaults -currentHost read-type "${domain#currentHost:}" "$key" 2>/dev/null)" || return 1 + ;; + *) + out="$(defaults read-type "$domain" "$key" 2>/dev/null)" || return 1 + ;; + esac + printf '%s\n' "${out#Type is }" +} + +table_type_of() { + case "$1" in + boolean) printf 'bool\n' ;; + integer) printf 'int\n' ;; + dictionary) printf 'dict\n' ;; + *) printf '%s\n' "$1" ;; + esac +} + +normalize() { + case "$1" in + bool) + case "$2" in + true | TRUE | True | YES | Yes | yes | 1) printf '1\n' ;; + false | FALSE | False | NO | No | no | 0) printf '0\n' ;; + *) printf '%s\n' "$2" ;; + esac + ;; + *) printf '%s\n' "$2" ;; + esac +} + +audit_row() { + local i="$1" + local domain="${t_domain[$i]}" key="${t_key[$i]}" type="${t_type[$i]}" + local value="${t_value[$i]}" status="${t_status[$i]}" + local live want live_type + + case "$status" in + noaudit=*) + echo "skip: $domain $key (${status#noaudit=})" + return 0 + ;; + esac + + if ! live="$(defaults_read "$domain" "$key")"; then + echo "missing: $domain $key" + failures=$((failures + 1)) + return 0 + fi + + # A `raw` row is written with no type flag, so `defaults` infers the stored + # type and the table has no claim to assert against it. + if [ "$type" != raw ]; then + if live_type="$(defaults_read_type "$domain" "$key")"; then + live_type="$(table_type_of "$live_type")" + if [ "$live_type" != "$type" ]; then + echo "drift: $domain $key type want=$type live=$live_type" + failures=$((failures + 1)) + return 0 + fi + fi + fi + + want="$(normalize "$type" "$value")" + live="$(normalize "$type" "$live")" + if [ "$want" = "$live" ]; then + echo "ok: $domain $key" + else + echo "drift: $domain $key want=$want live=$live" + failures=$((failures + 1)) + fi +} +``` + +- [ ] **Step 4: Wire the condition check into `main`** + +Replace the dispatch block inside `main`'s while loop with: + +```bash + if row_selected "$i"; then + if ! condition_matches "${t_status[$i]}"; then + echo "skip: ${t_domain[$i]} ${t_key[$i]} (${t_status[$i]})" + else + case "$mode" in + audit) audit_row "$i" ;; + apply) apply_row "$i" ;; + esac + fi + fi +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +```bash +./test/test_macos_defaults.sh +``` + +Expected on macOS: 21 passed, 0 failed. Expected on Linux: 14 passed, 0 failed, 7 skipped. + +- [ ] **Step 6: Commit** + +```bash +git add scripts/macos-defaults.sh test/test_macos_defaults.sh +git commit -m "Add audit mode to macos-defaults + +Compares each row against the live machine. Four rules the naive string +compare gets wrong, each with a test: booleans normalize (defaults stores +0/1, the table reads true/false), a changed storage type is drift, an +absent key is missing rather than drift, and noaudit rows never reach the +exit code. + +os= and host= conditions are implemented rather than merely parsed. The +spec reserved them as unused, but a status the parser accepts and then +ignores would apply an os=Linux row on Darwin." +``` + +--- + +### Task 3: `apply` mode, `--dry-run`, and container writes + +**Files:** +- Modify: `scripts/macos-defaults.sh` +- Modify: `test/test_macos_defaults.sh` + +**Interfaces:** +- Consumes: `defaults_read`, `normalize` from Task 2; `dry_run` from Task 1. +- Produces: `needs_sudo ` (true only when the domain is an absolute path whose plist exists and is not writable), `write_row `, `apply_row `. + +- [ ] **Step 1: Write the failing tests** + +Append to `test/test_macos_defaults.sh`, above the runner block: + +```bash +test_apply_writes_missing_key() { + darwin_only "apply writes a key that is absent" || return 0 + sandbox + row "$DOMAIN" Count int 7 > "$TABLE" + mdefaults apply + if [ "$status" = 0 ] && [ "$(defaults read "$DOMAIN" Count)" = 7 ] \ + && grep -q "^write: " <<<"$out"; then + ok "apply writes a key that is absent" + else + bad "apply writes a key that is absent (status=$status, out=$out)" + fi +} + +test_apply_is_idempotent() { + darwin_only "a second apply is a no-op reported as ok" || return 0 + sandbox + row "$DOMAIN" Count int 7 > "$TABLE" + mdefaults apply + mdefaults apply + if [ "$status" = 0 ] && grep -q "^ok: " <<<"$out" && ! grep -q "^write: " <<<"$out"; then + ok "a second apply is a no-op reported as ok" + else + bad "a second apply is a no-op reported as ok (status=$status, out=$out)" + fi +} + +test_dry_run_reports_would_write() { + darwin_only "dry-run prefixes its decisions with would:" || return 0 + sandbox + row "$DOMAIN" Count int 7 > "$TABLE" + mdefaults --dry-run apply + if [ "$status" = 0 ] && grep -q "^would: write: " <<<"$out"; then + ok "dry-run prefixes its decisions with would:" + else + bad "dry-run prefixes its decisions with would: (status=$status, out=$out)" + fi +} + +test_dry_run_changes_nothing() { + darwin_only "dry-run creates no plist" || return 0 + sandbox + row "$DOMAIN" Count int 7 > "$TABLE" + mdefaults --dry-run apply + if [ "$status" = 0 ] && [ ! -e "$DOMAIN.plist" ]; then + ok "dry-run creates no plist" + else + bad "dry-run creates no plist (status=$status, out=$out)" + fi +} + +# audit cannot tell whether a noaudit row needs writing, so apply always writes +# one. Without this, the 40 TCC rows would never be applied on a fresh Mac. +test_apply_writes_noaudit_rows() { + darwin_only "apply writes noaudit rows unconditionally" || return 0 + sandbox + row "$DOMAIN" Count int 7 noaudit=unset > "$TABLE" + mdefaults apply + if [ "$status" = 0 ] && [ "$(defaults read "$DOMAIN" Count)" = 7 ]; then + ok "apply writes noaudit rows unconditionally" + else + bad "apply writes noaudit rows unconditionally (status=$status, out=$out)" + fi +} + +# An array value cannot be expressed as a type/value pair, so container rows +# carry a literal argument tail and are the only rows that get eval'd. +test_apply_writes_container_value() { + darwin_only "apply writes an array row through its literal argument tail" || return 0 + sandbox + row "$DOMAIN" Langs array '"en" "fr"' noaudit=complex > "$TABLE" + mdefaults apply + if [ "$status" = 0 ] && [ "$(defaults read "$DOMAIN" Langs | tr -d '\n ')" = "(en,fr)" ]; then + ok "apply writes an array row through its literal argument tail" + else + bad "apply writes an array row through its literal argument tail (status=$status, out=$out)" + fi +} + +test_apply_skips_unmatched_condition() { + sandbox + row NSGlobalDomain SomeKey bool true os=NoSuchOS > "$TABLE" + mdefaults apply + if [ "$status" = 0 ] && grep -q "^skip: " <<<"$out"; then + ok "apply skips an unmatched condition" + else + bad "apply skips an unmatched condition (status=$status, out=$out)" + fi +} +``` + +Add to the runner block: + +```bash +test_apply_writes_missing_key +test_apply_is_idempotent +test_dry_run_reports_would_write +test_dry_run_changes_nothing +test_apply_writes_noaudit_rows +test_apply_writes_container_value +test_apply_skips_unmatched_condition +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +./test/test_macos_defaults.sh +``` + +Expected: the 7 new tests FAIL on macOS (`apply_row` undefined); on Linux 6 SKIP and `test_apply_skips_unmatched_condition` FAILs. + +- [ ] **Step 3: Add the writer** + +In `scripts/macos-defaults.sh`, insert after `audit_row`: + +```bash +# Decided from the plist's writability rather than the path prefix: the test +# suite uses absolute-path domains under mktemp, which are writable and must +# not reach for sudo. +needs_sudo() { + local domain="$1" + case "$domain" in + /*) ;; + *) return 1 ;; + esac + [ -e "$domain.plist" ] && [ ! -w "$domain.plist" ] +} + +write_row() { + local domain="$1" key="$2" type="$3" value="$4" + local host_flag="" target="$domain" sudo_cmd="" + + case "$domain" in + currentHost:*) + host_flag="-currentHost" + target="${domain#currentHost:}" + ;; + esac + if needs_sudo "$target"; then + sudo_cmd="sudo" + fi + + case "$type" in + array | dict | dict-add | date | data) + # Container values carry their own quoted argument tail, which only + # the shell can re-split. Scalar rows never take this branch. + eval "$sudo_cmd defaults $host_flag write \"\$target\" \"\$key\" -$type $value" + ;; + raw) + $sudo_cmd defaults $host_flag write "$target" "$key" "$value" + ;; + *) + $sudo_cmd defaults $host_flag write "$target" "$key" "-$type" "$value" + ;; + esac +} + +apply_row() { + local i="$1" + local domain="${t_domain[$i]}" key="${t_key[$i]}" type="${t_type[$i]}" + local value="${t_value[$i]}" status="${t_status[$i]}" + local prefix="" live + + if [ "$dry_run" = 1 ]; then + prefix="would: " + fi + + case "$status" in + noaudit=*) ;; + *) + if live="$(defaults_read "$domain" "$key")"; then + if [ "$(normalize "$type" "$live")" = "$(normalize "$type" "$value")" ]; then + echo "ok: $domain $key" + return 0 + fi + fi + ;; + esac + + echo "${prefix}write: $domain $key = $value" + if [ "$dry_run" = 1 ]; then + return 0 + fi + write_row "$domain" "$key" "$type" "$value" +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +./test/test_macos_defaults.sh +``` + +Expected on macOS: 28 passed, 0 failed. On Linux: 15 passed, 0 failed, 13 skipped. + +- [ ] **Step 5: Confirm apply never prompts for a password in the sandbox** + +```bash +./test/test_macos_defaults.sh +``` + +Expected: the run completes without a `Password:` prompt. If one appears, `needs_sudo` is matching on the path prefix rather than writability. + +- [ ] **Step 6: Commit** + +```bash +git add scripts/macos-defaults.sh test/test_macos_defaults.sh +git commit -m "Add apply mode to macos-defaults + +Writes only rows whose live value differs, except noaudit rows, which are +always written since audit cannot tell whether they need it. + +sudo is chosen from the plist's writability, not the path prefix, so the +sandboxed tests never prompt. Container rows carry a literal argument tail +and are the only ones eval'd; an array value has no type/value form." +``` + +--- + +### Task 4: `accept` mode + +Rewrites table rows from what is live. This is also the seeding tool Task 6 uses, so it has to preserve every comment and blank line in the file. + +**Files:** +- Modify: `scripts/macos-defaults.sh` +- Modify: `test/test_macos_defaults.sh` + +**Interfaces:** +- Consumes: `defaults_read`, `defaults_read_type`, `table_type_of`, `normalize`, `row_selected`, the `t_*` arrays. + +**Deviation from the spec, deliberate:** the spec writes the signature as `accept [domain key ...]`. The filter is `[domain [key]]` instead — one optional domain, one optional key — which also lets `accept ` take every drifting row in a domain. A list of pairs would need an array, and expanding an empty array under `set -u` is an error in bash 3.2. +- Produces: `run_accept` (rewrites `$TABLE` in place), called from `main` instead of the per-row loop. + +- [ ] **Step 1: Write the failing tests** + +Append to `test/test_macos_defaults.sh`, above the runner block: + +```bash +test_accept_updates_a_drifting_value() { + darwin_only "accept rewrites a drifting row to the live value" || return 0 + sandbox + defaults write "$DOMAIN" Count -int 3 + row "$DOMAIN" Count int 7 > "$TABLE" + mdefaults accept + if [ "$status" = 0 ] && grep -q " int 3$" "$TABLE"; then + ok "accept rewrites a drifting row to the live value" + else + bad "accept rewrites a drifting row to the live value (status=$status, table=$(cat "$TABLE"))" + fi +} + +# The comments carried over from .macos are the most valuable thing in the +# table, and accept rewrites the file wholesale. +test_accept_preserves_comments_and_blanks() { + darwin_only "accept preserves comments and blank lines" || return 0 + sandbox + defaults write "$DOMAIN" Count -int 3 + { + printf '# a banner\n' + printf '\n' + printf '# why this setting exists\n' + row "$DOMAIN" Count int 7 + } > "$TABLE" + mdefaults accept + if [ "$status" = 0 ] && [ "$(head -1 "$TABLE")" = "# a banner" ] \ + && [ "$(sed -n 3p "$TABLE")" = "# why this setting exists" ] \ + && [ -z "$(sed -n 2p "$TABLE")" ]; then + ok "accept preserves comments and blank lines" + else + bad "accept preserves comments and blank lines (status=$status, table=$(cat "$TABLE"))" + fi +} + +test_accept_leaves_matching_rows_alone() { + darwin_only "accept leaves a matching row byte-identical" || return 0 + sandbox + defaults write "$DOMAIN" Count -int 7 + row "$DOMAIN" Count int 7 > "$TABLE" + before="$(cksum < "$TABLE")" + mdefaults accept + if [ "$status" = 0 ] && [ "$(cksum < "$TABLE")" = "$before" ]; then + ok "accept leaves a matching row byte-identical" + else + bad "accept leaves a matching row byte-identical (status=$status, out=$out)" + fi +} + +# The 9 noaudit=unset rows exist because their key was absent at seed time. Once +# a key becomes readable the marker is stale, and only accept can clear it. +test_accept_promotes_a_readable_unset_row() { + darwin_only "accept clears noaudit=unset once the key reads" || return 0 + sandbox + defaults write "$DOMAIN" Count -int 3 + row "$DOMAIN" Count int 7 noaudit=unset > "$TABLE" + mdefaults accept + if [ "$status" = 0 ] && ! grep -q "noaudit=unset" "$TABLE" && grep -q " int 3$" "$TABLE"; then + ok "accept clears noaudit=unset once the key reads" + else + bad "accept clears noaudit=unset once the key reads (status=$status, table=$(cat "$TABLE"))" + fi +} + +test_accept_updates_the_type_when_it_drifts() { + darwin_only "accept rewrites the type column too" || return 0 + sandbox + defaults write "$DOMAIN" Count -string seven + row "$DOMAIN" Count int 7 > "$TABLE" + mdefaults accept + if [ "$status" = 0 ] && grep -q " string seven$" "$TABLE"; then + ok "accept rewrites the type column too" + else + bad "accept rewrites the type column too (status=$status, table=$(cat "$TABLE"))" + fi +} + +test_accept_honors_the_filter() { + darwin_only "accept honors the domain and key filter" || return 0 + sandbox + defaults write "$DOMAIN" First -int 1 + defaults write "$DOMAIN" Second -int 2 + { + row "$DOMAIN" First int 9 + row "$DOMAIN" Second int 9 + } > "$TABLE" + mdefaults accept "$DOMAIN" Second + if [ "$status" = 0 ] && grep -q "First int 9$" "$TABLE" && grep -q "Second int 2$" "$TABLE"; then + ok "accept honors the domain and key filter" + else + bad "accept honors the domain and key filter (status=$status, table=$(cat "$TABLE"))" + fi +} +``` + +Add to the runner block: + +```bash +test_accept_updates_a_drifting_value +test_accept_preserves_comments_and_blanks +test_accept_leaves_matching_rows_alone +test_accept_promotes_a_readable_unset_row +test_accept_updates_the_type_when_it_drifts +test_accept_honors_the_filter +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +./test/test_macos_defaults.sh +``` + +Expected: 6 new tests FAIL on macOS, SKIP on Linux. + +- [ ] **Step 3: Add `run_accept`** + +In `scripts/macos-defaults.sh`, insert after `apply_row`: + +```bash +run_accept() { + local i n idx live live_type new_status tmp line trimmed + local new_row=() + + n="${#t_domain[@]}" + i=0 + while [ "$i" -lt "$n" ]; do + new_row[$i]="" + if row_selected "$i" && condition_matches "${t_status[$i]}"; then + if live="$(defaults_read "${t_domain[$i]}" "${t_key[$i]}")"; then + new_status="${t_status[$i]}" + # The marker only recorded that the key was unreadable at seed + # time; it just read, so it no longer describes anything. + if [ "$new_status" = "noaudit=unset" ]; then + new_status="" + fi + live_type="${t_type[$i]}" + if [ "$live_type" != raw ] && [ "$new_status" = "" ]; then + if live_type="$(defaults_read_type "${t_domain[$i]}" "${t_key[$i]}")"; then + live_type="$(table_type_of "$live_type")" + else + live_type="${t_type[$i]}" + fi + fi + if [ "$live_type" != "${t_type[$i]}" ] \ + || [ "$(normalize "$live_type" "$live")" != "$(normalize "${t_type[$i]}" "${t_value[$i]}")" ] \ + || [ "$new_status" != "${t_status[$i]}" ]; then + new_row[$i]="${t_domain[$i]}$TAB${t_key[$i]}$TAB$live_type$TAB$live" + if [ -n "$new_status" ]; then + new_row[$i]="${new_row[$i]}$TAB$new_status" + fi + echo "accept: ${t_domain[$i]} ${t_key[$i]} = $live" + fi + fi + fi + i=$((i + 1)) + done + + tmp="$(mktemp "${TMPDIR:-/tmp}/macos-defaults.XXXXXX")" + idx=0 + while IFS= read -r line || [ -n "$line" ]; do + trimmed="${line#"${line%%[![:space:]]*}"}" + case "$trimmed" in + "" | "#"*) + printf '%s\n' "$line" >>"$tmp" + continue + ;; + esac + if [ -n "${new_row[$idx]}" ]; then + printf '%s\n' "${new_row[$idx]}" >>"$tmp" + else + printf '%s\n' "$line" >>"$tmp" + fi + idx=$((idx + 1)) + done <"$TABLE" + + if [ "$dry_run" = 1 ]; then + rm -f "$tmp" + return 0 + fi + mv "$tmp" "$TABLE" +} +``` + +- [ ] **Step 4: Dispatch `accept` from `main`** + +In `main`, immediately after the `check` block, add: + +```bash + if [ "$mode" = accept ]; then + run_accept + return 0 + fi +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +```bash +./test/test_macos_defaults.sh +``` + +Expected on macOS: 34 passed, 0 failed. On Linux: 15 passed, 0 failed, 19 skipped. + +- [ ] **Step 6: Commit** + +```bash +git add scripts/macos-defaults.sh test/test_macos_defaults.sh +git commit -m "Add accept mode to macos-defaults + +Rewrites a row's value and type from what is live, and clears a +noaudit=unset marker once the key reads. Rewriting the file wholesale means +comments and blank lines have to survive intact, which is tested: those +comments are the why carried over from .macos." +``` + +--- + +### Task 5: Generate the table from `.macos` + +Transcribes 217 rows mechanically. The generator is committed rather than run and discarded so the transcription is reviewable, following the `scripts/migrate-to-home.sh` precedent. + +**Files:** +- Create: `scripts/migrate-macos-defaults.sh` +- Create: `macos-defaults` + +**Interfaces:** +- Consumes: `scripts/macos-defaults.sh check` for validation. +- Produces: `scripts/migrate-macos-defaults.sh [--remainder]`. Default mode prints the table to stdout. `--remainder` prints the lines of `.macos` that did *not* become table rows, which Task 7 uses to rewrite `.macos`. + +- [ ] **Step 1: Write the generator** + +Create `scripts/migrate-macos-defaults.sh`: + +```bash +#!/bin/bash +# One-shot: split .macos into the macos-defaults table (default) and the +# imperative lines that stay behind (--remainder). Committed so the 217-row +# transcription can be reviewed rather than trusted. +set -euf -o pipefail + +DOTS="$(cd "$(dirname "$0")/.." && pwd)" +SOURCE="$DOTS/.macos" +TAB=$'\t' +mode=table + +if [ $# -gt 0 ]; then + case "$1" in + --remainder) mode=remainder ;; + *) + echo "usage: $0 [--remainder]" >&2 + exit 2 + ;; + esac +fi + +pending=() + +flush_pending() { + local p + if [ "${#pending[@]}" -gt 0 ]; then + for p in "${pending[@]}"; do + printf '%s\n' "$p" + done + fi + pending=() +} + +quote_tail() { + local out="" a + for a in "$@"; do + out="$out${out:+ }$(printf '%q' "$a")" + done + printf '%s' "$out" +} + +# Absent from the live machine for two different reasons that need different +# markers: a whole domain that will not read is TCC, a readable domain missing +# one key is simply unset. +classify() { + local domain="$1" key="$2" host_flag="" + case "$domain" in + currentHost:*) + host_flag="-currentHost" + domain="${domain#currentHost:}" + ;; + esac + # `command` is required: this runs inside the `defaults` shim below, and a + # bare call would re-enter it instead of reaching the binary. + if command defaults $host_flag read "$domain" "$key" >/dev/null 2>&1; then + printf '' + return 0 + fi + if command defaults $host_flag read "$domain" >/dev/null 2>&1; then + printf 'noaudit=unset' + else + printf 'noaudit=tcc' + fi +} + +emit() { + local host="$1" domain="$2" key="$3" type="$4" value="$5" status + if [ "$mode" = remainder ]; then + pending=() + return 0 + fi + if [ "$host" = currentHost ]; then + domain="currentHost:$domain" + fi + # Continuation lines in .macos are tab-indented; a surviving tab would add a + # phantom column. + value="${value//$TAB/ }" + case "$type" in + array | dict | dict-add | date | data) status="noaudit=complex" ;; + *) status="$(classify "$domain" "$key")" ;; + esac + flush_pending + if [ -n "$status" ]; then + printf '%s\n' "$domain$TAB$key$TAB$type$TAB$value$TAB$status" + else + printf '%s\n' "$domain$TAB$key$TAB$type$TAB$value" + fi +} + +defaults() { + local host="" + if [ "$1" = "-currentHost" ]; then + host=currentHost + shift + fi + if [ "$1" != write ]; then + return 0 + fi + shift + local domain="$1" key="$2" + shift 2 + case "$1" in + -bool | -boolean) emit "$host" "$domain" "$key" bool "$2" ;; + -int | -integer) emit "$host" "$domain" "$key" int "$2" ;; + -float) emit "$host" "$domain" "$key" float "$2" ;; + -string) emit "$host" "$domain" "$key" string "$2" ;; + -date) emit "$host" "$domain" "$key" date "$2" ;; + -data) emit "$host" "$domain" "$key" data "$2" ;; + -array | -array-add) + shift + emit "$host" "$domain" "$key" array "$(quote_tail "$@")" + ;; + -dict) + shift + emit "$host" "$domain" "$key" dict "$(quote_tail "$@")" + ;; + -dict-add) + shift + emit "$host" "$domain" "$key" dict-add "$(quote_tail "$@")" + ;; + # An untyped value is handed to `defaults` to parse as a plist fragment, + # which is what `AdminHostInfo HostName` and `mouse.scaling -1` rely on. + *) emit "$host" "$domain" "$key" raw "$1" ;; + esac +} + +sudo() { "$@"; } + +while IFS= read -r line || [ -n "$line" ]; do + while [ "${line%\\}" != "$line" ]; do + line="${line%\\}" + IFS= read -r next || break + line="$line${next#"${next%%[![:space:]]*}"}" + done + trimmed="${line#"${line%%[![:space:]]*}"}" + case "$trimmed" in + "" | "#"*) + pending+=("$line") + continue + ;; + "defaults write "* | "defaults -currentHost write "* | "sudo defaults write "*) + eval "$trimmed" + ;; + *) + if [ "$mode" = remainder ]; then + flush_pending + printf '%s\n' "$line" + else + pending=() + fi + ;; + esac +done <"$SOURCE" +``` + +- [ ] **Step 2: Make it executable and generate the table** + +```bash +chmod +x scripts/migrate-macos-defaults.sh +./scripts/migrate-macos-defaults.sh > macos-defaults +``` + +- [ ] **Step 3: Verify no `defaults write` line was dropped** + +```bash +declared=$(grep -cE '^[[:space:]]*(sudo )?defaults (-currentHost )?write ' .macos) +rows=$(grep -cvE '^[[:space:]]*(#|$)' macos-defaults) +echo "declared=$declared rows=$rows" +[ "$declared" = "$rows" ] && echo "no rows dropped" +``` + +Expected: `declared=217 rows=217 / no rows dropped`. If the counts differ, a `defaults write` form is unhandled; find it by diffing the domain/key pairs rather than guessing. + +- [ ] **Step 4: Verify the table parses** + +```bash +./scripts/macos-defaults.sh check +``` + +Expected: `ok: 217 rows in /macos-defaults`. A failure here names the offending line; the likely causes are a tab that survived a continuation join or a container row that missed its `noaudit=complex`. + +- [ ] **Step 5: Verify the marker counts match the design probe** + +```bash +for r in tcc unset complex; do printf '%-8s %s\n' "$r" "$(grep -c "noaudit=$r$" macos-defaults)"; done +grep -cvE '^[[:space:]]*(#|$)' macos-defaults +``` + +Expected: `tcc 40`, `unset 9`, `complex 11`, total 217. These are the numbers the spec's probe recorded. A material difference means the machine changed since the probe, which is worth reading before continuing rather than accepting silently. + +- [ ] **Step 6: Read the generated table** + +Skim `macos-defaults` end to end. Confirm each row's comment still sits above the right row and that no comment belonging to an imperative line (the `PlistBuddy` block, `chflags`, `nvram`) came across. Fix anything misplaced by hand; the generator's job was the rows, not editorial judgment. + +- [ ] **Step 7: Commit** + +```bash +git add scripts/migrate-macos-defaults.sh macos-defaults +git commit -m "Generate the macos-defaults table from .macos + +217 rows transcribed mechanically, with each setting's comment carried +across. Container types get noaudit=complex; keys that will not read get +noaudit=tcc when the whole domain is unreadable and noaudit=unset when only +the key is absent. + +.macos is untouched here, so the two files declare the same settings until +the next commit strips it." +``` + +--- + +### Task 6: Seed from the live machine and resolve the four drifts + +Machine-specific and run once. Its test is `audit` exiting 0. + +**Files:** +- Modify: `macos-defaults` +- Modify: `docs/superpowers/specs/2026-08-25-macos-defaults-declarative-design.md` (record the TCC write finding) + +**Interfaces:** +- Consumes: `audit`, `apply`, `accept` from Tasks 2-4. +- Produces: a table whose `audit` exits 0 on this machine. + +- [ ] **Step 1: Capture the baseline audit** + +```bash +./scripts/macos-defaults.sh audit > /tmp/audit-before.txt || true +for label in ok drift missing skip; do printf '%-8s %s\n' "$label" "$(grep -c "^$label:" /tmp/audit-before.txt)"; done +``` + +Expected: `ok 153`, `drift 4`, `missing 0`, `skip 60`. `missing` should be zero because Task 5 already marked every unreadable key. + +- [ ] **Step 2: Confirm the four drifts are the ones the spec predicted** + +```bash +grep '^drift:' /tmp/audit-before.txt +``` + +Expected, in some order: + +``` +drift: NSGlobalDomain AppleLocale want=en_US@currency=USD live=en_US@currency=usd +drift: com.apple.ActivityMonitor ShowCategory want=0 live=100 +drift: com.apple.ActivityMonitor OpenMainWindow want=1 live=0 +drift: .GlobalPreferences com.apple.mouse.scaling want=-1 live=3 +``` + +A fifth drift is new information, not a bug. Read it and decide before continuing. + +- [ ] **Step 3: Accept the three the machine wins** + +```bash +./scripts/macos-defaults.sh accept NSGlobalDomain AppleLocale +./scripts/macos-defaults.sh accept com.apple.ActivityMonitor ShowCategory +./scripts/macos-defaults.sh accept com.apple.ActivityMonitor OpenMainWindow +git diff macos-defaults +``` + +Expected: exactly three changed rows, each taking the live value. + +- [ ] **Step 4: Reapply the one the table wins** + +Mouse acceleration is the setting this whole change exists to catch, so the table wins rather than the machine. + +```bash +./scripts/macos-defaults.sh apply .GlobalPreferences com.apple.mouse.scaling +defaults read .GlobalPreferences com.apple.mouse.scaling +``` + +Expected: `write:` on the apply, then `-1` from the read. + +- [ ] **Step 5: Verify audit is green** + +```bash +./scripts/macos-defaults.sh audit > /tmp/audit-after.txt; echo "exit=$?" +for label in ok drift missing skip; do printf '%-8s %s\n' "$label" "$(grep -c "^$label:" /tmp/audit-after.txt)"; done +``` + +Expected: `exit=0`, `ok 157`, `drift 0`, `missing 0`, `skip 60`. + +- [ ] **Step 6: Ask the owner before probing TCC writes** + +The spec's one open risk: reads to `com.apple.Safari` definitely fail under TCC, but whether `defaults write` still lands is unknown. If writes fail too, 35 Safari settings have not applied on a fresh Mac in years. + +Answering it means writing to a real Safari preference. **Stop and ask before running this.** With approval: + +```bash +defaults read com.apple.Safari AlwaysRestoreSessionAtLaunch; echo "read exit=$?" +defaults write com.apple.Safari AlwaysRestoreSessionAtLaunch -bool true; echo "write exit=$?" +defaults read com.apple.Safari AlwaysRestoreSessionAtLaunch; echo "read-back exit=$?" +``` + +Three outcomes, each with a different consequence: + +| Result | Meaning | Action | +|---|---|---| +| write exits non-zero | `.macos` has not applied Safari settings for years | Record it in the spec; consider dropping the 35 rows or documenting the FDA requirement | +| write exits 0, read-back still fails | The write went somewhere unreadable from this shell | Record it; the rows stay `noaudit=tcc` and apply keeps attempting them | +| write exits 0, read-back succeeds | TCC blocks the domain only until something touches it | Re-run `classify` for the Safari rows; some may promote out of `noaudit=tcc` | + +- [ ] **Step 7: Record the finding in the spec** + +Replace the spec's "Open risk" section with what Step 6 actually showed, including the commands and their exit codes. An open risk that has been answered should stop reading as open. + +- [ ] **Step 8: Commit** + +```bash +git add macos-defaults docs/superpowers/specs/2026-08-25-macos-defaults-declarative-design.md +git commit -m "Seed macos-defaults from the live machine + +Three drifting rows take the machine's value: AppleLocale (macOS +canonicalized the currency code) and two Activity Monitor keys the app +rewrites itself. The fourth, .GlobalPreferences com.apple.mouse.scaling, +goes the other way — the table disables mouse acceleration and the machine +had turned it back on, which is the drift this change exists to catch. + +audit now exits 0: 157 ok, 60 skipped." +``` + +--- + +### Task 7: Strip `.macos`, wire the Makefile, update the docs + +**Files:** +- Modify: `.macos` +- Modify: `Makefile` +- Modify: `.editorconfig` +- Modify: `CLAUDE.md` + +**Interfaces:** +- Consumes: `scripts/migrate-macos-defaults.sh --remainder`, `scripts/macos-defaults.sh check`. +- Produces: `make macos-audit|macos-apply|macos-accept|check-macos-defaults`; `check-macos-defaults` joins `preflight`. + +- [ ] **Step 1: Rewrite `.macos` as the remainder** + +```bash +./scripts/migrate-macos-defaults.sh --remainder > /tmp/macos.remainder +wc -l /tmp/macos.remainder +``` + +Expected: roughly 45 lines. Read it before moving it into place; it should contain the System Settings quit, the sudo keepalive, `nvram`, `systemsetup`, the nine `PlistBuddy` calls, both `chflags`, `lsregister`, the Dock `find -delete`, `tmutil`, the `killall` loop, and the closing echo. + +```bash +cp /tmp/macos.remainder .macos +``` + +- [ ] **Step 2: Restore the shebang and add a pointer** + +`--remainder` drops the shebang along with the leading comments. Put it back and say where the settings went, since the next reader will look here first: + +```bash +#!/usr/bin/env bash + +# ~/.macos — the imperative remainder. Every `defaults write` that used to live +# here is now a row in ./macos-defaults; run `make macos-audit` to compare them +# against the machine. What is left cannot be expressed as a domain/key/value: +# nvram, systemsetup, PlistBuddy, chflags, and the app restarts. +``` + +- [ ] **Step 3: Tidy the remainder by hand** + +Remove any section banner whose settings all moved to the table, and any comment left without a statement under it. This is a ~45 line file; read the whole thing. + +- [ ] **Step 4: Verify `.macos` is still valid shell** + +```bash +bash -n .macos && echo "syntax ok" +grep -c 'defaults write' .macos +``` + +Expected: `syntax ok`, and `0` uncommented `defaults write` lines. Commented-out examples may remain; check with `grep -n 'defaults write' .macos` that every hit starts with `#`. + +- [ ] **Step 5: Add the Makefile targets** + +Replace the existing `macos:` target with: + +```make +macos-audit: + @./scripts/macos-defaults.sh audit + +macos-apply: + ./scripts/macos-defaults.sh apply + +macos-accept: + ./scripts/macos-defaults.sh accept + +check-macos-defaults: + @./scripts/macos-defaults.sh check + +macos: macos-apply + sh .macos + osascript -e 'tell app "loginwindow" to «event aevtrrst»' +``` + +Add `./test/test_macos_defaults.sh` to the `test:` target, append `check-macos-defaults` to `preflight:`, and add the four new target names to `.PHONY`. + +- [ ] **Step 6: Add the `.editorconfig` stanza** + +```ini +[macos-defaults] +# Columns are tab-separated data, not indentation. +indent_style = unset +``` + +- [ ] **Step 7: Update `CLAUDE.md`** + +Under Commands, after the `deploy.sh` line: + +```markdown +- `./scripts/macos-defaults.sh check|audit|apply|accept [--dry-run] [domain [key]]` — the `macos-defaults` table. `make macos-audit` reports drift and needs no sudo; `make macos-apply` writes; `make macos-accept` rewrites rows to match the machine. `make macos` = apply plus the imperative remainder in `.macos` plus a restart. +``` + +Under Architecture, after the `manifest` + `deploy.sh` bullet: + +```markdown +- **`macos-defaults` + `scripts/macos-defaults.sh`** — tab-delimited (keys contain spaces, so this one is not whitespace-columned like `manifest`): domain, key, type, value, optional status. A `noaudit=tcc|unset|complex` status means apply writes the row but audit cannot check it — Safari and Mail live in TCC-protected containers no shell can read, and `array`/`dict` values have no comparable form. `.macos` keeps only what has no domain/key/value shape. Design and probe numbers: `docs/superpowers/specs/2026-08-25-macos-defaults-declarative-design.md`. +``` + +- [ ] **Step 8: Run the full gate** + +```bash +make preflight +``` + +Expected: `test_deploy.sh`, `test_shell.sh`, and `test_macos_defaults.sh` all pass, and every `check-*` target passes including `check-macos-defaults`. If `check-editorconfig` reports `macos-defaults`, the trailing-whitespace or final-newline rule was violated by the generator. + +- [ ] **Step 9: Verify the whole thing end to end** + +```bash +./scripts/macos-defaults.sh audit; echo "audit exit=$?" +./scripts/macos-defaults.sh --dry-run apply | grep -c '^would:' +``` + +Expected: audit exits 0, and dry-run proposes writes only for the 60 `noaudit` rows (which apply always writes) and nothing else. + +- [ ] **Step 10: Commit** + +```bash +git add .macos Makefile .editorconfig CLAUDE.md +git commit -m "Retire the defaults section of .macos + +.macos drops to its imperative remainder: nvram, systemsetup, PlistBuddy, +chflags, lsregister, tmutil and the app restarts. Everything with a +domain/key/value shape now lives in macos-defaults. + +make macos-audit is the one meant for casual use and needs no sudo; root +owned plists under /Library/Preferences are world-readable, so only apply +ever reaches for it. check-macos-defaults joins preflight, so CI picks it +up on both legs without an ci.yml edit." +``` + +--- + +## Verification Summary + +| Claim | How it is checked | Where | +|---|---|---| +| The parser handles what `deploy.sh`'s would not | `#` inside a value, key with spaces, empty trailing column | Task 1 Step 4 | +| Comparison is not a naive string equality | bool normalization, type drift, missing vs drift | Task 2 Step 5 | +| Apply never prompts for a password in tests | Full suite runs with no `Password:` prompt | Task 3 Step 5 | +| Accept preserves the commentary carried from `.macos` | Comment and blank-line placement asserted | Task 4 Step 5 | +| No setting was lost in transcription | 217 declared = 217 rows; marker counts match the probe | Task 5 Steps 3 and 5 | +| The table describes this machine | `audit` exits 0 | Task 6 Step 5 | +| `.macos` still runs | `bash -n`, zero uncommented `defaults write` | Task 7 Step 4 | +| CI covers all of it on both platforms | `make preflight` | Task 7 Step 8 | From 440f1a12dd1cd0bee92df68e4638cc3e4b7aac3a Mon Sep 17 00:00:00 2001 From: Alan Norton Date: Tue, 25 Aug 2026 11:51:59 -0700 Subject: [PATCH 03/26] Add macos-defaults table parser and check mode Parses the tab-delimited table and validates it, with no machine access yet. Two departures from deploy.sh's parser, both tested: a # only starts a comment at the start of a line, and rows are split by parameter expansion rather than IFS=$'\t' read, which collapses tab runs. --- scripts/macos-defaults.sh | 178 +++++++++++++++++++++++++++ test/test_macos_defaults.sh | 236 ++++++++++++++++++++++++++++++++++++ 2 files changed, 414 insertions(+) create mode 100755 scripts/macos-defaults.sh create mode 100755 test/test_macos_defaults.sh diff --git a/scripts/macos-defaults.sh b/scripts/macos-defaults.sh new file mode 100755 index 00000000..609fb6b8 --- /dev/null +++ b/scripts/macos-defaults.sh @@ -0,0 +1,178 @@ +#!/bin/bash +# Declarative macOS defaults: check/audit/apply/accept the table in ./macos-defaults. +# Spec: docs/superpowers/specs/2026-08-25-macos-defaults-declarative-design.md +set -euf -o pipefail + +DOTS="$(cd "$(dirname "$0")/.." && pwd)" +# Overridable so the test suite can point at a sandboxed table. +TABLE="${MACOS_DEFAULTS_TABLE:-$DOTS/macos-defaults}" +TAB=$'\t' + +dry_run=0 +mode=audit +filter_domain="" +filter_key="" +failures=0 + +usage() { + echo "usage: $0 [--dry-run] [check|audit|apply|accept] [domain [key]]" >&2 +} + +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) + dry_run=1 + shift + ;; + check | audit | apply | accept) + mode="$1" + shift + filter_domain="${1:-}" + if [ $# -gt 0 ]; then shift; fi + filter_key="${1:-}" + if [ $# -gt 0 ]; then shift; fi + if [ $# -gt 0 ]; then + usage + exit 2 + fi + ;; + *) + usage + exit 2 + ;; + esac +done + +t_domain=() +t_key=() +t_type=() +t_value=() +t_status=() + +# Splits on tabs into the global ROW, preserving empty fields. `IFS=$'\t' read` +# cannot be used here: bash classes tab as IFS whitespace, so it collapses runs +# of tabs and drops a leading one, shifting every field left without an error. +split_row() { + local rest="$1" + ROW=() + while :; do + case "$rest" in + *"$TAB"*) + ROW+=("${rest%%"$TAB"*}") + rest="${rest#*"$TAB"}" + ;; + *) + ROW+=("$rest") + break + ;; + esac + done +} + +parse_table() { + local lineno=0 line trimmed status i + if [ ! -f "$TABLE" ]; then + echo "error: table not found at $TABLE" >&2 + exit 1 + fi + while IFS= read -r line || [ -n "$line" ]; do + lineno=$((lineno + 1)) + trimmed="${line#"${line%%[![:space:]]*}"}" + case "$trimmed" in + "" | "#"*) continue ;; + esac + split_row "$line" + if [ "${#ROW[@]}" -lt 4 ] || [ "${#ROW[@]}" -gt 5 ]; then + echo "error: $TABLE line $lineno: expected 4 or 5 tab-separated columns, got ${#ROW[@]}" >&2 + exit 1 + fi + i=0 + while [ "$i" -lt 4 ]; do + if [ -z "${ROW[$i]}" ]; then + echo "error: $TABLE line $lineno: column $((i + 1)) is empty" >&2 + exit 1 + fi + i=$((i + 1)) + done + status="" + if [ "${#ROW[@]}" = 5 ]; then + status="${ROW[4]}" + if [ -z "$status" ]; then + echo "error: $TABLE line $lineno: status column is empty; omit it instead" >&2 + exit 1 + fi + fi + case "${ROW[2]}" in + bool | int | float | string | raw) ;; + array | dict | dict-add | date | data) + case "$status" in + noaudit=*) ;; + *) + echo "error: $TABLE line $lineno: type '${ROW[2]}' cannot be compared; it needs a noaudit= status" >&2 + exit 1 + ;; + esac + ;; + *) + echo "error: $TABLE line $lineno: unknown type '${ROW[2]}'" >&2 + exit 1 + ;; + esac + case "$status" in + "" | noaudit=tcc | noaudit=unset | noaudit=complex | os=?* | host=?*) ;; + *) + echo "error: $TABLE line $lineno: unknown status '$status'" >&2 + exit 1 + ;; + esac + t_domain+=("${ROW[0]}") + t_key+=("${ROW[1]}") + t_type+=("${ROW[2]}") + t_value+=("${ROW[3]}") + t_status+=("$status") + done <"$TABLE" + if [ "${#t_domain[@]}" -eq 0 ]; then + echo "error: $TABLE has no rows" >&2 + exit 1 + fi +} + +row_selected() { + local i="$1" + if [ -n "$filter_domain" ] && [ "${t_domain[$i]}" != "$filter_domain" ]; then + return 1 + fi + if [ -n "$filter_key" ] && [ "${t_key[$i]}" != "$filter_key" ]; then + return 1 + fi + return 0 +} + +main() { + local i n + parse_table + n="${#t_domain[@]}" + if [ "$mode" = check ]; then + if [ "$n" = 1 ]; then + echo "ok: 1 row in $TABLE" + else + echo "ok: $n rows in $TABLE" + fi + return 0 + fi + i=0 + while [ "$i" -lt "$n" ]; do + if row_selected "$i"; then + case "$mode" in + audit) audit_row "$i" ;; + apply) apply_row "$i" ;; + esac + fi + i=$((i + 1)) + done + if [ "$failures" -gt 0 ]; then + exit 1 + fi +} + +main diff --git a/test/test_macos_defaults.sh b/test/test_macos_defaults.sh new file mode 100755 index 00000000..6edb9043 --- /dev/null +++ b/test/test_macos_defaults.sh @@ -0,0 +1,236 @@ +#!/bin/bash +# Tests for scripts/macos-defaults.sh. Table parsing runs on any platform; +# anything that shells out to `defaults` is Darwin-only and skipped elsewhere. +set -euf -o pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BASE="$(mktemp -d "${TMPDIR:-/tmp}/test-macos-defaults.XXXXXX")" +BASE="$(cd "$BASE" && pwd)" +trap 'rm -rf "$BASE"' EXIT + +pass=0 +fail=0 +skipped=0 +sb_count=0 + +ok() { pass=$((pass + 1)); echo "PASS: $1"; } +bad() { fail=$((fail + 1)); echo "FAIL: $1"; } +note() { skipped=$((skipped + 1)); echo "SKIP: $1"; } + +# `defaults` accepts an absolute plist path as a domain, so $DOMAIN keeps every +# write inside the sandbox instead of a real preference domain. +sandbox() { + sb_count=$((sb_count + 1)) + SB="$BASE/$sb_count" + mkdir -p "$SB" + TABLE="$SB/macos-defaults" + DOMAIN="$SB/com.example.test" +} + +mdefaults() { + set +e + out="$(MACOS_DEFAULTS_TABLE="$TABLE" "$ROOT/scripts/macos-defaults.sh" "$@" 2>&1)" + status=$? + set -e +} + +# Emit one tab-delimited row. Written field by field so an empty trailing +# field survives; that case is what the malformed-row tests exercise. +row() { + local first=1 f + for f in "$@"; do + if [ "$first" = 1 ]; then + printf '%s' "$f" + first=0 + else + printf '\t%s' "$f" + fi + done + printf '\n' +} + +darwin_only() { + if [ "$(uname)" != "Darwin" ]; then + note "$1 (not Darwin)" + return 1 + fi + return 0 +} + +test_rejects_unknown_flag() { + sandbox + row NSGlobalDomain SomeKey bool true > "$TABLE" + mdefaults --bogus + if [ "$status" = 2 ] && grep -q "usage:" <<<"$out"; then + ok "unknown flag exits 2 with usage" + else + bad "unknown flag exits 2 with usage (status=$status, out=$out)" + fi +} + +test_rejects_missing_table() { + sandbox + mdefaults check + if [ "$status" = 1 ] && grep -q "table not found" <<<"$out"; then + ok "missing table exits 1" + else + bad "missing table exits 1 (status=$status, out=$out)" + fi +} + +test_rejects_three_columns() { + sandbox + row NSGlobalDomain SomeKey bool > "$TABLE" + mdefaults check + if [ "$status" = 1 ] && grep -q "line 1" <<<"$out"; then + ok "three-column row exits 1 naming the line" + else + bad "three-column row exits 1 naming the line (status=$status, out=$out)" + fi +} + +test_rejects_six_columns() { + sandbox + row NSGlobalDomain SomeKey bool true noaudit=tcc surprise > "$TABLE" + mdefaults check + if [ "$status" = 1 ] && grep -q "line 1" <<<"$out"; then + ok "six-column row exits 1 naming the line" + else + bad "six-column row exits 1 naming the line (status=$status, out=$out)" + fi +} + +# A trailing tab makes an empty fifth field. The format reserves the trailing +# position for `status` precisely so no interior field is ever empty; rejecting +# this keeps that invariant, and .editorconfig forbids the trailing whitespace. +test_rejects_empty_status_column() { + sandbox + printf 'NSGlobalDomain\tSomeKey\tbool\ttrue\t\n' > "$TABLE" + mdefaults check + if [ "$status" = 1 ] && grep -q "status" <<<"$out"; then + ok "empty trailing status column exits 1" + else + bad "empty trailing status column exits 1 (status=$status, out=$out)" + fi +} + +test_rejects_unknown_type() { + sandbox + row NSGlobalDomain SomeKey number 4 > "$TABLE" + mdefaults check + if [ "$status" = 1 ] && grep -q "unknown type" <<<"$out"; then + ok "unknown type exits 1" + else + bad "unknown type exits 1 (status=$status, out=$out)" + fi +} + +test_rejects_unknown_status() { + sandbox + row NSGlobalDomain SomeKey bool true arch=arm64 > "$TABLE" + mdefaults check + if [ "$status" = 1 ] && grep -q "unknown status" <<<"$out"; then + ok "unknown status exits 1" + else + bad "unknown status exits 1 (status=$status, out=$out)" + fi +} + +# `audit` has no way to compare a container value, so a container row without a +# noaudit marker would report drift forever. +test_rejects_container_type_without_noaudit() { + sandbox + row com.apple.terminal StringEncodings array 4 > "$TABLE" + mdefaults check + if [ "$status" = 1 ] && grep -q "noaudit" <<<"$out"; then + ok "container type without noaudit exits 1" + else + bad "container type without noaudit exits 1 (status=$status, out=$out)" + fi +} + +test_rejects_empty_table() { + sandbox + printf '# comments only\n\n' > "$TABLE" + mdefaults check + if [ "$status" = 1 ] && grep -q "no rows" <<<"$out"; then + ok "comment-only table exits 1" + else + bad "comment-only table exits 1 (status=$status, out=$out)" + fi +} + +# deploy.sh strips from the first `#` to end of line. That would truncate this +# value, which is why this parser only treats a leading `#` as a comment. +test_hash_inside_value_is_not_a_comment() { + sandbox + row com.example.app Greeting string "hello # world" > "$TABLE" + mdefaults check + if [ "$status" = 0 ] && grep -q "1 row" <<<"$out"; then + ok "a # inside a value does not start a comment" + else + bad "a # inside a value does not start a comment (status=$status, out=$out)" + fi +} + +test_indented_comment_is_a_comment() { + sandbox + { + printf ' # indented\n' + row NSGlobalDomain SomeKey bool true + } > "$TABLE" + mdefaults check + if [ "$status" = 0 ] && grep -q "1 row" <<<"$out"; then + ok "an indented # line is a comment" + else + bad "an indented # line is a comment (status=$status, out=$out)" + fi +} + +# `com.apple.print.PrintingPrefs "Quit When Finished"` is a real row, and it is +# the reason this file is tab-delimited rather than whitespace-columned. +test_key_with_spaces_parses() { + sandbox + row com.apple.print.PrintingPrefs "Quit When Finished" bool true > "$TABLE" + mdefaults check + if [ "$status" = 0 ] && grep -q "1 row" <<<"$out"; then + ok "a key containing spaces parses" + else + bad "a key containing spaces parses (status=$status, out=$out)" + fi +} + +test_check_counts_rows() { + sandbox + { + printf '# a banner\n' + row NSGlobalDomain FirstKey bool true + printf '\n' + row com.apple.Safari SecondKey bool true noaudit=tcc + } > "$TABLE" + mdefaults check + if [ "$status" = 0 ] && grep -q "2 rows" <<<"$out"; then + ok "check counts data rows, ignoring comments and blanks" + else + bad "check counts data rows, ignoring comments and blanks (status=$status, out=$out)" + fi +} + +# --- runner ----------------------------------------------------------------- +test_rejects_unknown_flag +test_rejects_missing_table +test_rejects_three_columns +test_rejects_six_columns +test_rejects_empty_status_column +test_rejects_unknown_type +test_rejects_unknown_status +test_rejects_container_type_without_noaudit +test_rejects_empty_table +test_hash_inside_value_is_not_a_comment +test_indented_comment_is_a_comment +test_key_with_spaces_parses +test_check_counts_rows + +echo +echo "$pass passed, $fail failed, $skipped skipped" +[ "$fail" -eq 0 ] From d4bde1f3ca838479bfe9ac4f9d661a6ff14d39d3 Mon Sep 17 00:00:00 2001 From: Alan Norton Date: Tue, 25 Aug 2026 11:56:36 -0700 Subject: [PATCH 04/26] Add audit mode to macos-defaults Compares each row against the live machine. Four rules the naive string compare gets wrong, each with a test: booleans normalize (defaults stores 0/1, the table reads true/false), a changed storage type is drift, an absent key is missing rather than drift, and noaudit rows never reach the exit code. os= and host= conditions are implemented rather than merely parsed. The spec reserved them as unused, but a status the parser accepts and then ignores would apply an os=Linux row on Darwin. --- scripts/macos-defaults.sh | 110 ++++++++++++++++++++++++++++++-- test/test_macos_defaults.sh | 121 ++++++++++++++++++++++++++++++++++++ 2 files changed, 227 insertions(+), 4 deletions(-) diff --git a/scripts/macos-defaults.sh b/scripts/macos-defaults.sh index 609fb6b8..7ea0035d 100755 --- a/scripts/macos-defaults.sh +++ b/scripts/macos-defaults.sh @@ -148,6 +148,104 @@ row_selected() { return 0 } +source "$DOTS/scripts/host-id.sh" +os="$(os_id)" +host="$(host_id)" + +condition_matches() { + case "$1" in + "" | noaudit=*) return 0 ;; + os=*) [ "${1#os=}" = "$os" ] ;; + host=*) [ "${1#host=}" = "$host" ] ;; + *) return 1 ;; + esac +} + +defaults_read() { + local domain="$1" key="$2" + case "$domain" in + currentHost:*) defaults -currentHost read "${domain#currentHost:}" "$key" 2>/dev/null ;; + *) defaults read "$domain" "$key" 2>/dev/null ;; + esac +} + +defaults_read_type() { + local domain="$1" key="$2" out + case "$domain" in + currentHost:*) + out="$(defaults -currentHost read-type "${domain#currentHost:}" "$key" 2>/dev/null)" || return 1 + ;; + *) + out="$(defaults read-type "$domain" "$key" 2>/dev/null)" || return 1 + ;; + esac + printf '%s\n' "${out#Type is }" +} + +table_type_of() { + case "$1" in + boolean) printf 'bool\n' ;; + integer) printf 'int\n' ;; + dictionary) printf 'dict\n' ;; + *) printf '%s\n' "$1" ;; + esac +} + +normalize() { + case "$1" in + bool) + case "$2" in + true | TRUE | True | YES | Yes | yes | 1) printf '1\n' ;; + false | FALSE | False | NO | No | no | 0) printf '0\n' ;; + *) printf '%s\n' "$2" ;; + esac + ;; + *) printf '%s\n' "$2" ;; + esac +} + +audit_row() { + local i="$1" + local domain="${t_domain[$i]}" key="${t_key[$i]}" type="${t_type[$i]}" + local value="${t_value[$i]}" status="${t_status[$i]}" + local live want live_type + + case "$status" in + noaudit=*) + echo "skip: $domain $key (${status#noaudit=})" + return 0 + ;; + esac + + if ! live="$(defaults_read "$domain" "$key")"; then + echo "missing: $domain $key" + failures=$((failures + 1)) + return 0 + fi + + # A `raw` row is written with no type flag, so `defaults` infers the stored + # type and the table has no claim to assert against it. + if [ "$type" != raw ]; then + if live_type="$(defaults_read_type "$domain" "$key")"; then + live_type="$(table_type_of "$live_type")" + if [ "$live_type" != "$type" ]; then + echo "drift: $domain $key type want=$type live=$live_type" + failures=$((failures + 1)) + return 0 + fi + fi + fi + + want="$(normalize "$type" "$value")" + live="$(normalize "$type" "$live")" + if [ "$want" = "$live" ]; then + echo "ok: $domain $key" + else + echo "drift: $domain $key want=$want live=$live" + failures=$((failures + 1)) + fi +} + main() { local i n parse_table @@ -163,10 +261,14 @@ main() { i=0 while [ "$i" -lt "$n" ]; do if row_selected "$i"; then - case "$mode" in - audit) audit_row "$i" ;; - apply) apply_row "$i" ;; - esac + if ! condition_matches "${t_status[$i]}"; then + echo "skip: ${t_domain[$i]} ${t_key[$i]} (${t_status[$i]})" + else + case "$mode" in + audit) audit_row "$i" ;; + apply) apply_row "$i" ;; + esac + fi fi i=$((i + 1)) done diff --git a/test/test_macos_defaults.sh b/test/test_macos_defaults.sh index 6edb9043..330fa227 100755 --- a/test/test_macos_defaults.sh +++ b/test/test_macos_defaults.sh @@ -216,6 +216,119 @@ test_check_counts_rows() { fi } +# A row whose condition does not match is skipped before any `defaults` call, +# so this case needs no Darwin gate. +test_audit_skips_unmatched_condition() { + sandbox + row NSGlobalDomain SomeKey bool true os=NoSuchOS > "$TABLE" + mdefaults audit + if [ "$status" = 0 ] && grep -q "^skip: " <<<"$out"; then + ok "unmatched os condition is skipped" + else + bad "unmatched os condition is skipped (status=$status, out=$out)" + fi +} + +test_audit_ok_when_value_matches() { + darwin_only "audit reports ok when the live value matches" || return 0 + sandbox + defaults write "$DOMAIN" Flag -bool true + row "$DOMAIN" Flag bool true > "$TABLE" + mdefaults audit + if [ "$status" = 0 ] && grep -q "^ok: " <<<"$out"; then + ok "audit reports ok when the live value matches" + else + bad "audit reports ok when the live value matches (status=$status, out=$out)" + fi +} + +# `defaults` stores booleans as 0/1 but the table keeps them readable as +# true/false, so every bool comparison depends on normalizing both sides. +test_audit_normalizes_bools() { + darwin_only "audit normalizes true against a stored 1" || return 0 + sandbox + defaults write "$DOMAIN" Flag -bool true + row "$DOMAIN" Flag bool YES > "$TABLE" + mdefaults audit + if [ "$status" = 0 ] && grep -q "^ok: " <<<"$out"; then + ok "audit normalizes true/YES/1 to the same value" + else + bad "audit normalizes true/YES/1 to the same value (status=$status, out=$out)" + fi +} + +test_audit_reports_drift() { + darwin_only "audit reports drift with both values" || return 0 + sandbox + defaults write "$DOMAIN" Count -int 3 + row "$DOMAIN" Count int 7 > "$TABLE" + mdefaults audit + if [ "$status" = 1 ] && grep -q "^drift: .*want=7 live=3" <<<"$out"; then + ok "audit reports drift with want and live" + else + bad "audit reports drift with want and live (status=$status, out=$out)" + fi +} + +# An absent key and a mismatched key need different fixes, so they get +# different labels; conflating them made the design probe unreadable. +test_audit_reports_missing() { + darwin_only "audit reports an absent key as missing" || return 0 + sandbox + defaults write "$DOMAIN" Other -int 1 + row "$DOMAIN" Count int 7 > "$TABLE" + mdefaults audit + if [ "$status" = 1 ] && grep -q "^missing: " <<<"$out" && ! grep -q "^drift: " <<<"$out"; then + ok "audit reports an absent key as missing, not drift" + else + bad "audit reports an absent key as missing, not drift (status=$status, out=$out)" + fi +} + +test_audit_reports_type_drift() { + darwin_only "audit reports a changed storage type as drift" || return 0 + sandbox + defaults write "$DOMAIN" Count -string 7 + row "$DOMAIN" Count int 7 > "$TABLE" + mdefaults audit + if [ "$status" = 1 ] && grep -q "^drift: .*type want=int live=string" <<<"$out"; then + ok "audit reports a changed storage type as drift" + else + bad "audit reports a changed storage type as drift (status=$status, out=$out)" + fi +} + +# noaudit rows must never influence the exit code, or every audit on a machine +# with Safari settings would exit non-zero forever. +test_audit_skips_noaudit_rows_without_failing() { + darwin_only "audit skips noaudit rows and still exits 0" || return 0 + sandbox + row "$DOMAIN" Missing bool true noaudit=tcc > "$TABLE" + mdefaults audit + if [ "$status" = 0 ] && grep -q "^skip: .*(tcc)" <<<"$out"; then + ok "audit skips noaudit rows and still exits 0" + else + bad "audit skips noaudit rows and still exits 0 (status=$status, out=$out)" + fi +} + +test_audit_filters_by_domain_and_key() { + darwin_only "audit honors the domain and key filter" || return 0 + sandbox + defaults write "$DOMAIN" First -bool true + defaults write "$DOMAIN" Second -bool true + { + row "$DOMAIN" First bool true + row "$DOMAIN" Second bool true + } > "$TABLE" + mdefaults audit "$DOMAIN" Second + if [ "$status" = 0 ] && grep -q "Second" <<<"$out" && ! grep -q "First" <<<"$out"; then + ok "audit honors the domain and key filter" + else + bad "audit honors the domain and key filter (status=$status, out=$out)" + fi +} + # --- runner ----------------------------------------------------------------- test_rejects_unknown_flag test_rejects_missing_table @@ -230,6 +343,14 @@ test_hash_inside_value_is_not_a_comment test_indented_comment_is_a_comment test_key_with_spaces_parses test_check_counts_rows +test_audit_skips_unmatched_condition +test_audit_ok_when_value_matches +test_audit_normalizes_bools +test_audit_reports_drift +test_audit_reports_missing +test_audit_reports_type_drift +test_audit_skips_noaudit_rows_without_failing +test_audit_filters_by_domain_and_key echo echo "$pass passed, $fail failed, $skipped skipped" From 182bd85d1c515242459844534bb6a1d291ddedba Mon Sep 17 00:00:00 2001 From: Alan Norton Date: Tue, 25 Aug 2026 12:09:18 -0700 Subject: [PATCH 05/26] Add apply mode to macos-defaults Writes only rows whose live value differs, except noaudit rows, which are always written since audit cannot tell whether they need it. sudo is chosen from the plist's writability, not the path prefix, so the sandboxed tests never prompt. Container rows carry a literal argument tail and are the only ones eval'd; an array value has no type/value form. --- scripts/macos-defaults.sh | 70 +++++++++++++++++++++++++++ test/test_macos_defaults.sh | 96 +++++++++++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+) diff --git a/scripts/macos-defaults.sh b/scripts/macos-defaults.sh index 7ea0035d..402499fe 100755 --- a/scripts/macos-defaults.sh +++ b/scripts/macos-defaults.sh @@ -246,6 +246,76 @@ audit_row() { fi } +# Decided from the plist's writability rather than the path prefix: the test +# suite uses absolute-path domains under mktemp, which are writable and must +# not reach for sudo. +needs_sudo() { + local domain="$1" + case "$domain" in + /*) ;; + *) return 1 ;; + esac + [ -e "$domain.plist" ] && [ ! -w "$domain.plist" ] +} + +write_row() { + local domain="$1" key="$2" type="$3" value="$4" + local host_flag="" target="$domain" sudo_cmd="" + + case "$domain" in + currentHost:*) + host_flag="-currentHost" + target="${domain#currentHost:}" + ;; + esac + if needs_sudo "$target"; then + sudo_cmd="sudo" + fi + + case "$type" in + array | dict | dict-add | date | data) + # Container values carry their own quoted argument tail, which only + # the shell can re-split. Scalar rows never take this branch. + eval "$sudo_cmd defaults $host_flag write \"\$target\" \"\$key\" -$type $value" + ;; + raw) + $sudo_cmd defaults $host_flag write "$target" "$key" "$value" + ;; + *) + $sudo_cmd defaults $host_flag write "$target" "$key" "-$type" "$value" + ;; + esac +} + +apply_row() { + local i="$1" + local domain="${t_domain[$i]}" key="${t_key[$i]}" type="${t_type[$i]}" + local value="${t_value[$i]}" status="${t_status[$i]}" + local prefix="" live + + if [ "$dry_run" = 1 ]; then + prefix="would: " + fi + + case "$status" in + noaudit=*) ;; + *) + if live="$(defaults_read "$domain" "$key")"; then + if [ "$(normalize "$type" "$live")" = "$(normalize "$type" "$value")" ]; then + echo "ok: $domain $key" + return 0 + fi + fi + ;; + esac + + echo "${prefix}write: $domain $key = $value" + if [ "$dry_run" = 1 ]; then + return 0 + fi + write_row "$domain" "$key" "$type" "$value" +} + main() { local i n parse_table diff --git a/test/test_macos_defaults.sh b/test/test_macos_defaults.sh index 330fa227..96afb36f 100755 --- a/test/test_macos_defaults.sh +++ b/test/test_macos_defaults.sh @@ -329,6 +329,95 @@ test_audit_filters_by_domain_and_key() { fi } +test_apply_writes_missing_key() { + darwin_only "apply writes a key that is absent" || return 0 + sandbox + row "$DOMAIN" Count int 7 > "$TABLE" + mdefaults apply + if [ "$status" = 0 ] && [ "$(defaults read "$DOMAIN" Count)" = 7 ] \ + && grep -q "^write: " <<<"$out"; then + ok "apply writes a key that is absent" + else + bad "apply writes a key that is absent (status=$status, out=$out)" + fi +} + +test_apply_is_idempotent() { + darwin_only "a second apply is a no-op reported as ok" || return 0 + sandbox + row "$DOMAIN" Count int 7 > "$TABLE" + mdefaults apply + mdefaults apply + if [ "$status" = 0 ] && grep -q "^ok: " <<<"$out" && ! grep -q "^write: " <<<"$out"; then + ok "a second apply is a no-op reported as ok" + else + bad "a second apply is a no-op reported as ok (status=$status, out=$out)" + fi +} + +test_dry_run_reports_would_write() { + darwin_only "dry-run prefixes its decisions with would:" || return 0 + sandbox + row "$DOMAIN" Count int 7 > "$TABLE" + mdefaults --dry-run apply + if [ "$status" = 0 ] && grep -q "^would: write: " <<<"$out"; then + ok "dry-run prefixes its decisions with would:" + else + bad "dry-run prefixes its decisions with would: (status=$status, out=$out)" + fi +} + +test_dry_run_changes_nothing() { + darwin_only "dry-run creates no plist" || return 0 + sandbox + row "$DOMAIN" Count int 7 > "$TABLE" + mdefaults --dry-run apply + if [ "$status" = 0 ] && [ ! -e "$DOMAIN.plist" ]; then + ok "dry-run creates no plist" + else + bad "dry-run creates no plist (status=$status, out=$out)" + fi +} + +# audit cannot tell whether a noaudit row needs writing, so apply always writes +# one. Without this, the 40 TCC rows would never be applied on a fresh Mac. +test_apply_writes_noaudit_rows() { + darwin_only "apply writes noaudit rows unconditionally" || return 0 + sandbox + row "$DOMAIN" Count int 7 noaudit=unset > "$TABLE" + mdefaults apply + if [ "$status" = 0 ] && [ "$(defaults read "$DOMAIN" Count)" = 7 ]; then + ok "apply writes noaudit rows unconditionally" + else + bad "apply writes noaudit rows unconditionally (status=$status, out=$out)" + fi +} + +# An array value cannot be expressed as a type/value pair, so container rows +# carry a literal argument tail and are the only rows that get eval'd. +test_apply_writes_container_value() { + darwin_only "apply writes an array row through its literal argument tail" || return 0 + sandbox + row "$DOMAIN" Langs array '"en" "fr"' noaudit=complex > "$TABLE" + mdefaults apply + if [ "$status" = 0 ] && [ "$(defaults read "$DOMAIN" Langs | tr -d '\n ')" = "(en,fr)" ]; then + ok "apply writes an array row through its literal argument tail" + else + bad "apply writes an array row through its literal argument tail (status=$status, out=$out)" + fi +} + +test_apply_skips_unmatched_condition() { + sandbox + row NSGlobalDomain SomeKey bool true os=NoSuchOS > "$TABLE" + mdefaults apply + if [ "$status" = 0 ] && grep -q "^skip: " <<<"$out"; then + ok "apply skips an unmatched condition" + else + bad "apply skips an unmatched condition (status=$status, out=$out)" + fi +} + # --- runner ----------------------------------------------------------------- test_rejects_unknown_flag test_rejects_missing_table @@ -351,6 +440,13 @@ test_audit_reports_missing test_audit_reports_type_drift test_audit_skips_noaudit_rows_without_failing test_audit_filters_by_domain_and_key +test_apply_writes_missing_key +test_apply_is_idempotent +test_dry_run_reports_would_write +test_dry_run_changes_nothing +test_apply_writes_noaudit_rows +test_apply_writes_container_value +test_apply_skips_unmatched_condition echo echo "$pass passed, $fail failed, $skipped skipped" From 84690194c603508dc3119216dd94cc11bef21fdd Mon Sep 17 00:00:00 2001 From: Alan Norton Date: Tue, 25 Aug 2026 12:17:34 -0700 Subject: [PATCH 06/26] Add accept mode to macos-defaults Rewrites a row's value and type from what is live, and clears a noaudit=unset marker once the key reads. Rewriting the file wholesale means comments and blank lines have to survive intact, which is tested: those comments are the why carried over from .macos. Also strengthens test_apply_writes_noaudit_rows: it previously wrote an absent key, which an ordinary row would write too, so it did not prove apply writes noaudit rows unconditionally. It now pre-sets a live value that matches the table, so only the unconditional write path reports write: instead of ok:. --- scripts/macos-defaults.sh | 67 ++++++++++++++++++++++ test/test_macos_defaults.sh | 107 +++++++++++++++++++++++++++++++++++- 2 files changed, 172 insertions(+), 2 deletions(-) diff --git a/scripts/macos-defaults.sh b/scripts/macos-defaults.sh index 402499fe..3f8c787e 100755 --- a/scripts/macos-defaults.sh +++ b/scripts/macos-defaults.sh @@ -316,6 +316,69 @@ apply_row() { write_row "$domain" "$key" "$type" "$value" } +run_accept() { + local i n idx live live_type new_status tmp line trimmed + local new_row=() + + n="${#t_domain[@]}" + i=0 + while [ "$i" -lt "$n" ]; do + new_row[$i]="" + if row_selected "$i" && condition_matches "${t_status[$i]}"; then + if live="$(defaults_read "${t_domain[$i]}" "${t_key[$i]}")"; then + new_status="${t_status[$i]}" + # The marker only recorded that the key was unreadable at seed + # time; it just read, so it no longer describes anything. + if [ "$new_status" = "noaudit=unset" ]; then + new_status="" + fi + live_type="${t_type[$i]}" + if [ "$live_type" != raw ] && [ "$new_status" = "" ]; then + if live_type="$(defaults_read_type "${t_domain[$i]}" "${t_key[$i]}")"; then + live_type="$(table_type_of "$live_type")" + else + live_type="${t_type[$i]}" + fi + fi + if [ "$live_type" != "${t_type[$i]}" ] \ + || [ "$(normalize "$live_type" "$live")" != "$(normalize "${t_type[$i]}" "${t_value[$i]}")" ] \ + || [ "$new_status" != "${t_status[$i]}" ]; then + new_row[$i]="${t_domain[$i]}$TAB${t_key[$i]}$TAB$live_type$TAB$live" + if [ -n "$new_status" ]; then + new_row[$i]="${new_row[$i]}$TAB$new_status" + fi + echo "accept: ${t_domain[$i]} ${t_key[$i]} = $live" + fi + fi + fi + i=$((i + 1)) + done + + tmp="$(mktemp "${TMPDIR:-/tmp}/macos-defaults.XXXXXX")" + idx=0 + while IFS= read -r line || [ -n "$line" ]; do + trimmed="${line#"${line%%[![:space:]]*}"}" + case "$trimmed" in + "" | "#"*) + printf '%s\n' "$line" >>"$tmp" + continue + ;; + esac + if [ -n "${new_row[$idx]}" ]; then + printf '%s\n' "${new_row[$idx]}" >>"$tmp" + else + printf '%s\n' "$line" >>"$tmp" + fi + idx=$((idx + 1)) + done <"$TABLE" + + if [ "$dry_run" = 1 ]; then + rm -f "$tmp" + return 0 + fi + mv "$tmp" "$TABLE" +} + main() { local i n parse_table @@ -328,6 +391,10 @@ main() { fi return 0 fi + if [ "$mode" = accept ]; then + run_accept + return 0 + fi i=0 while [ "$i" -lt "$n" ]; do if row_selected "$i"; then diff --git a/test/test_macos_defaults.sh b/test/test_macos_defaults.sh index 96afb36f..b6a9e8b7 100755 --- a/test/test_macos_defaults.sh +++ b/test/test_macos_defaults.sh @@ -380,13 +380,16 @@ test_dry_run_changes_nothing() { } # audit cannot tell whether a noaudit row needs writing, so apply always writes -# one. Without this, the 40 TCC rows would never be applied on a fresh Mac. +# one. The live value is pre-set to MATCH the table here: an ordinary row would +# report ok: and skip, so only an unconditional write reports write:. test_apply_writes_noaudit_rows() { darwin_only "apply writes noaudit rows unconditionally" || return 0 sandbox + defaults write "$DOMAIN" Count -int 7 row "$DOMAIN" Count int 7 noaudit=unset > "$TABLE" mdefaults apply - if [ "$status" = 0 ] && [ "$(defaults read "$DOMAIN" Count)" = 7 ]; then + if [ "$status" = 0 ] && grep -q "^write: " <<<"$out" && ! grep -q "^ok: " <<<"$out" \ + && [ "$(defaults read "$DOMAIN" Count)" = 7 ]; then ok "apply writes noaudit rows unconditionally" else bad "apply writes noaudit rows unconditionally (status=$status, out=$out)" @@ -418,6 +421,100 @@ test_apply_skips_unmatched_condition() { fi } +test_accept_updates_a_drifting_value() { + darwin_only "accept rewrites a drifting row to the live value" || return 0 + sandbox + defaults write "$DOMAIN" Count -int 3 + row "$DOMAIN" Count int 7 > "$TABLE" + mdefaults accept + if [ "$status" = 0 ] && grep -q " int 3$" "$TABLE"; then + ok "accept rewrites a drifting row to the live value" + else + bad "accept rewrites a drifting row to the live value (status=$status, table=$(cat "$TABLE"))" + fi +} + +# The comments carried over from .macos are the most valuable thing in the +# table, and accept rewrites the file wholesale. +test_accept_preserves_comments_and_blanks() { + darwin_only "accept preserves comments and blank lines" || return 0 + sandbox + defaults write "$DOMAIN" Count -int 3 + { + printf '# a banner\n' + printf '\n' + printf '# why this setting exists\n' + row "$DOMAIN" Count int 7 + } > "$TABLE" + mdefaults accept + if [ "$status" = 0 ] && [ "$(head -1 "$TABLE")" = "# a banner" ] \ + && [ "$(sed -n 3p "$TABLE")" = "# why this setting exists" ] \ + && [ -z "$(sed -n 2p "$TABLE")" ]; then + ok "accept preserves comments and blank lines" + else + bad "accept preserves comments and blank lines (status=$status, table=$(cat "$TABLE"))" + fi +} + +test_accept_leaves_matching_rows_alone() { + darwin_only "accept leaves a matching row byte-identical" || return 0 + sandbox + defaults write "$DOMAIN" Count -int 7 + row "$DOMAIN" Count int 7 > "$TABLE" + before="$(cksum < "$TABLE")" + mdefaults accept + if [ "$status" = 0 ] && [ "$(cksum < "$TABLE")" = "$before" ]; then + ok "accept leaves a matching row byte-identical" + else + bad "accept leaves a matching row byte-identical (status=$status, out=$out)" + fi +} + +# The 9 noaudit=unset rows exist because their key was absent at seed time. Once +# a key becomes readable the marker is stale, and only accept can clear it. +test_accept_promotes_a_readable_unset_row() { + darwin_only "accept clears noaudit=unset once the key reads" || return 0 + sandbox + defaults write "$DOMAIN" Count -int 3 + row "$DOMAIN" Count int 7 noaudit=unset > "$TABLE" + mdefaults accept + if [ "$status" = 0 ] && ! grep -q "noaudit=unset" "$TABLE" && grep -q " int 3$" "$TABLE"; then + ok "accept clears noaudit=unset once the key reads" + else + bad "accept clears noaudit=unset once the key reads (status=$status, table=$(cat "$TABLE"))" + fi +} + +test_accept_updates_the_type_when_it_drifts() { + darwin_only "accept rewrites the type column too" || return 0 + sandbox + defaults write "$DOMAIN" Count -string seven + row "$DOMAIN" Count int 7 > "$TABLE" + mdefaults accept + if [ "$status" = 0 ] && grep -q " string seven$" "$TABLE"; then + ok "accept rewrites the type column too" + else + bad "accept rewrites the type column too (status=$status, table=$(cat "$TABLE"))" + fi +} + +test_accept_honors_the_filter() { + darwin_only "accept honors the domain and key filter" || return 0 + sandbox + defaults write "$DOMAIN" First -int 1 + defaults write "$DOMAIN" Second -int 2 + { + row "$DOMAIN" First int 9 + row "$DOMAIN" Second int 9 + } > "$TABLE" + mdefaults accept "$DOMAIN" Second + if [ "$status" = 0 ] && grep -q "First int 9$" "$TABLE" && grep -q "Second int 2$" "$TABLE"; then + ok "accept honors the domain and key filter" + else + bad "accept honors the domain and key filter (status=$status, table=$(cat "$TABLE"))" + fi +} + # --- runner ----------------------------------------------------------------- test_rejects_unknown_flag test_rejects_missing_table @@ -447,6 +544,12 @@ test_dry_run_changes_nothing test_apply_writes_noaudit_rows test_apply_writes_container_value test_apply_skips_unmatched_condition +test_accept_updates_a_drifting_value +test_accept_preserves_comments_and_blanks +test_accept_leaves_matching_rows_alone +test_accept_promotes_a_readable_unset_row +test_accept_updates_the_type_when_it_drifts +test_accept_honors_the_filter echo echo "$pass passed, $fail failed, $skipped skipped" From ae36e0467c5a69f78d067492f0cb639e317caf85 Mon Sep 17 00:00:00 2001 From: Alan Norton Date: Tue, 25 Aug 2026 12:28:56 -0700 Subject: [PATCH 07/26] Guard accept against noaudit=tcc and noaudit=complex rows run_accept had no branch excluding tcc/complex rows from the rewrite decision. A tcc key never reads, so that path was inert, but a complex row's value column is an eval argument tail that defaults_read can never match, so the comparison always differed and accept rewrote it with a multi-line plist dump, splicing newlines into a one-row-per-line file and breaking every row after it. Adds accept_candidate to exclude both statuses before the compare; noaudit=unset promotion is unaffected since it does not carry either marker. Also points the run_accept temp file at $TABLE.XXXXXX instead of $TMPDIR, so the final mv is a same-filesystem rename rather than a copy-then-unlink that could leave a partial table on a mid-copy crash. Covers both regressions with new tests. The wc -l check in the complex-row test uses -eq rather than =, since BSD wc pads its count with leading spaces even through a redirect, which would fail a string comparison against a bare 1 regardless of correctness. --- scripts/macos-defaults.sh | 16 ++++++++++++++-- test/test_macos_defaults.sh | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/scripts/macos-defaults.sh b/scripts/macos-defaults.sh index 3f8c787e..958f5d05 100755 --- a/scripts/macos-defaults.sh +++ b/scripts/macos-defaults.sh @@ -316,6 +316,18 @@ apply_row() { write_row "$domain" "$key" "$type" "$value" } +# A tcc row's key does not read at all, and a complex row's value column is an +# eval argument tail rather than a serialization `defaults read` could match, so +# the compare would always differ and rewrite the row with a multi-line plist +# dump — splicing newlines into a one-row-per-line file. +accept_candidate() { + local i="$1" + case "${t_status[$i]}" in + noaudit=tcc | noaudit=complex) return 1 ;; + esac + row_selected "$i" && condition_matches "${t_status[$i]}" +} + run_accept() { local i n idx live live_type new_status tmp line trimmed local new_row=() @@ -324,7 +336,7 @@ run_accept() { i=0 while [ "$i" -lt "$n" ]; do new_row[$i]="" - if row_selected "$i" && condition_matches "${t_status[$i]}"; then + if accept_candidate "$i"; then if live="$(defaults_read "${t_domain[$i]}" "${t_key[$i]}")"; then new_status="${t_status[$i]}" # The marker only recorded that the key was unreadable at seed @@ -354,7 +366,7 @@ run_accept() { i=$((i + 1)) done - tmp="$(mktemp "${TMPDIR:-/tmp}/macos-defaults.XXXXXX")" + tmp="$(mktemp "$TABLE.XXXXXX")" idx=0 while IFS= read -r line || [ -n "$line" ]; do trimmed="${line#"${line%%[![:space:]]*}"}" diff --git a/test/test_macos_defaults.sh b/test/test_macos_defaults.sh index b6a9e8b7..566adeb1 100755 --- a/test/test_macos_defaults.sh +++ b/test/test_macos_defaults.sh @@ -515,6 +515,40 @@ test_accept_honors_the_filter() { fi } +# A complex row's value column is an eval argument tail, and `defaults read` +# returns a multi-line plist dump for an array. Rewriting the row splices those +# newlines into the table and breaks every row after it. +test_accept_never_rewrites_a_readable_complex_row() { + darwin_only "accept leaves a readable complex row byte-identical" || return 0 + sandbox + defaults write "$DOMAIN" Langs -array en fr + row "$DOMAIN" Langs array '"en" "fr"' noaudit=complex > "$TABLE" + before="$(cksum < "$TABLE")" + mdefaults accept + if [ "$status" = 0 ] && [ "$(cksum < "$TABLE")" = "$before" ] \ + && [ "$(wc -l < "$TABLE")" -eq 1 ]; then + ok "accept leaves a readable complex row byte-identical" + else + bad "accept leaves a readable complex row byte-identical (status=$status, table=$(cat "$TABLE"))" + fi +} + +# The tcc marker records that a domain is unreadable under TCC, which a sandbox +# cannot simulate. A readable key with the marker set is the same code path. +test_accept_never_rewrites_a_tcc_row() { + darwin_only "accept leaves a noaudit=tcc row untouched even when the key reads" || return 0 + sandbox + defaults write "$DOMAIN" Count -int 3 + row "$DOMAIN" Count int 7 noaudit=tcc > "$TABLE" + before="$(cksum < "$TABLE")" + mdefaults accept + if [ "$status" = 0 ] && [ "$(cksum < "$TABLE")" = "$before" ]; then + ok "accept leaves a noaudit=tcc row untouched even when the key reads" + else + bad "accept leaves a noaudit=tcc row untouched even when the key reads (status=$status, table=$(cat "$TABLE"))" + fi +} + # --- runner ----------------------------------------------------------------- test_rejects_unknown_flag test_rejects_missing_table @@ -550,6 +584,8 @@ test_accept_leaves_matching_rows_alone test_accept_promotes_a_readable_unset_row test_accept_updates_the_type_when_it_drifts test_accept_honors_the_filter +test_accept_never_rewrites_a_readable_complex_row +test_accept_never_rewrites_a_tcc_row echo echo "$pass passed, $fail failed, $skipped skipped" From 5d36c0bb5998f071064dbd58dea76100f5310180 Mon Sep 17 00:00:00 2001 From: Alan Norton Date: Tue, 25 Aug 2026 12:35:22 -0700 Subject: [PATCH 08/26] Correct the defaults-write count from 217 to 218 The design probe grepped only matching lines into a scratch file before sourcing it, so the trailing backslash on .macos:342 (FXInfoPanesExpanded -dict) joined it to the next matching statement rather than to its own continuation lines. Two statements merged into one and the probe undercounted by one. The swallowed row is com.apple.dock mouse-over-hilite-stack, which reads back as a boolean 1 and matches what .macos declares, so it lands in the healthy bucket: auditable-and-matching goes 153 -> 154 and rows carrying no status go 157 -> 158. The marker counts are unchanged at tcc 40, unset 9, complex 11. The real generator walks the file itself and joins true continuations, so it was always going to emit 218; only the plan's expected numbers were wrong, and they would have halted the migration task on a false alarm. Co-Authored-By: Claude Opus 5 (1M context) --- .../2026-08-25-macos-defaults-declarative.md | 24 +++++++++---------- ...08-25-macos-defaults-declarative-design.md | 14 +++++------ 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/docs/superpowers/plans/2026-08-25-macos-defaults-declarative.md b/docs/superpowers/plans/2026-08-25-macos-defaults-declarative.md index b011f75a..0d4110af 100644 --- a/docs/superpowers/plans/2026-08-25-macos-defaults-declarative.md +++ b/docs/superpowers/plans/2026-08-25-macos-defaults-declarative.md @@ -2,7 +2,7 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Replace the 217 `defaults write` lines in `.macos` with a reviewable table and a script that reports when macOS has drifted away from it. +**Goal:** Replace the 218 `defaults write` lines in `.macos` with a reviewable table and a script that reports when macOS has drifted away from it. **Architecture:** A tab-delimited table (`macos-defaults`) holds one row per setting. `scripts/macos-defaults.sh` reads it in four modes: `check` validates the file, `audit` compares each row against the live machine, `apply` writes rows that differ, and `accept` rewrites rows to match what is live. Rows that cannot be compared (TCC-protected containers, unset keys, `array`/`dict` values) carry a `noaudit=` marker: they are still written by `apply`, but `audit` reports them as `skip` and they never affect the exit code. @@ -31,7 +31,7 @@ | `scripts/macos-defaults.sh` (create) | Parser plus the four modes. The only thing that knows the table format. | | `scripts/migrate-macos-defaults.sh` (create) | One-shot generator: parses `.macos` into the table. Committed so the transcription is reviewable. | | `test/test_macos_defaults.sh` (create) | Parser tests run everywhere; `defaults`-backed tests are Darwin-gated. | -| `.macos` (modify) | Loses all 217 `defaults write` lines; keeps the imperative tail. | +| `.macos` (modify) | Loses all 218 `defaults write` lines; keeps the imperative tail. | | `Makefile` (modify) | `macos-audit`, `macos-apply`, `macos-accept`, `check-macos-defaults`; `macos` gains the apply step. | | `.editorconfig` (modify) | A `[macos-defaults]` stanza declaring the tabs as data separators. | | `CLAUDE.md` (modify) | Commands and Architecture entries for the new table. | @@ -1291,7 +1291,7 @@ comments are the why carried over from .macos." ### Task 5: Generate the table from `.macos` -Transcribes 217 rows mechanically. The generator is committed rather than run and discarded so the transcription is reviewable, following the `scripts/migrate-to-home.sh` precedent. +Transcribes 218 rows mechanically. The generator is committed rather than run and discarded so the transcription is reviewable, following the `scripts/migrate-to-home.sh` precedent. **Files:** - Create: `scripts/migrate-macos-defaults.sh` @@ -1308,7 +1308,7 @@ Create `scripts/migrate-macos-defaults.sh`: ```bash #!/bin/bash # One-shot: split .macos into the macos-defaults table (default) and the -# imperative lines that stay behind (--remainder). Committed so the 217-row +# imperative lines that stay behind (--remainder). Committed so the 218-row # transcription can be reviewed rather than trusted. set -euf -o pipefail @@ -1477,7 +1477,7 @@ echo "declared=$declared rows=$rows" [ "$declared" = "$rows" ] && echo "no rows dropped" ``` -Expected: `declared=217 rows=217 / no rows dropped`. If the counts differ, a `defaults write` form is unhandled; find it by diffing the domain/key pairs rather than guessing. +Expected: `declared=218 rows=218 / no rows dropped`. If the counts differ, a `defaults write` form is unhandled; find it by diffing the domain/key pairs rather than guessing. - [ ] **Step 4: Verify the table parses** @@ -1485,7 +1485,7 @@ Expected: `declared=217 rows=217 / no rows dropped`. If the counts differ, a `de ./scripts/macos-defaults.sh check ``` -Expected: `ok: 217 rows in /macos-defaults`. A failure here names the offending line; the likely causes are a tab that survived a continuation join or a container row that missed its `noaudit=complex`. +Expected: `ok: 218 rows in /macos-defaults`. A failure here names the offending line; the likely causes are a tab that survived a continuation join or a container row that missed its `noaudit=complex`. - [ ] **Step 5: Verify the marker counts match the design probe** @@ -1494,7 +1494,7 @@ for r in tcc unset complex; do printf '%-8s %s\n' "$r" "$(grep -c "noaudit=$r$" grep -cvE '^[[:space:]]*(#|$)' macos-defaults ``` -Expected: `tcc 40`, `unset 9`, `complex 11`, total 217. These are the numbers the spec's probe recorded. A material difference means the machine changed since the probe, which is worth reading before continuing rather than accepting silently. +Expected: `tcc 40`, `unset 9`, `complex 11`, total 218, leaving 158 rows with no status. A material difference means the machine changed since the probe, which is worth reading before continuing rather than accepting silently. - [ ] **Step 6: Read the generated table** @@ -1506,7 +1506,7 @@ Skim `macos-defaults` end to end. Confirm each row's comment still sits above th git add scripts/migrate-macos-defaults.sh macos-defaults git commit -m "Generate the macos-defaults table from .macos -217 rows transcribed mechanically, with each setting's comment carried +218 rows transcribed mechanically, with each setting's comment carried across. Container types get noaudit=complex; keys that will not read get noaudit=tcc when the whole domain is unreadable and noaudit=unset when only the key is absent. @@ -1536,7 +1536,7 @@ Machine-specific and run once. Its test is `audit` exiting 0. for label in ok drift missing skip; do printf '%-8s %s\n' "$label" "$(grep -c "^$label:" /tmp/audit-before.txt)"; done ``` -Expected: `ok 153`, `drift 4`, `missing 0`, `skip 60`. `missing` should be zero because Task 5 already marked every unreadable key. +Expected: `ok 154`, `drift 4`, `missing 0`, `skip 60`. `missing` should be zero because Task 5 already marked every unreadable key. - [ ] **Step 2: Confirm the four drifts are the ones the spec predicted** @@ -1584,7 +1584,7 @@ Expected: `write:` on the apply, then `-1` from the read. for label in ok drift missing skip; do printf '%-8s %s\n' "$label" "$(grep -c "^$label:" /tmp/audit-after.txt)"; done ``` -Expected: `exit=0`, `ok 157`, `drift 0`, `missing 0`, `skip 60`. +Expected: `exit=0`, `ok 158`, `drift 0`, `missing 0`, `skip 60`. - [ ] **Step 6: Ask the owner before probing TCC writes** @@ -1622,7 +1622,7 @@ rewrites itself. The fourth, .GlobalPreferences com.apple.mouse.scaling, goes the other way — the table disables mouse acceleration and the machine had turned it back on, which is the drift this change exists to catch. -audit now exits 0: 157 ok, 60 skipped." +audit now exits 0: 158 ok, 60 skipped." ``` --- @@ -1767,7 +1767,7 @@ up on both legs without an ci.yml edit." | Comparison is not a naive string equality | bool normalization, type drift, missing vs drift | Task 2 Step 5 | | Apply never prompts for a password in tests | Full suite runs with no `Password:` prompt | Task 3 Step 5 | | Accept preserves the commentary carried from `.macos` | Comment and blank-line placement asserted | Task 4 Step 5 | -| No setting was lost in transcription | 217 declared = 217 rows; marker counts match the probe | Task 5 Steps 3 and 5 | +| No setting was lost in transcription | 218 declared = 218 rows; marker counts match the probe | Task 5 Steps 3 and 5 | | The table describes this machine | `audit` exits 0 | Task 6 Step 5 | | `.macos` still runs | `bash -n`, zero uncommented `defaults write` | Task 7 Step 4 | | CI covers all of it on both platforms | `make preflight` | Task 7 Step 8 | diff --git a/docs/superpowers/specs/2026-08-25-macos-defaults-declarative-design.md b/docs/superpowers/specs/2026-08-25-macos-defaults-declarative-design.md index cbb090fc..ff77af53 100644 --- a/docs/superpowers/specs/2026-08-25-macos-defaults-declarative-design.md +++ b/docs/superpowers/specs/2026-08-25-macos-defaults-declarative-design.md @@ -6,9 +6,9 @@ ## Problem -`.macos` is a one-way imperative script. It writes 217 `defaults write` lines and keeps no record of desired state, so nothing detects when macOS or an application rewrites a setting after an OS update, a manual change in System Settings, or an app's own housekeeping. +`.macos` is a one-way imperative script. It writes 218 `defaults write` lines and keeps no record of desired state, so nothing detects when macOS or an application rewrites a setting after an OS update, a manual change in System Settings, or an app's own housekeeping. -A read-only probe of all 217 lines against this machine (nyx, macOS 26) found four settings already untrue: +A read-only probe of all 218 lines against this machine (macOS 26) found four settings already untrue: | Row | `.macos` declares | Live | |---|---|---| @@ -31,7 +31,7 @@ Every `defaults write` in `.macos`, parsed with a shell shim and compared agains | Bucket | Count | Meaning | |---|---|---| -| Auditable, matches live | 153 | The healthy core | +| Auditable, matches live | 154 | The healthy core | | Genuine drift | 4 | Listed above | | TCC-blocked | 40 | Safari 35, Mail 5 | | Actually unset | 9 | Key absent from a readable domain | @@ -66,7 +66,7 @@ com.apple.Safari AlwaysRestoreSessionAtLaunch bool true noaudit=tcc - **key** — verbatim; may contain spaces. `com.apple.print.PrintingPrefs "Quit When Finished"` and `com.apple.BluetoothAudioAgent "Apple Bitpool Min (editable)"` are why this file is tab-delimited rather than whitespace-columned like `manifest`. - **type** — `bool`, `int`, `float`, `string`, `raw` (written with no type flag), plus `array`, `dict`, `dict-add`, `date` on `noaudit=complex` rows. - **value** — human form. Booleans read `true`/`false` in the file; normalization happens at compare time. -- **status** — omitted on the 157 live rows. Otherwise `noaudit=tcc`, `noaudit=unset`, or `noaudit=complex`. The parser also accepts `os=` and `host=`, unused today, so a second Mac needs no format change. +- **status** — omitted on the 158 live rows. Otherwise `noaudit=tcc`, `noaudit=unset`, or `noaudit=complex`. The parser also accepts `os=` and `host=`, unused today, so a second Mac needs no format change. ### Why `noaudit=` and not `skip=` @@ -77,7 +77,7 @@ Those 60 rows still have to be *written* on a fresh Mac, or `.macos` cannot reti Three of these are deliberate departures from `deploy.sh`. 1. **A line is a comment only if its first non-whitespace character is `#`.** `deploy.sh` uses `${line%%#*}`, which would eat a `#` inside a value. No value contains one today; the parser should not depend on that staying true. -2. **Split tabs by parameter expansion, never `IFS=$'\t' read`.** Bash treats tab as IFS *whitespace*, so it collapses runs of tabs and drops a leading one. An empty column silently shifts every field left and the run reports plausible nonsense. The probe that produced this design hit exactly this and reported 215 of 217 keys missing before the bug was found. +2. **Split tabs by parameter expansion, never `IFS=$'\t' read`.** Bash treats tab as IFS *whitespace*, so it collapses runs of tabs and drops a leading one. An empty column silently shifts every field left and the run reports plausible nonsense. The probe that produced this design hit exactly this and reported almost every key missing before the bug was found. 3. **Four or five fields, nothing else, or a hard error naming the line — and nothing runs.** Only the trailing `status` field may be omitted, so there is no shift risk. It is omitted rather than left empty because `.editorconfig` sets `trim_trailing_whitespace = true` and a five-field row with an empty status would end in a tab. 4. Validate the whole file before acting on any row, matching `deploy.sh`. @@ -135,7 +135,7 @@ This is the same trust level as `sh .macos` — a shell script from this repo, r `scripts/migrate-macos-defaults.sh` generates the table by parsing `.macos` with a `defaults` shell shim. Committed rather than run and discarded, following the `scripts/migrate-to-home.sh` precedent, so the transcription is reviewable rather than trusted. -**Verification:** re-parse `.macos` at its pre-change commit, re-parse the generated table, and diff the `domain/key/type/value` sets. They must be identical except for rows explicitly accepted. That is a one-command proof that nothing was dropped across 217 lines. +**Verification:** re-parse `.macos` at its pre-change commit, re-parse the generated table, and diff the `domain/key/type/value` sets. They must be identical except for rows explicitly accepted. That is a one-command proof that nothing was dropped across 218 lines. Seeding fills values from live. The four drifting rows are held back as a decision rather than auto-accepted: @@ -150,7 +150,7 @@ The 9 unset rows enter as `noaudit=unset` so the baseline is green: `helpviewer ## What `.macos` keeps -All 217 `defaults write` lines leave. Roughly 45 lines remain: the System Settings quit, the sudo keepalive, `nvram SystemAudioVolume`, `systemsetup -settimezone`, the nine `PlistBuddy` Finder-view calls, both `chflags`, `lsregister`, the Dock `find -delete`, `tmutil disable`, the closing `killall` loop, and the final echo. +All 218 `defaults write` lines leave. Roughly 45 lines remain: the System Settings quit, the sudo keepalive, `nvram SystemAudioVolume`, `systemsetup -settimezone`, the nine `PlistBuddy` Finder-view calls, both `chflags`, `lsregister`, the Dock `find -delete`, `tmutil disable`, the closing `killall` loop, and the final echo. It keeps its filename and gains a header comment pointing at `macos-defaults`. Nothing is symlinked to it — it has no `manifest` entry — so a later rename to `scripts/macos-imperative.sh` costs nothing but muscle memory. From 996e866f981727e6e7a21a3f2232a99c42bcf8ef Mon Sep 17 00:00:00 2001 From: Alan Norton Date: Tue, 25 Aug 2026 12:40:06 -0700 Subject: [PATCH 09/26] Generate the macos-defaults table from .macos 218 rows transcribed mechanically, with each setting's comment carried across. Container types get noaudit=complex; keys that will not read get noaudit=tcc when the whole domain is unreadable and noaudit=unset when only the key is absent. .macos is untouched here, so the two files declare the same settings until the next commit strips it. --- macos-defaults | 728 ++++++++++++++++++++++++++++++ scripts/migrate-macos-defaults.sh | 153 +++++++ 2 files changed, 881 insertions(+) create mode 100644 macos-defaults create mode 100755 scripts/migrate-macos-defaults.sh diff --git a/macos-defaults b/macos-defaults new file mode 100644 index 00000000..999014c6 --- /dev/null +++ b/macos-defaults @@ -0,0 +1,728 @@ + +# Set sidebar icon size to medium +NSGlobalDomain NSTableViewDefaultSizeMode int 2 + +# Always show scrollbars +NSGlobalDomain AppleShowScrollBars string Always +# Possible values: `WhenScrolling`, `Automatic` and `Always` + +# Disable the over-the-top focus ring animation +NSGlobalDomain NSUseAnimatedFocusRing bool false + +# Increase window resize speed for Cocoa applications +NSGlobalDomain NSWindowResizeTime float 0.001 + +# Expand save panel by default +NSGlobalDomain NSNavPanelExpandedStateForSaveMode bool true +NSGlobalDomain NSNavPanelExpandedStateForSaveMode2 bool true + +# Expand print panel by default +NSGlobalDomain PMPrintingExpandedStateForPrint bool true +NSGlobalDomain PMPrintingExpandedStateForPrint2 bool true + +# Save to disk (not to iCloud) by default +NSGlobalDomain NSDocumentSaveNewDocumentsToCloud bool false + +# Automatically quit printer app once the print jobs complete +com.apple.print.PrintingPrefs Quit When Finished bool true + +# Disable the “Are you sure you want to open this application?” dialog +com.apple.LaunchServices LSQuarantine bool false + +# Display ASCII control characters using caret notation in standard text views +# Try e.g. `cd /tmp; unidecode "\x{0000}" > cc.txt; open -e cc.txt` +NSGlobalDomain NSTextShowsControlCharacters bool true + +# Disable Resume system-wide +com.apple.systempreferences NSQuitAlwaysKeepsWindows bool false + +# Disable automatic termination of inactive apps +NSGlobalDomain NSDisableAutomaticTermination bool true + +# Disable the crash reporter +#defaults write com.apple.CrashReporter DialogType -string "none" + +# Set Help Viewer windows to non-floating mode +com.apple.helpviewer DevMode bool true noaudit=unset + +# Fix for the ancient UTF-8 bug in QuickLook (https://mths.be/bbo) +# Commented out, as this is known to cause problems in various Adobe apps :( +# See https://github.com/mathiasbynens/dotfiles/issues/237 +#echo "0x08000100:0" > ~/.CFUserTextEncoding + +# Reveal IP address, hostname, OS version, etc. when clicking the clock +# in the login window +/Library/Preferences/com.apple.loginwindow AdminHostInfo raw HostName + +# Disable automatic capitalization as it’s annoying when typing code +NSGlobalDomain NSAutomaticCapitalizationEnabled bool false + +# Disable smart dashes as they’re annoying when typing code +NSGlobalDomain NSAutomaticDashSubstitutionEnabled bool false + +# Disable automatic period substitution as it’s annoying when typing code +NSGlobalDomain NSAutomaticPeriodSubstitutionEnabled bool false + +# Disable smart quotes as they’re annoying when typing code +NSGlobalDomain NSAutomaticQuoteSubstitutionEnabled bool false + +# Disable auto-correct +NSGlobalDomain NSAutomaticSpellingCorrectionEnabled bool false + +############################################################################### +# Trackpad, mouse, keyboard, Bluetooth accessories, and input # +############################################################################### + +# Trackpad: enable tap to click for this user and for the login screen +com.apple.driver.AppleBluetoothMultitouch.trackpad Clicking bool true +currentHost:NSGlobalDomain com.apple.mouse.tapBehavior int 1 +NSGlobalDomain com.apple.mouse.tapBehavior int 1 + +# Disable “natural” (Lion-style) scrolling +NSGlobalDomain com.apple.swipescrolldirection bool false + +# Disable "forceClick" lookup behavior +NSGlobalDomain com.apple.trackpad.forceClick bool false + +# Set preferred trackpad behavior +com.apple.AppleMultitouchTrackpad ActuateDetents bool false +com.apple.AppleMultitouchTrackpad AppleEnableSwipeNavigateWithScrolls bool false +com.apple.AppleMultitouchTrackpad Clicking bool true +com.apple.AppleMultitouchTrackpad DragLock bool false +com.apple.AppleMultitouchTrackpad Dragging bool false +com.apple.AppleMultitouchTrackpad FirstClickThreshold int 1 +com.apple.AppleMultitouchTrackpad ForceSuppressed bool true +com.apple.AppleMultitouchTrackpad HIDScrollZoomModifierMask bool false +com.apple.AppleMultitouchTrackpad SecondClickThreshold int 1 +com.apple.AppleMultitouchTrackpad TrackpadCornerSecondaryClick bool false +com.apple.AppleMultitouchTrackpad TrackpadFiveFingerPinchGesture int 2 +com.apple.AppleMultitouchTrackpad TrackpadFourFingerHorizSwipeGesture int 2 +com.apple.AppleMultitouchTrackpad TrackpadFourFingerPinchGesture int 2 +com.apple.AppleMultitouchTrackpad TrackpadFourFingerVertSwipeGesture int 2 +com.apple.AppleMultitouchTrackpad TrackpadHandResting bool true +com.apple.AppleMultitouchTrackpad TrackpadHorizScroll bool true +com.apple.AppleMultitouchTrackpad TrackpadMomentumScroll bool true +com.apple.AppleMultitouchTrackpad TrackpadPinch bool false +com.apple.AppleMultitouchTrackpad TrackpadRightClick bool true +com.apple.AppleMultitouchTrackpad TrackpadRotate bool false +com.apple.AppleMultitouchTrackpad TrackpadScroll bool true +com.apple.AppleMultitouchTrackpad TrackpadThreeFingerDrag bool false +com.apple.AppleMultitouchTrackpad TrackpadThreeFingerHorizSwipeGesture bool true +com.apple.AppleMultitouchTrackpad TrackpadThreeFingerTapGesture bool false +com.apple.AppleMultitouchTrackpad TrackpadThreeFingerVertSwipeGesture bool true +com.apple.AppleMultitouchTrackpad TrackpadTwoFingerDoubleTapGesture bool false +com.apple.AppleMultitouchTrackpad TrackpadTwoFingerFromRightEdgeSwipeGesture bool false + +# Increase sound quality for Bluetooth headphones/headsets +com.apple.BluetoothAudioAgent Apple Bitpool Min (editable) int 40 + +# Enable full keyboard access for all controls +# (e.g. enable Tab in modal dialogs) +NSGlobalDomain AppleKeyboardUIMode int 3 + +# Disable press-and-hold for keys in favor of key repeat +NSGlobalDomain ApplePressAndHoldEnabled bool false + +# Set a blazingly fast keyboard repeat rate +NSGlobalDomain KeyRepeat int 1 +NSGlobalDomain InitialKeyRepeat int 20 + +# Set language and text formats +# Note: if you’re in the US, replace `EUR` with `USD`, `Centimeters` with +# `Inches`, `en_GB` with `en_US`, and `true` with `false`. +NSGlobalDomain AppleLanguages array en noaudit=complex +NSGlobalDomain AppleLocale string en_US@currency=USD +NSGlobalDomain AppleMeasurementUnits string Inches +NSGlobalDomain AppleMetricUnits bool false + +# Show language menu in the top right corner of the boot screen +/Library/Preferences/com.apple.loginwindow showInputMenu bool true + +# Stop Music/TV from responding to the keyboard media keys +#launchctl unload -w /System/Library/LaunchAgents/com.apple.rcd.plist 2> /dev/null + +############################################################################### +# Energy saving # +############################################################################### + +# nonrational: Disabled 2023-07-21. Buggy as of Ventura. + +# Enable lid wakeup +# sudo pmset -a lidwake 1 + +# Restart automatically on power loss +# sudo pmset -a autorestart 1 + +# Restart automatically if the computer freezes +# sudo systemsetup -setrestartfreeze on + +# Sleep the display after 15 minutes +# sudo pmset -a displaysleep 15 + +# Disable machine sleep while charging +# sudo pmset -c sleep 0 + +# Set machine sleep to 5 minutes on battery +# sudo pmset -b sleep 5 + +# Set standby delay to 24 hours (default is 1 hour) +# sudo pmset -a standbydelay 86400 + +# Never go into computer sleep mode +# sudo systemsetup -setcomputersleep Off > /dev/null + +# Hibernation mode +# 0: Disable hibernation (speeds up entering sleep mode) +# 3: Copy RAM to disk so the system state can still be restored in case of a +# power failure. +# sudo pmset -a hibernatemode 0 + +# Remove the sleep image file to save disk space +# sudo rm -f /private/var/vm/sleepimage +# Create a zero-byte file instead… +# sudo touch /private/var/vm/sleepimage +# …and make sure it can’t be rewritten +# sudo chflags uchg /private/var/vm/sleepimage + +############################################################################### +# Screen # +############################################################################### + +# Require password immediately after sleep or screen saver begins +com.apple.screensaver askForPassword int 1 +com.apple.screensaver askForPasswordDelay int 0 + +# Save screenshots to the desktop +com.apple.screencapture location string /Users/norton/Desktop + +# Save screenshots in PNG format (other options: BMP, GIF, JPG, PDF, TIFF) +com.apple.screencapture type string png + +# Disable shadow in screenshots +com.apple.screencapture disable-shadow bool true + +# Enable subpixel font rendering on non-Apple LCDs +# Reference: https://github.com/kevinSuttle/macOS-Defaults/issues/17#issuecomment-266633501 +NSGlobalDomain AppleFontSmoothing int 1 + +# Enable HiDPI display modes (requires restart) +/Library/Preferences/com.apple.windowserver DisplayResolutionEnabled bool true + +############################################################################### +# Finder # +############################################################################### + +# Finder: allow quitting via ⌘ + Q; doing so will also hide desktop icons +com.apple.finder QuitMenuItem bool true + +# Finder: disable window animations and Get Info animations +com.apple.finder DisableAllAnimations bool true + +# Set Desktop as the default location for new Finder windows +# For other paths, use `PfLo` and `file:///full/path/here/` +com.apple.finder NewWindowTarget string PfDe +com.apple.finder NewWindowTargetPath string file:///Users/norton/Desktop/ + +# Show icons for hard drives, servers, and removable media on the desktop +com.apple.finder ShowExternalHardDrivesOnDesktop bool true +com.apple.finder ShowHardDrivesOnDesktop bool true +com.apple.finder ShowMountedServersOnDesktop bool true +com.apple.finder ShowRemovableMediaOnDesktop bool true + +# Finder: show hidden files by default +# defaults write com.apple.finder AppleShowAllFiles -bool true + +# Finder: show all filename extensions +NSGlobalDomain AppleShowAllExtensions bool true + +# Finder: show status bar +com.apple.finder ShowStatusBar bool true + +# Finder: show path bar +com.apple.finder ShowPathbar bool true + +# Display full POSIX path as Finder window title +com.apple.finder _FXShowPosixPathInTitle bool true + +# Keep folders on top when sorting by name +com.apple.finder _FXSortFoldersFirst bool true + +# When performing a search, search the current folder by default +com.apple.finder FXDefaultSearchScope string SCcf + +# Disable the warning when changing a file extension +com.apple.finder FXEnableExtensionChangeWarning bool false + +# Enable spring loading for directories +NSGlobalDomain com.apple.springing.enabled bool true + +# Remove the spring loading delay for directories +NSGlobalDomain com.apple.springing.delay float 0 + +# To speed up SMB file browsing, you can prevent macOS from reading .DS_Store files on SMB shares. +# This makes the Finder use only basic information to immediately display each folder's contents +# in alphanumeric order. +# https://support.apple.com/en-us/HT208209 +# +# Avoid creating .DS_Store files on network or USB volumes +com.apple.desktopservices DSDontWriteNetworkStores bool true +com.apple.desktopservices DSDontWriteUSBStores bool true + +# Disable disk image verification +com.apple.frameworks.diskimages skip-verify bool true +com.apple.frameworks.diskimages skip-verify-locked bool true +com.apple.frameworks.diskimages skip-verify-remote bool true + +# Automatically open a new Finder window when a volume is mounted +com.apple.frameworks.diskimages auto-open-ro-root bool true +com.apple.frameworks.diskimages auto-open-rw-root bool true +com.apple.finder OpenWindowForNewRemovableDisk bool true + +# Use list view in all Finder windows by default +com.apple.finder FXPreferredViewStyle string Nlsv + +# Disable the warning before emptying the Trash +com.apple.finder WarnOnEmptyTrash bool false + +# Enable AirDrop over Ethernet and on unsupported Macs running Lion +com.apple.NetworkBrowser BrowseAllInterfaces bool true + +# Expand the following File Info panes: +# “General”, “Open with”, and “Sharing & Permissions” +com.apple.finder FXInfoPanesExpanded dict General -bool true OpenWith -bool true Privileges -bool true noaudit=complex + +############################################################################### +# Dock and hot corners # +############################################################################### + +# Enable highlight hover effect for the grid view of a stack (Dock) +com.apple.dock mouse-over-hilite-stack bool true + +# Set the icon size of Dock items to 36 pixels +com.apple.dock tilesize int 36 + +# Change minimize/maximize window effect +com.apple.dock mineffect string scale + +# Minimize windows into their application’s icon +# defaults write com.apple.dock minimize-to-application -bool true + +# Enable spring loading for all Dock items +com.apple.dock enable-spring-load-actions-on-all-items bool true + +# Show indicator lights for open applications in the Dock +com.apple.dock show-process-indicators bool true + +# Wipe all (default) app icons from the Dock +# This is only really useful when setting up a new Mac, or if you don’t use +# the Dock to launch apps. +# defaults write com.apple.dock persistent-apps -array + +# Show only open applications in the Dock +# defaults write com.apple.dock static-only -bool true + +# Don’t animate opening applications from the Dock +com.apple.dock launchanim bool false + +# Speed up Mission Control animations +com.apple.dock expose-animation-duration float 0.1 + +# Don't group windows by application in Mission Control +# (i.e. use the old Exposé behavior instead) +com.apple.dock expose-group-by-app bool false + +# Don't automatically rearrange Spaces based on most recent use +com.apple.dock mru-spaces bool false + +# Remove the auto-hiding Dock delay +com.apple.dock autohide-delay float 0 +# Remove the animation when hiding/showing the Dock +com.apple.dock autohide-time-modifier float 0 + +# Automatically hide and show the Dock +com.apple.dock autohide bool true + +# Make Dock icons of hidden applications translucent +com.apple.dock showhidden bool true + +# Don’t show recent applications in Dock +com.apple.dock show-recents bool false + +# Disable the Launchpad gesture (pinch with thumb and three fingers) +com.apple.dock showLaunchpadGestureEnabled int 0 + +# Add iOS & Watch Simulator to Launchpad +# sudo ln -sf "/Applications/Xcode.app/Contents/Developer/Applications/Simulator.app" "/Applications/Simulator.app" +# sudo ln -sf "/Applications/Xcode.app/Contents/Developer/Applications/Simulator (Watch).app" "/Applications/Simulator (Watch).app" + +# Add a spacer to the left side of the Dock (where the applications are) +# defaults write com.apple.dock persistent-apps -array-add '{tile-data={}; tile-type="spacer-tile";}' +# Add a spacer to the right side of the Dock (where the Trash is) +# defaults write com.apple.dock persistent-others -array-add '{tile-data={}; tile-type="spacer-tile";}' + +# Hot corners +# Possible values: +# 0: no-op +# 2: Mission Control +# 3: Show application windows +# 4: Desktop +# 5: Start screen saver +# 6: Disable screen saver +# 10: Put display to sleep +# 11: Launchpad +# 12: Notification Center +# 13: Lock Screen +# Top left screen corner → Mission Control +# defaults write com.apple.dock wvous-tl-corner -int 2 +# defaults write com.apple.dock wvous-tl-modifier -int 0 +# Top right screen corner → Desktop +# defaults write com.apple.dock wvous-tr-corner -int 4 +# defaults write com.apple.dock wvous-tr-modifier -int 0 +# Bottom left screen corner → Start screen saver +com.apple.dock wvous-bl-corner int 5 +com.apple.dock wvous-bl-modifier int 0 + +############################################################################### +# Safari & WebKit # +############################################################################### + +# Privacy: don’t send search queries to Apple +com.apple.Safari UniversalSearchEnabled bool false noaudit=tcc +com.apple.Safari SuppressSearchSuggestions bool true noaudit=tcc + +# Press Tab to highlight each item on a web page +com.apple.Safari WebKitTabToLinksPreferenceKey bool true noaudit=tcc +com.apple.Safari com.apple.Safari.ContentPageGroupIdentifier.WebKit2TabsToLinks bool true noaudit=tcc + +# Show the full URL in the address bar (note: this still hides the scheme) +com.apple.Safari ShowFullURLInSmartSearchField bool true noaudit=tcc + +# Set Safari’s home page to `about:blank` for faster loading +com.apple.Safari HomePage string about:blank noaudit=tcc + +# Prevent Safari from opening ‘safe’ files automatically after downloading +com.apple.Safari AutoOpenSafeDownloads bool false noaudit=tcc + +# Allow hitting the Backspace key to go to the previous page in history +com.apple.Safari com.apple.Safari.ContentPageGroupIdentifier.WebKit2BackspaceKeyNavigationEnabled bool true noaudit=tcc + +# Hide Safari’s bookmarks bar by default +com.apple.Safari ShowFavoritesBar bool false noaudit=tcc + +# Hide Safari’s sidebar in Top Sites +com.apple.Safari ShowSidebarInTopSites bool false noaudit=tcc + +# Disable Safari’s thumbnail cache for History and Top Sites +com.apple.Safari DebugSnapshotsUpdatePolicy int 2 noaudit=tcc + +# Enable Safari’s debug menu +com.apple.Safari IncludeInternalDebugMenu bool true noaudit=tcc + +# Make Safari’s search banners default to Contains instead of Starts With +com.apple.Safari FindOnPageMatchesWordStartsOnly bool false noaudit=tcc + +# Remove useless icons from Safari’s bookmarks bar +com.apple.Safari ProxiesInBookmarksBar raw () noaudit=tcc + +# Enable the Develop menu and the Web Inspector in Safari +com.apple.Safari IncludeDevelopMenu bool true noaudit=tcc +com.apple.Safari WebKitDeveloperExtrasEnabledPreferenceKey bool true noaudit=tcc +com.apple.Safari com.apple.Safari.ContentPageGroupIdentifier.WebKit2DeveloperExtrasEnabled bool true noaudit=tcc + +# Add a context menu item for showing the Web Inspector in web views +NSGlobalDomain WebKitDeveloperExtras bool true + +# Enable continuous spellchecking +com.apple.Safari WebContinuousSpellCheckingEnabled bool true noaudit=tcc +# Disable auto-correct +com.apple.Safari WebAutomaticSpellingCorrectionEnabled bool false noaudit=tcc + +# Disable AutoFill +com.apple.Safari AutoFillFromAddressBook bool false noaudit=tcc +com.apple.Safari AutoFillPasswords bool false noaudit=tcc +com.apple.Safari AutoFillCreditCardData bool false noaudit=tcc +com.apple.Safari AutoFillMiscellaneousForms bool false noaudit=tcc + +# Warn about fraudulent websites +com.apple.Safari WarnAboutFraudulentWebsites bool true noaudit=tcc + +# Disable plug-ins +com.apple.Safari WebKitPluginsEnabled bool false noaudit=tcc +com.apple.Safari com.apple.Safari.ContentPageGroupIdentifier.WebKit2PluginsEnabled bool false noaudit=tcc + +# Disable Java +com.apple.Safari WebKitJavaEnabled bool false noaudit=tcc +com.apple.Safari com.apple.Safari.ContentPageGroupIdentifier.WebKit2JavaEnabled bool false noaudit=tcc +com.apple.Safari com.apple.Safari.ContentPageGroupIdentifier.WebKit2JavaEnabledForLocalFiles bool false noaudit=tcc + +# Block pop-up windows +com.apple.Safari WebKitJavaScriptCanOpenWindowsAutomatically bool false noaudit=tcc +com.apple.Safari com.apple.Safari.ContentPageGroupIdentifier.WebKit2JavaScriptCanOpenWindowsAutomatically bool false noaudit=tcc + +# Disable auto-playing video +com.apple.Safari WebKitMediaPlaybackAllowsInline bool false noaudit=tcc +com.apple.SafariTechnologyPreview WebKitMediaPlaybackAllowsInline bool false +com.apple.Safari com.apple.Safari.ContentPageGroupIdentifier.WebKit2AllowsInlineMediaPlayback bool false noaudit=tcc +com.apple.SafariTechnologyPreview com.apple.Safari.ContentPageGroupIdentifier.WebKit2AllowsInlineMediaPlayback bool false + +# Enable “Do Not Track” +com.apple.Safari SendDoNotTrackHTTPHeader bool true noaudit=tcc + +# Update extensions automatically +com.apple.Safari InstallExtensionUpdatesAutomatically bool true noaudit=tcc + +############################################################################### +# Mail # +############################################################################### + +# Disable send and reply animations in Mail.app +com.apple.mail DisableReplyAnimations bool true noaudit=tcc +com.apple.mail DisableSendAnimations bool true noaudit=tcc + +# Copy email addresses as `foo@example.com` instead of `Foo Bar ` in Mail.app +com.apple.mail AddressesIncludeNameOnPasteboard bool false noaudit=tcc + +# Add the keyboard shortcut ⌘ + Enter to send an email in Mail.app +com.apple.mail NSUserKeyEquivalents dict-add Send @\\U21a9 noaudit=complex + +# Display emails in threaded mode, sorted by date (oldest at the top) +com.apple.mail DraftsViewerAttributes dict-add DisplayInThreadedMode -string yes noaudit=complex +com.apple.mail DraftsViewerAttributes dict-add SortedDescending -string yes noaudit=complex +com.apple.mail DraftsViewerAttributes dict-add SortOrder -string received-date noaudit=complex + +# Disable inline attachments (just show the icons) +com.apple.mail DisableInlineAttachmentViewing bool true noaudit=tcc + +# Disable automatic spell checking +com.apple.mail SpellCheckingBehavior string NoSpellCheckingEnabled noaudit=tcc + +############################################################################### +# Spotlight # +############################################################################### + +# Hide Spotlight tray-icon (and subsequent helper) +# sudo chmod 600 /System/Library/CoreServices/Search.bundle/Contents/MacOS/Search + +# Disable Spotlight indexing for any volume that gets mounted and has not yet +# been indexed before. +# Use `sudo mdutil -i off "/Volumes/foo"` to stop indexing any volume. +# sudo defaults write /.Spotlight-V100/VolumeConfiguration Exclusions -array "/Volumes" + +# Change indexing order and disable some search results +# Yosemite-specific search results (remove them if you are using macOS 10.9 or older): +# MENU_DEFINITION +# MENU_CONVERSION +# MENU_EXPRESSION +# MENU_SPOTLIGHT_SUGGESTIONS (send search queries to Apple) +# MENU_WEBSEARCH (send search queries to Apple) +# MENU_OTHER +# +# nonrational: SOURCE enables calculator? + +# nonrational: Disabled 2023-07-21. Buggy as of Ventura. +# defaults write com.apple.spotlight orderedItems -array # '{"enabled" = 1;"name" = "APPLICATIONS";}' # '{"enabled" = 1;"name" = "SYSTEM_PREFS";}' # '{"enabled" = 1;"name" = "DIRECTORIES";}' # '{"enabled" = 1;"name" = "PDF";}' # '{"enabled" = 1;"name" = "FONTS";}' # '{"enabled" = 1;"name" = "SOURCE";}' # '{"enabled" = 0;"name" = "DOCUMENTS";}' # '{"enabled" = 0;"name" = "MESSAGES";}' # '{"enabled" = 0;"name" = "CONTACT";}' # '{"enabled" = 0;"name" = "EVENT_TODO";}' # '{"enabled" = 0;"name" = "IMAGES";}' # '{"enabled" = 0;"name" = "BOOKMARKS";}' # '{"enabled" = 0;"name" = "MUSIC";}' # '{"enabled" = 0;"name" = "MOVIES";}' # '{"enabled" = 0;"name" = "PRESENTATIONS";}' # '{"enabled" = 0;"name" = "SPREADSHEETS";}' # '{"enabled" = 0;"name" = "MENU_DEFINITION";}' # '{"enabled" = 0;"name" = "MENU_OTHER";}' # '{"enabled" = 0;"name" = "MENU_CONVERSION";}' # '{"enabled" = 0;"name" = "MENU_EXPRESSION";}' # '{"enabled" = 0;"name" = "MENU_WEBSEARCH";}' # '{"enabled" = 0;"name" = "MENU_SPOTLIGHT_SUGGESTIONS";}' + +# # Load new settings before rebuilding the index +# killall mds > /dev/null 2>&1 +# # Make sure indexing is enabled for the main volume +# sudo mdutil -i on / > /dev/null +# # Rebuild the index from scratch +# sudo mdutil -E / > /dev/null + +############################################################################### +# Terminal & iTerm 2 # +############################################################################### + +# Only use UTF-8 in Terminal.app +com.apple.terminal StringEncodings array 4 noaudit=complex + +# Enable “focus follows mouse” for Terminal.app and all X11 apps +# i.e. hover over a window and start typing in it without clicking first +#defaults write com.apple.terminal FocusFollowsMouse -bool true +#defaults write org.x.X11 wm_ffm -bool true + +# Enable Secure Keyboard Entry in Terminal.app +# See: https://security.stackexchange.com/a/47786/8918 +com.apple.terminal SecureKeyboardEntry bool true + +# Disable the annoying line marks +com.apple.Terminal ShowLineMarks int 0 + +############################################################################### +# Time Machine # +############################################################################### + +# Prevent Time Machine from prompting to use new hard drives as backup volume +com.apple.TimeMachine DoNotOfferNewDisksForBackup bool true + +############################################################################### +# Activity Monitor # +############################################################################### + +# Show the main window when launching Activity Monitor +com.apple.ActivityMonitor OpenMainWindow bool true + +# Visualize CPU usage in the Activity Monitor Dock icon +com.apple.ActivityMonitor IconType int 5 + +# Show all processes in Activity Monitor +com.apple.ActivityMonitor ShowCategory int 0 + +# Sort Activity Monitor results by CPU usage +com.apple.ActivityMonitor SortColumn string CPUUsage +com.apple.ActivityMonitor SortDirection int 0 + +############################################################################### +# Address Book, Calendar, TextEdit, and Disk Utility # +############################################################################### + +# Enable the debug menu in Address Book +com.apple.addressbook ABShowDebugMenu bool true noaudit=tcc + +# Enable the debug menu in Calendar (pre-10.8) +com.apple.iCal IncludeDebugMenu bool true + +# Use plain text mode for new TextEdit documents +com.apple.TextEdit RichText int 0 noaudit=tcc +# Open and save files as UTF-8 in TextEdit +com.apple.TextEdit PlainTextEncoding int 4 noaudit=tcc +com.apple.TextEdit PlainTextEncodingForWrite int 4 noaudit=tcc + +# Enable the debug menu in Disk Utility +com.apple.DiskUtility DUDebugMenuEnabled bool true +com.apple.DiskUtility advanced-image-options bool true + +# Auto-play videos when opened with QuickTime Player +com.apple.QuickTimePlayerX MGPlayMovieOnOpen bool true noaudit=unset + +############################################################################### +# Mac App Store # +############################################################################### + +# Enable the WebKit Developer Tools in the Mac App Store +com.apple.appstore WebKitDeveloperExtras bool true + +# Enable Debug Menu in the Mac App Store +com.apple.appstore ShowDebugMenu bool true + +# Enable the automatic update check +com.apple.SoftwareUpdate AutomaticCheckEnabled bool true + +# Check for software updates daily, not just once per week +com.apple.SoftwareUpdate ScheduleFrequency int 1 + +# Download newly available updates in background +com.apple.SoftwareUpdate AutomaticDownload int 1 + +# Install System data files & security updates +com.apple.SoftwareUpdate CriticalUpdateInstall int 1 + +# Automatically download apps purchased on other Macs +com.apple.SoftwareUpdate ConfigDataInstall int 1 + +# Turn on app auto-update +com.apple.commerce AutoUpdate bool true + +# Allow the App Store to reboot machine on macOS updates +com.apple.commerce AutoUpdateRestartRequired bool true + +############################################################################### +# Photos # +############################################################################### + +# Prevent Photos from opening automatically when devices are plugged in +currentHost:com.apple.ImageCapture disableHotPlug bool true + +############################################################################### +# Messages # +############################################################################### + +# Disable automatic emoji substitution (i.e. use plain text smileys) +com.apple.messageshelper.MessageController SOInputLineSettings dict-add automaticEmojiSubstitutionEnablediMessage -bool false noaudit=complex + +# Disable smart quotes as it’s annoying for messages that contain code +com.apple.messageshelper.MessageController SOInputLineSettings dict-add automaticQuoteSubstitutionEnabled -bool false noaudit=complex + +# Disable continuous spell checking +com.apple.messageshelper.MessageController SOInputLineSettings dict-add continuousSpellCheckingEnabled -bool false noaudit=complex + +############################################################################### +# Google Chrome & Google Chrome Canary # +############################################################################### + +# Disable the all too sensitive backswipe on trackpads +com.google.Chrome AppleEnableSwipeNavigateWithScrolls bool false +com.google.Chrome.canary AppleEnableSwipeNavigateWithScrolls bool false + +# Disable the all too sensitive backswipe on Magic Mouse +com.google.Chrome AppleEnableMouseSwipeNavigateWithScrolls bool false +com.google.Chrome.canary AppleEnableMouseSwipeNavigateWithScrolls bool false + +# Use the system-native print preview dialog +com.google.Chrome DisablePrintPreview bool true +com.google.Chrome.canary DisablePrintPreview bool true + +# Expand the print dialog by default +com.google.Chrome PMPrintingExpandedStateForPrint2 bool true +com.google.Chrome.canary PMPrintingExpandedStateForPrint2 bool true + +################################################################ +# _ _ _ +# ___ _ _ _ __ _ __ | | ___ _ __ ___ ___ _ __ | |_ __ _| | +# / __| | | | '_ \| '_ \| |/ _ \ '_ ` _ \ / _ \ '_ \| __/ _` | | +# \__ \ |_| | |_) | |_) | | __/ | | | | | __/ | | | || (_| | | +# |___/\__,_| .__/| .__/|_|\___|_| |_| |_|\___|_| |_|\__\__,_|_| +# |_| |_| nonrational +################################################################ + +# Use all F1, F2 as standard keys +-g com.apple.keyboard.fnState bool true + +# Stop the "Try Safari!" nagification +com.apple.coreservices.uiagent CSUIHasSafariBeenLaunched bool YES +com.apple.coreservices.uiagent CSUIRecommendSafariNextNotificationDate date 2050-01-01T00:00:00Z noaudit=complex +com.apple.coreservices.uiagent CSUILastOSVersionWhereSafariRecommendationWasMade float 10.99 + +# Disable "floating thumbnail" preview and screenshot delay +com.apple.screencapture show-thumbnail bool false + +# More compact spacing for menu bar items. +-globalDomain NSStatusItemSpacing int 6 +-globalDomain NSStatusItemSelectionPadding int 12 + +############################################################### +# _ _ _ +# _____ ___ __ ___ _ __(_)_ __ ___ ___ _ __ | |_ __ _| | +# / _ \ \/ / '_ \ / _ \ '__| | '_ ` _ \ / _ \ '_ \| __/ _` | | +# | __/> <| |_) | __/ | | | | | | | | __/ | | | || (_| | | +# \___/_/\_\ .__/ \___|_| |_|_| |_| |_|\___|_| |_|\__\__,_|_| +# |_| nonrational +############################################################### + +com.apple.dock showAppExposeGestureEnabled int 1 +com.apple.dock showLaunchpadGestureEnabled int 0 +com.apple.dock showMissionControlGestureEnabled int 1 +com.apple.driver.AppleBluetoothMultitouch.trackpad TrackpadTwoFingerFromRightEdgeSwipeGesture int 0 + +# Indirect GLX was disabled by default in Xorg 1.17, starting from XQuartz version 2.7.9, +# to enable indirect GLX and to allow remote visualization on the cluster you will have to issue at the terminal command prompt of your mac: +org.macosforge.xquartz.X11 enable_iglx bool true +org.x.X11 enable_test_extensions bool true + +# disable mouse acceleration +.GlobalPreferences com.apple.mouse.scaling raw -1 + +############################################################################### +# Siri # +############################################################################### + +# Disable Siri +com.apple.Siri StatusMenuVisible bool false noaudit=unset +com.apple.Siri UserHasDeclinedEnable bool true noaudit=unset + +############################################################################### +# Game Center # +############################################################################### + +# Disable Game Center notifications +com.apple.GameCenter GKInviteAlertEnabled bool false noaudit=unset diff --git a/scripts/migrate-macos-defaults.sh b/scripts/migrate-macos-defaults.sh new file mode 100755 index 00000000..6b4f38a9 --- /dev/null +++ b/scripts/migrate-macos-defaults.sh @@ -0,0 +1,153 @@ +#!/bin/bash +# One-shot: split .macos into the macos-defaults table (default) and the +# imperative lines that stay behind (--remainder). Committed so the 218-row +# transcription can be reviewed rather than trusted. +set -euf -o pipefail + +DOTS="$(cd "$(dirname "$0")/.." && pwd)" +SOURCE="$DOTS/.macos" +TAB=$'\t' +mode=table + +if [ $# -gt 0 ]; then + case "$1" in + --remainder) mode=remainder ;; + *) + echo "usage: $0 [--remainder]" >&2 + exit 2 + ;; + esac +fi + +pending=() + +flush_pending() { + local p + if [ "${#pending[@]}" -gt 0 ]; then + for p in "${pending[@]}"; do + printf '%s\n' "$p" + done + fi + pending=() +} + +quote_tail() { + local out="" a + for a in "$@"; do + out="$out${out:+ }$(printf '%q' "$a")" + done + printf '%s' "$out" +} + +# Absent from the live machine for two different reasons that need different +# markers: a whole domain that will not read is TCC, a readable domain missing +# one key is simply unset. +classify() { + local domain="$1" key="$2" host_flag="" + case "$domain" in + currentHost:*) + host_flag="-currentHost" + domain="${domain#currentHost:}" + ;; + esac + # `command` is required: this runs inside the `defaults` shim below, and a + # bare call would re-enter it instead of reaching the binary. + if command defaults $host_flag read "$domain" "$key" >/dev/null 2>&1; then + printf '' + return 0 + fi + if command defaults $host_flag read "$domain" >/dev/null 2>&1; then + printf 'noaudit=unset' + else + printf 'noaudit=tcc' + fi +} + +emit() { + local host="$1" domain="$2" key="$3" type="$4" value="$5" status + if [ "$mode" = remainder ]; then + pending=() + return 0 + fi + if [ "$host" = currentHost ]; then + domain="currentHost:$domain" + fi + # Continuation lines in .macos are tab-indented; a surviving tab would add a + # phantom column. + value="${value//$TAB/ }" + case "$type" in + array | dict | dict-add | date | data) status="noaudit=complex" ;; + *) status="$(classify "$domain" "$key")" ;; + esac + flush_pending + if [ -n "$status" ]; then + printf '%s\n' "$domain$TAB$key$TAB$type$TAB$value$TAB$status" + else + printf '%s\n' "$domain$TAB$key$TAB$type$TAB$value" + fi +} + +defaults() { + local host="" + if [ "$1" = "-currentHost" ]; then + host=currentHost + shift + fi + if [ "$1" != write ]; then + return 0 + fi + shift + local domain="$1" key="$2" + shift 2 + case "$1" in + -bool | -boolean) emit "$host" "$domain" "$key" bool "$2" ;; + -int | -integer) emit "$host" "$domain" "$key" int "$2" ;; + -float) emit "$host" "$domain" "$key" float "$2" ;; + -string) emit "$host" "$domain" "$key" string "$2" ;; + -date) emit "$host" "$domain" "$key" date "$2" ;; + -data) emit "$host" "$domain" "$key" data "$2" ;; + -array | -array-add) + shift + emit "$host" "$domain" "$key" array "$(quote_tail "$@")" + ;; + -dict) + shift + emit "$host" "$domain" "$key" dict "$(quote_tail "$@")" + ;; + -dict-add) + shift + emit "$host" "$domain" "$key" dict-add "$(quote_tail "$@")" + ;; + # An untyped value is handed to `defaults` to parse as a plist fragment, + # which is what `AdminHostInfo HostName` and `mouse.scaling -1` rely on. + *) emit "$host" "$domain" "$key" raw "$1" ;; + esac +} + +sudo() { "$@"; } + +while IFS= read -r line || [ -n "$line" ]; do + while [ "${line%\\}" != "$line" ]; do + line="${line%\\}" + IFS= read -r next || break + line="$line${next#"${next%%[![:space:]]*}"}" + done + trimmed="${line#"${line%%[![:space:]]*}"}" + case "$trimmed" in + "" | "#"*) + pending+=("$line") + continue + ;; + "defaults write "* | "defaults -currentHost write "* | "sudo defaults write "*) + eval "$trimmed" + ;; + *) + if [ "$mode" = remainder ]; then + flush_pending + printf '%s\n' "$line" + else + pending=() + fi + ;; + esac +done <"$SOURCE" From 3ff2b60ef6a5a1897bdb9f28252f44f4b38a1ffc Mon Sep 17 00:00:00 2001 From: Alan Norton Date: Tue, 25 Aug 2026 13:47:06 -0700 Subject: [PATCH 10/26] Correct the tcc/unset split from 40/9 to 44/5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generator classifies a row by whether its DOMAIN reads, which is the test that actually separates the two cases. The design probe instead checked `defaults domains`, which lists com.apple.TextEdit even though `defaults read com.apple.TextEdit` fails. TextEdit (3 rows) and addressbook (1) own TCC container directories and fail the domain read, so they belong with Safari and Mail rather than in the unset bucket. Verified per row: all five remaining unset rows sit in domains that read fine and are missing only the key. Totals are unchanged — 60 rows carry a marker either way — so no audit expectation moves. Co-Authored-By: Claude Opus 5 (1M context) --- .../plans/2026-08-25-macos-defaults-declarative.md | 4 ++-- .../2026-08-25-macos-defaults-declarative-design.md | 10 ++++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/plans/2026-08-25-macos-defaults-declarative.md b/docs/superpowers/plans/2026-08-25-macos-defaults-declarative.md index 0d4110af..4bc00c5f 100644 --- a/docs/superpowers/plans/2026-08-25-macos-defaults-declarative.md +++ b/docs/superpowers/plans/2026-08-25-macos-defaults-declarative.md @@ -887,7 +887,7 @@ test_dry_run_changes_nothing() { } # audit cannot tell whether a noaudit row needs writing, so apply always writes -# one. Without this, the 40 TCC rows would never be applied on a fresh Mac. +# one. Without this, the 44 TCC rows would never be applied on a fresh Mac. test_apply_writes_noaudit_rows() { darwin_only "apply writes noaudit rows unconditionally" || return 0 sandbox @@ -1494,7 +1494,7 @@ for r in tcc unset complex; do printf '%-8s %s\n' "$r" "$(grep -c "noaudit=$r$" grep -cvE '^[[:space:]]*(#|$)' macos-defaults ``` -Expected: `tcc 40`, `unset 9`, `complex 11`, total 218, leaving 158 rows with no status. A material difference means the machine changed since the probe, which is worth reading before continuing rather than accepting silently. +Expected: `tcc 44`, `unset 5`, `complex 11`, total 218, leaving 158 rows with no status. A material difference means the machine changed since the probe, which is worth reading before continuing rather than accepting silently. - [ ] **Step 6: Read the generated table** diff --git a/docs/superpowers/specs/2026-08-25-macos-defaults-declarative-design.md b/docs/superpowers/specs/2026-08-25-macos-defaults-declarative-design.md index ff77af53..a46b56ce 100644 --- a/docs/superpowers/specs/2026-08-25-macos-defaults-declarative-design.md +++ b/docs/superpowers/specs/2026-08-25-macos-defaults-declarative-design.md @@ -33,15 +33,17 @@ Every `defaults write` in `.macos`, parsed with a shell shim and compared agains |---|---|---| | Auditable, matches live | 154 | The healthy core | | Genuine drift | 4 | Listed above | -| TCC-blocked | 40 | Safari 35, Mail 5 | -| Actually unset | 9 | Key absent from a readable domain | +| TCC-blocked | 44 | Safari 35, Mail 5, TextEdit 3, addressbook 1 | +| Actually unset | 5 | Key absent from a domain that reads fine | | Genuinely complex | 11 | `array` / `dict` / `dict-add` / `date` | Four of the complex rows (`com.apple.mail NSUserKeyEquivalents`, `DraftsViewerAttributes` ×3) are blocked by TCC *and* by their container types. They are marked `noaudit=complex`, because that is the binding constraint: granting Full Disk Access would still leave them uncomparable. ### TCC is a hard constraint -`~/Library/Containers/com.apple.Safari/Data/Library/Preferences/com.apple.Safari.plist` exists and is written regularly, but `ls` on that directory returns `Operation not permitted` and `defaults read com.apple.Safari` reports the domain does not exist. That is TCC, not a missing key. Those 40 rows cannot be audited from a shell without granting Full Disk Access to the terminal, and CI can never have it. +`~/Library/Containers/com.apple.Safari/Data/Library/Preferences/com.apple.Safari.plist` exists and is written regularly, but `ls` on that directory returns `Operation not permitted` and `defaults read com.apple.Safari` reports the domain does not exist. That is TCC, not a missing key. Those 44 rows cannot be audited from a shell without granting Full Disk Access to the terminal, and CI can never have it. + +The test that separates the two cases is whether the *domain* reads, not whether the key does: `defaults read com.apple.Safari` fails, while `defaults read com.apple.GameCenter` succeeds and only the key is absent. `com.apple.TextEdit` and `com.apple.addressbook` look unset but fail the domain read and own TCC container directories, so they belong with Safari and Mail. An earlier draft of this spec put them in the unset bucket by checking `defaults domains`, which lists `com.apple.TextEdit` even though reading it fails. Granting FDA was considered and rejected: it makes audit results depend on a machine-configuration step this repo cannot enforce or verify. @@ -146,7 +148,7 @@ Seeding fills values from live. The four drifting rows are held back as a decisi | `com.apple.ActivityMonitor OpenMainWindow` | accept. If it drifts back, the app rewrites it on quit and the row wants a `noaudit=` marker | | `.GlobalPreferences com.apple.mouse.scaling` | **reapply** — the table wins; mouse acceleration should be off | -The 9 unset rows enter as `noaudit=unset` so the baseline is green: `helpviewer DevMode`, `addressbook ABShowDebugMenu`, `TextEdit` ×3, `QuickTimePlayerX MGPlayMovieOnOpen`, `Siri` ×2, `GameCenter GKInviteAlertEnabled`. +The 5 unset rows enter as `noaudit=unset` so the baseline is green: `helpviewer DevMode`, `QuickTimePlayerX MGPlayMovieOnOpen`, `Siri` ×2, `GameCenter GKInviteAlertEnabled`. ## What `.macos` keeps From cde89bb3e69c28aaad4d1c26f084f909adf78817 Mon Sep 17 00:00:00 2001 From: Alan Norton Date: Wed, 26 Aug 2026 07:13:12 -0700 Subject: [PATCH 11/26] Keep ${HOME} as a literal token through macos-defaults instead of a live path The generator's eval-based parser expanded ${HOME} while transcribing .macos, baking this machine's home directory into two rows of a table meant to describe desired state. The generator now rewrites the machine's home directory back to the literal ${HOME} token, and the applier expands it at comparison and write time (audit, apply, accept) so the table stays portable across machines. Adds three tests covering audit, apply, and accept against a ${HOME}-tokenized row. --- macos-defaults | 4 +-- scripts/macos-defaults.sh | 19 ++++++++++--- scripts/migrate-macos-defaults.sh | 4 +++ test/test_macos_defaults.sh | 45 +++++++++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 5 deletions(-) diff --git a/macos-defaults b/macos-defaults index 999014c6..02e04436 100644 --- a/macos-defaults +++ b/macos-defaults @@ -193,7 +193,7 @@ com.apple.screensaver askForPassword int 1 com.apple.screensaver askForPasswordDelay int 0 # Save screenshots to the desktop -com.apple.screencapture location string /Users/norton/Desktop +com.apple.screencapture location string ${HOME}/Desktop # Save screenshots in PNG format (other options: BMP, GIF, JPG, PDF, TIFF) com.apple.screencapture type string png @@ -221,7 +221,7 @@ com.apple.finder DisableAllAnimations bool true # Set Desktop as the default location for new Finder windows # For other paths, use `PfLo` and `file:///full/path/here/` com.apple.finder NewWindowTarget string PfDe -com.apple.finder NewWindowTargetPath string file:///Users/norton/Desktop/ +com.apple.finder NewWindowTargetPath string file://${HOME}/Desktop/ # Show icons for hard drives, servers, and removable media on the desktop com.apple.finder ShowExternalHardDrivesOnDesktop bool true diff --git a/scripts/macos-defaults.sh b/scripts/macos-defaults.sh index 958f5d05..da8eddbb 100755 --- a/scripts/macos-defaults.sh +++ b/scripts/macos-defaults.sh @@ -204,6 +204,18 @@ normalize() { esac } +# The table stores ${HOME} literally so a row describes desired state rather +# than one machine's paths. Every comparison and every write expands it. +expand_value() { + local v="$1" + printf '%s\n' "${v//\$\{HOME\}/$HOME}" +} + +tokenize_value() { + local v="$1" token='${HOME}' + printf '%s\n' "${v//$HOME/$token}" +} + audit_row() { local i="$1" local domain="${t_domain[$i]}" key="${t_key[$i]}" type="${t_type[$i]}" @@ -236,7 +248,7 @@ audit_row() { fi fi - want="$(normalize "$type" "$value")" + want="$(normalize "$type" "$(expand_value "$value")")" live="$(normalize "$type" "$live")" if [ "$want" = "$live" ]; then echo "ok: $domain $key" @@ -261,6 +273,7 @@ needs_sudo() { write_row() { local domain="$1" key="$2" type="$3" value="$4" local host_flag="" target="$domain" sudo_cmd="" + value="$(expand_value "$value")" case "$domain" in currentHost:*) @@ -301,7 +314,7 @@ apply_row() { noaudit=*) ;; *) if live="$(defaults_read "$domain" "$key")"; then - if [ "$(normalize "$type" "$live")" = "$(normalize "$type" "$value")" ]; then + if [ "$(normalize "$type" "$live")" = "$(normalize "$type" "$(expand_value "$value")")" ]; then echo "ok: $domain $key" return 0 fi @@ -355,7 +368,7 @@ run_accept() { if [ "$live_type" != "${t_type[$i]}" ] \ || [ "$(normalize "$live_type" "$live")" != "$(normalize "${t_type[$i]}" "${t_value[$i]}")" ] \ || [ "$new_status" != "${t_status[$i]}" ]; then - new_row[$i]="${t_domain[$i]}$TAB${t_key[$i]}$TAB$live_type$TAB$live" + new_row[$i]="${t_domain[$i]}$TAB${t_key[$i]}$TAB$live_type$TAB$(tokenize_value "$live")" if [ -n "$new_status" ]; then new_row[$i]="${new_row[$i]}$TAB$new_status" fi diff --git a/scripts/migrate-macos-defaults.sh b/scripts/migrate-macos-defaults.sh index 6b4f38a9..c1a1c3f4 100755 --- a/scripts/migrate-macos-defaults.sh +++ b/scripts/migrate-macos-defaults.sh @@ -7,6 +7,7 @@ set -euf -o pipefail DOTS="$(cd "$(dirname "$0")/.." && pwd)" SOURCE="$DOTS/.macos" TAB=$'\t' +HOME_TOKEN='${HOME}' mode=table if [ $# -gt 0 ]; then @@ -75,6 +76,9 @@ emit() { # Continuation lines in .macos are tab-indented; a surviving tab would add a # phantom column. value="${value//$TAB/ }" + # eval expanded ${HOME} while parsing, which would bake this machine's home + # directory into a table meant to describe desired state rather than one Mac. + value="${value//$HOME/$HOME_TOKEN}" case "$type" in array | dict | dict-add | date | data) status="noaudit=complex" ;; *) status="$(classify "$domain" "$key")" ;; diff --git a/test/test_macos_defaults.sh b/test/test_macos_defaults.sh index 566adeb1..a2394b00 100755 --- a/test/test_macos_defaults.sh +++ b/test/test_macos_defaults.sh @@ -549,6 +549,48 @@ test_accept_never_rewrites_a_tcc_row() { fi } +# The table keeps ${HOME} literal so a row is portable; audit has to expand it +# or these two rows report drift on every machine, including the one that wrote them. +test_audit_expands_the_home_token() { + darwin_only "audit expands \${HOME} before comparing" || return 0 + sandbox + defaults write "$DOMAIN" Where -string "$HOME/Desktop" + row "$DOMAIN" Where string '${HOME}/Desktop' > "$TABLE" + mdefaults audit + if [ "$status" = 0 ] && grep -q "^ok: " <<<"$out"; then + ok "audit expands \${HOME} before comparing" + else + bad "audit expands \${HOME} before comparing (status=$status, out=$out)" + fi +} + +test_apply_expands_the_home_token() { + darwin_only "apply writes the expanded \${HOME} path" || return 0 + sandbox + row "$DOMAIN" Where string '${HOME}/Desktop' > "$TABLE" + mdefaults apply + if [ "$status" = 0 ] && [ "$(defaults read "$DOMAIN" Where)" = "$HOME/Desktop" ]; then + ok "apply writes the expanded \${HOME} path" + else + bad "apply writes the expanded \${HOME} path (status=$status, out=$out)" + fi +} + +# Without this, accepting one of these rows would bake the literal home +# directory back into the table and undo the whole point. +test_accept_tokenizes_the_home_path() { + darwin_only "accept stores \${HOME} rather than the literal path" || return 0 + sandbox + defaults write "$DOMAIN" Where -string "$HOME/Downloads" + row "$DOMAIN" Where string '${HOME}/Desktop' > "$TABLE" + mdefaults accept + if [ "$status" = 0 ] && grep -q 'Downloads$' "$TABLE" && ! grep -q "$HOME" "$TABLE"; then + ok "accept stores \${HOME} rather than the literal path" + else + bad "accept stores \${HOME} rather than the literal path (status=$status, table=$(cat "$TABLE"))" + fi +} + # --- runner ----------------------------------------------------------------- test_rejects_unknown_flag test_rejects_missing_table @@ -586,6 +628,9 @@ test_accept_updates_the_type_when_it_drifts test_accept_honors_the_filter test_accept_never_rewrites_a_readable_complex_row test_accept_never_rewrites_a_tcc_row +test_audit_expands_the_home_token +test_apply_expands_the_home_token +test_accept_tokenizes_the_home_path echo echo "$pass passed, $fail failed, $skipped skipped" From 9ab04af81c5ed44aa9c4790d46490ba6526be523 Mon Sep 17 00:00:00 2001 From: Alan Norton Date: Wed, 26 Aug 2026 07:16:43 -0700 Subject: [PATCH 12/26] Record two type drifts the design probe could not see The probe compared values only, never storage types, so it missed that the trackpad pane rewrote FirstClickThreshold and SecondClickThreshold as booleans where .macos writes -int 1. Same effective value, different storage, and the type check added with audit reports it. Both are accepted rather than reapplied: the effective setting is already what .macos asked for, and writing -int back invites the pane to rewrite it again, turning the row into recurring noise in an audit whose worth depends on not crying wolf. Drift is 6 rather than 4 and the healthy core 152 rather than 154. The post-seed total is unchanged at 158 ok. Co-Authored-By: Claude Opus 5 (1M context) --- .../plans/2026-08-25-macos-defaults-declarative.md | 12 ++++++++---- .../2026-08-25-macos-defaults-declarative-design.md | 10 +++++++--- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/plans/2026-08-25-macos-defaults-declarative.md b/docs/superpowers/plans/2026-08-25-macos-defaults-declarative.md index 4bc00c5f..48e41a0d 100644 --- a/docs/superpowers/plans/2026-08-25-macos-defaults-declarative.md +++ b/docs/superpowers/plans/2026-08-25-macos-defaults-declarative.md @@ -1536,7 +1536,7 @@ Machine-specific and run once. Its test is `audit` exiting 0. for label in ok drift missing skip; do printf '%-8s %s\n' "$label" "$(grep -c "^$label:" /tmp/audit-before.txt)"; done ``` -Expected: `ok 154`, `drift 4`, `missing 0`, `skip 60`. `missing` should be zero because Task 5 already marked every unreadable key. +Expected: `ok 152`, `drift 6`, `missing 0`, `skip 60`. `missing` should be zero because Task 5 already marked every unreadable key. - [ ] **Step 2: Confirm the four drifts are the ones the spec predicted** @@ -1547,24 +1547,28 @@ grep '^drift:' /tmp/audit-before.txt Expected, in some order: ``` +drift: com.apple.AppleMultitouchTrackpad FirstClickThreshold type want=int live=bool +drift: com.apple.AppleMultitouchTrackpad SecondClickThreshold type want=int live=bool drift: NSGlobalDomain AppleLocale want=en_US@currency=USD live=en_US@currency=usd drift: com.apple.ActivityMonitor ShowCategory want=0 live=100 drift: com.apple.ActivityMonitor OpenMainWindow want=1 live=0 drift: .GlobalPreferences com.apple.mouse.scaling want=-1 live=3 ``` -A fifth drift is new information, not a bug. Read it and decide before continuing. +A seventh drift is new information, not a bug. Read it and decide before continuing. -- [ ] **Step 3: Accept the three the machine wins** +- [ ] **Step 3: Accept the five the machine wins** ```bash ./scripts/macos-defaults.sh accept NSGlobalDomain AppleLocale ./scripts/macos-defaults.sh accept com.apple.ActivityMonitor ShowCategory ./scripts/macos-defaults.sh accept com.apple.ActivityMonitor OpenMainWindow +./scripts/macos-defaults.sh accept com.apple.AppleMultitouchTrackpad FirstClickThreshold +./scripts/macos-defaults.sh accept com.apple.AppleMultitouchTrackpad SecondClickThreshold git diff macos-defaults ``` -Expected: exactly three changed rows, each taking the live value. +Expected: exactly five changed rows, each taking the live value. The two trackpad rows change only in the `type` column, `int` -> `bool`. - [ ] **Step 4: Reapply the one the table wins** diff --git a/docs/superpowers/specs/2026-08-25-macos-defaults-declarative-design.md b/docs/superpowers/specs/2026-08-25-macos-defaults-declarative-design.md index a46b56ce..85616503 100644 --- a/docs/superpowers/specs/2026-08-25-macos-defaults-declarative-design.md +++ b/docs/superpowers/specs/2026-08-25-macos-defaults-declarative-design.md @@ -8,7 +8,7 @@ `.macos` is a one-way imperative script. It writes 218 `defaults write` lines and keeps no record of desired state, so nothing detects when macOS or an application rewrites a setting after an OS update, a manual change in System Settings, or an app's own housekeeping. -A read-only probe of all 218 lines against this machine (macOS 26) found four settings already untrue: +A read-only probe of all 218 lines against this machine (macOS 26) found four settings already untrue. The probe compared values only; the finished tool also compares storage types, which later surfaced two more: | Row | `.macos` declares | Live | |---|---|---| @@ -16,6 +16,8 @@ A read-only probe of all 218 lines against this machine (macOS 26) found four se | `com.apple.ActivityMonitor ShowCategory` | `0` | `100` | | `com.apple.ActivityMonitor OpenMainWindow` | `true` | `0` | | `.GlobalPreferences com.apple.mouse.scaling` | `-1` | `3` | +| `com.apple.AppleMultitouchTrackpad FirstClickThreshold` | `int 1` | `bool 1` | +| `com.apple.AppleMultitouchTrackpad SecondClickThreshold` | `int 1` | `bool 1` | The last one matters: `.macos` disables mouse acceleration and the machine has it on. Nothing would ever have reported that. @@ -31,8 +33,8 @@ Every `defaults write` in `.macos`, parsed with a shell shim and compared agains | Bucket | Count | Meaning | |---|---|---| -| Auditable, matches live | 154 | The healthy core | -| Genuine drift | 4 | Listed above | +| Auditable, matches live | 152 | The healthy core | +| Genuine drift | 6 | The four above, plus two trackpad keys the pane restored as booleans | | TCC-blocked | 44 | Safari 35, Mail 5, TextEdit 3, addressbook 1 | | Actually unset | 5 | Key absent from a domain that reads fine | | Genuinely complex | 11 | `array` / `dict` / `dict-add` / `date` | @@ -147,6 +149,8 @@ Seeding fills values from live. The four drifting rows are held back as a decisi | `com.apple.ActivityMonitor ShowCategory` | accept — `100` is what a current Activity Monitor writes for the "all processes" view the comment asks for; confirm against the app | | `com.apple.ActivityMonitor OpenMainWindow` | accept. If it drifts back, the app rewrites it on quit and the row wants a `noaudit=` marker | | `.GlobalPreferences com.apple.mouse.scaling` | **reapply** — the table wins; mouse acceleration should be off | +| `com.apple.AppleMultitouchTrackpad FirstClickThreshold` | accept. Same value, different storage: the trackpad pane rewrote both keys as booleans. Writing `-int` back invites the pane to rewrite it again and turns the row into recurring noise | +| `com.apple.AppleMultitouchTrackpad SecondClickThreshold` | accept, same reason | The 5 unset rows enter as `noaudit=unset` so the baseline is green: `helpviewer DevMode`, `QuickTimePlayerX MGPlayMovieOnOpen`, `Siri` ×2, `GameCenter GKInviteAlertEnabled`. From 50e2b5fab6478097e69db80a0b8bda8dbbf1347b Mon Sep 17 00:00:00 2001 From: Alan Norton Date: Wed, 26 Aug 2026 07:22:32 -0700 Subject: [PATCH 13/26] Expand the ${HOME} token in run_accept's own drift comparison too run_accept compared the live value against the table's raw ${t_value}, which still holds the literal ${HOME} token for the two tokenized rows. That made accept report a spurious change on every unfiltered run even when the row already matched the machine, undermining the "accept leaves matching rows alone" contract. Expand the table value with expand_value in that comparison, matching audit_row and apply_row. Adds a test asserting accept prints no accept: line and leaves the table byte-identical when a tokenized row already matches. --- scripts/macos-defaults.sh | 2 +- test/test_macos_defaults.sh | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/scripts/macos-defaults.sh b/scripts/macos-defaults.sh index da8eddbb..36ba0a08 100755 --- a/scripts/macos-defaults.sh +++ b/scripts/macos-defaults.sh @@ -366,7 +366,7 @@ run_accept() { fi fi if [ "$live_type" != "${t_type[$i]}" ] \ - || [ "$(normalize "$live_type" "$live")" != "$(normalize "${t_type[$i]}" "${t_value[$i]}")" ] \ + || [ "$(normalize "$live_type" "$live")" != "$(normalize "${t_type[$i]}" "$(expand_value "${t_value[$i]}")")" ] \ || [ "$new_status" != "${t_status[$i]}" ]; then new_row[$i]="${t_domain[$i]}$TAB${t_key[$i]}$TAB$live_type$TAB$(tokenize_value "$live")" if [ -n "$new_status" ]; then diff --git a/test/test_macos_defaults.sh b/test/test_macos_defaults.sh index a2394b00..3053228f 100755 --- a/test/test_macos_defaults.sh +++ b/test/test_macos_defaults.sh @@ -591,6 +591,24 @@ test_accept_tokenizes_the_home_path() { fi } +# The token rows are the ones most likely to be accepted spuriously: the table +# holds ${HOME} and the machine holds the expanded path, so an unexpanded +# comparison always reports a change even when nothing drifted. +test_accept_leaves_a_tokenized_matching_row_alone() { + darwin_only "accept leaves a matching \${HOME} row alone and says nothing" || return 0 + sandbox + defaults write "$DOMAIN" Where -string "$HOME/Desktop" + row "$DOMAIN" Where string '${HOME}/Desktop' > "$TABLE" + before="$(cksum < "$TABLE")" + mdefaults accept + if [ "$status" = 0 ] && [ "$(cksum < "$TABLE")" = "$before" ] \ + && ! grep -q "^accept: " <<<"$out"; then + ok "accept leaves a matching \${HOME} row alone and says nothing" + else + bad "accept leaves a matching \${HOME} row alone and says nothing (status=$status, out=$out)" + fi +} + # --- runner ----------------------------------------------------------------- test_rejects_unknown_flag test_rejects_missing_table @@ -631,6 +649,7 @@ test_accept_never_rewrites_a_tcc_row test_audit_expands_the_home_token test_apply_expands_the_home_token test_accept_tokenizes_the_home_path +test_accept_leaves_a_tokenized_matching_row_alone echo echo "$pass passed, $fail failed, $skipped skipped" From d553a9c93a13b60b49117992b01a8228d5208667 Mon Sep 17 00:00:00 2001 From: Alan Norton Date: Wed, 26 Aug 2026 07:29:32 -0700 Subject: [PATCH 14/26] Make apply resolve a type drift that audit reports audit_row checks the stored type via defaults_read_type and reports a mismatch as drift, but apply_row only ever compared the normalized value. A row whose value matched but whose storage type had drifted left apply reporting ok while audit kept reporting the same drift, an unresolvable loop short of running accept the wrong way round. Adds type_matches, exempting raw rows (no type claim) and unreadable types (not a mismatch) the same way audit_row already does, and wires it into apply_row's match check alongside the existing value comparison. Adds a test where the value already matches and only the type differs, confirmed to fail against the prior code before the fix landed. --- scripts/macos-defaults.sh | 17 ++++++++++++++++- test/test_macos_defaults.sh | 18 ++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/scripts/macos-defaults.sh b/scripts/macos-defaults.sh index 36ba0a08..1b6779de 100755 --- a/scripts/macos-defaults.sh +++ b/scripts/macos-defaults.sh @@ -216,6 +216,20 @@ tokenize_value() { printf '%s\n' "${v//$HOME/$token}" } +# audit reports a type drift, so apply has to be able to resolve one. Without +# this the two commands disagree about whether a row has changed and apply +# silently leaves a drift that audit keeps reporting. +type_matches() { + local domain="$1" key="$2" type="$3" live_type + if [ "$type" = raw ]; then + return 0 + fi + if ! live_type="$(defaults_read_type "$domain" "$key")"; then + return 0 + fi + [ "$(table_type_of "$live_type")" = "$type" ] +} + audit_row() { local i="$1" local domain="${t_domain[$i]}" key="${t_key[$i]}" type="${t_type[$i]}" @@ -314,7 +328,8 @@ apply_row() { noaudit=*) ;; *) if live="$(defaults_read "$domain" "$key")"; then - if [ "$(normalize "$type" "$live")" = "$(normalize "$type" "$(expand_value "$value")")" ]; then + if [ "$(normalize "$type" "$live")" = "$(normalize "$type" "$(expand_value "$value")")" ] \ + && type_matches "$domain" "$key" "$type"; then echo "ok: $domain $key" return 0 fi diff --git a/test/test_macos_defaults.sh b/test/test_macos_defaults.sh index 3053228f..662f76d9 100755 --- a/test/test_macos_defaults.sh +++ b/test/test_macos_defaults.sh @@ -609,6 +609,23 @@ test_accept_leaves_a_tokenized_matching_row_alone() { fi } +# audit reports type drift, so apply must be able to resolve one. Before this, +# audit reported drift, apply reported ok and wrote nothing, and audit reported +# the same drift again — a loop with no exit but accept. +test_apply_rewrites_a_type_drifted_row() { + darwin_only "apply rewrites a row whose stored type drifted" || return 0 + sandbox + defaults write "$DOMAIN" Count -bool true + row "$DOMAIN" Count int 1 > "$TABLE" + mdefaults apply + if [ "$status" = 0 ] && grep -q "^write: " <<<"$out" \ + && [ "$(defaults read-type "$DOMAIN" Count)" = "Type is integer" ]; then + ok "apply rewrites a row whose stored type drifted" + else + bad "apply rewrites a row whose stored type drifted (status=$status, out=$out)" + fi +} + # --- runner ----------------------------------------------------------------- test_rejects_unknown_flag test_rejects_missing_table @@ -650,6 +667,7 @@ test_audit_expands_the_home_token test_apply_expands_the_home_token test_accept_tokenizes_the_home_path test_accept_leaves_a_tokenized_matching_row_alone +test_apply_rewrites_a_type_drifted_row echo echo "$pass passed, $fail failed, $skipped skipped" From 09155a43ec84c75789e0298f94a798477b2697e0 Mon Sep 17 00:00:00 2001 From: Alan Norton Date: Wed, 26 Aug 2026 07:36:01 -0700 Subject: [PATCH 15/26] Seed macos-defaults from the live machine Three drifting rows take the machine's value: AppleLocale (macOS canonicalized the currency code) and two Activity Monitor keys the app rewrites itself. Two Trackpad threshold rows take the machine's type, int -> bool, since that is what defaults actually stores there. The sixth, .GlobalPreferences com.apple.mouse.scaling, goes the other way -- the table disables mouse acceleration and the machine had turned it back on, which is the drift this change exists to catch. Retyped that row from raw to float first: a raw write stores the string "-1" where the live key is a float, so the write would have been silently inert and audit would have gone green on an unchanged machine. audit now exits 0: 158 ok, 60 skipped. TCC probe (Step 6) and the spec write-up (Step 7) are deliberately withheld pending owner approval. --- macos-defaults | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/macos-defaults b/macos-defaults index 02e04436..f2093cba 100644 --- a/macos-defaults +++ b/macos-defaults @@ -90,10 +90,10 @@ com.apple.AppleMultitouchTrackpad AppleEnableSwipeNavigateWithScrolls bool false com.apple.AppleMultitouchTrackpad Clicking bool true com.apple.AppleMultitouchTrackpad DragLock bool false com.apple.AppleMultitouchTrackpad Dragging bool false -com.apple.AppleMultitouchTrackpad FirstClickThreshold int 1 +com.apple.AppleMultitouchTrackpad FirstClickThreshold bool 1 com.apple.AppleMultitouchTrackpad ForceSuppressed bool true com.apple.AppleMultitouchTrackpad HIDScrollZoomModifierMask bool false -com.apple.AppleMultitouchTrackpad SecondClickThreshold int 1 +com.apple.AppleMultitouchTrackpad SecondClickThreshold bool 1 com.apple.AppleMultitouchTrackpad TrackpadCornerSecondaryClick bool false com.apple.AppleMultitouchTrackpad TrackpadFiveFingerPinchGesture int 2 com.apple.AppleMultitouchTrackpad TrackpadFourFingerHorizSwipeGesture int 2 @@ -131,7 +131,7 @@ NSGlobalDomain InitialKeyRepeat int 20 # Note: if you’re in the US, replace `EUR` with `USD`, `Centimeters` with # `Inches`, `en_GB` with `en_US`, and `true` with `false`. NSGlobalDomain AppleLanguages array en noaudit=complex -NSGlobalDomain AppleLocale string en_US@currency=USD +NSGlobalDomain AppleLocale string en_US@currency=usd NSGlobalDomain AppleMeasurementUnits string Inches NSGlobalDomain AppleMetricUnits bool false @@ -560,13 +560,13 @@ com.apple.TimeMachine DoNotOfferNewDisksForBackup bool true ############################################################################### # Show the main window when launching Activity Monitor -com.apple.ActivityMonitor OpenMainWindow bool true +com.apple.ActivityMonitor OpenMainWindow bool 0 # Visualize CPU usage in the Activity Monitor Dock icon com.apple.ActivityMonitor IconType int 5 # Show all processes in Activity Monitor -com.apple.ActivityMonitor ShowCategory int 0 +com.apple.ActivityMonitor ShowCategory int 100 # Sort Activity Monitor results by CPU usage com.apple.ActivityMonitor SortColumn string CPUUsage @@ -710,7 +710,7 @@ org.macosforge.xquartz.X11 enable_iglx bool true org.x.X11 enable_test_extensions bool true # disable mouse acceleration -.GlobalPreferences com.apple.mouse.scaling raw -1 +.GlobalPreferences com.apple.mouse.scaling float -1 ############################################################################### # Siri # From a08e78d5c65c90ec1a964341afdaee34eef13070 Mon Sep 17 00:00:00 2001 From: Alan Norton Date: Wed, 26 Aug 2026 10:58:04 -0700 Subject: [PATCH 16/26] Audit noaudit=tcc and noaudit=unset rows when they read TCC visibility is a property of which terminal is running, not of the row: with Full Disk Access granted, 40 of the table's 44 tcc rows read fine and were being silently skipped, hiding real drift (e.g. Safari password autofill). noaudit=complex still skips unconditionally since a container value has no comparable scalar form; tcc and unset now skip only when the key fails to read. Add a doctor mode that reports whether this terminal has Full Disk Access, and have audit print a hint when tcc rows were skipped for lack of it. --- scripts/macos-defaults.sh | 49 ++++++++++++++++++++++++++++++++++--- test/test_macos_defaults.sh | 49 +++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 4 deletions(-) diff --git a/scripts/macos-defaults.sh b/scripts/macos-defaults.sh index 1b6779de..de76a48a 100755 --- a/scripts/macos-defaults.sh +++ b/scripts/macos-defaults.sh @@ -13,9 +13,10 @@ mode=audit filter_domain="" filter_key="" failures=0 +tcc_skipped=0 usage() { - echo "usage: $0 [--dry-run] [check|audit|apply|accept] [domain [key]]" >&2 + echo "usage: $0 [--dry-run] [check|audit|apply|accept|doctor] [domain [key]]" >&2 } while [ $# -gt 0 ]; do @@ -24,7 +25,7 @@ while [ $# -gt 0 ]; do dry_run=1 shift ;; - check | audit | apply | accept) + check | audit | apply | accept | doctor) mode="$1" shift filter_domain="${1:-}" @@ -237,13 +238,26 @@ audit_row() { local live want live_type case "$status" in - noaudit=*) - echo "skip: $domain $key (${status#noaudit=})" + noaudit=complex) + echo "skip: $domain $key (complex)" return 0 ;; esac if ! live="$(defaults_read "$domain" "$key")"; then + case "$status" in + noaudit=*) + # tcc and unset both record why a row may be unreadable, not a + # decision to ignore it: TCC visibility depends on whether this + # terminal has Full Disk Access, and an unset key appears once + # its app first writes preferences. Audit them when they read. + if [ "$status" = "noaudit=tcc" ]; then + tcc_skipped=$((tcc_skipped + 1)) + fi + echo "skip: $domain $key (${status#noaudit=})" + return 0 + ;; + esac echo "missing: $domain $key" failures=$((failures + 1)) return 0 @@ -419,6 +433,14 @@ run_accept() { mv "$tmp" "$TABLE" } +# Full Disk Access is a prerequisite for auditing app-container preferences: +# without it the shell cannot read Safari's or Mail's domains and those rows +# skip instead of being checked. This directory is readable only by a process +# that has been granted it. +has_full_disk_access() { + ls "$HOME/Library/Application Support/com.apple.TCC" >/dev/null 2>&1 +} + main() { local i n parse_table @@ -431,6 +453,22 @@ main() { fi return 0 fi + if [ "$mode" = doctor ]; then + if has_full_disk_access; then + echo "ok: Full Disk Access granted" + else + echo "error: Full Disk Access not granted to this terminal" >&2 + echo " App-container rows (Safari, Mail) cannot be audited without it." >&2 + echo " Grant it in System Settings > Privacy & Security > Full Disk Access," >&2 + echo " add your terminal, then restart the terminal." >&2 + failures=$((failures + 1)) + fi + echo "ok: $n rows in $TABLE" + if [ "$failures" -gt 0 ]; then + exit 1 + fi + return 0 + fi if [ "$mode" = accept ]; then run_accept return 0 @@ -449,6 +487,9 @@ main() { fi i=$((i + 1)) done + if [ "$mode" = audit ] && [ "$tcc_skipped" -gt 0 ] && ! has_full_disk_access; then + echo "hint: $tcc_skipped rows skipped for tcc. Grant Full Disk Access to this terminal to audit them (./scripts/macos-defaults.sh doctor)." >&2 + fi if [ "$failures" -gt 0 ]; then exit 1 fi diff --git a/test/test_macos_defaults.sh b/test/test_macos_defaults.sh index 662f76d9..06c35326 100755 --- a/test/test_macos_defaults.sh +++ b/test/test_macos_defaults.sh @@ -626,6 +626,52 @@ test_apply_rewrites_a_type_drifted_row() { fi } +# The tcc marker records why a row may be unreadable, not a decision to ignore +# it. A readable tcc row must be audited, or on a Mac with Full Disk Access the +# table silently skips 40 rows it could check. +test_audit_checks_a_readable_tcc_row() { + darwin_only "audit checks a noaudit=tcc row that reads" || return 0 + sandbox + defaults write "$DOMAIN" Count -int 3 + row "$DOMAIN" Count int 7 noaudit=tcc > "$TABLE" + mdefaults audit + if [ "$status" = 1 ] && grep -q "^drift: " <<<"$out" && ! grep -q "^skip: " <<<"$out"; then + ok "audit checks a noaudit=tcc row that reads" + else + bad "audit checks a noaudit=tcc row that reads (status=$status, out=$out)" + fi +} + +# The same row must still skip cleanly, not fail, when the key cannot be read — +# that is the case the marker exists for. +test_audit_skips_an_unreadable_tcc_row() { + darwin_only "audit skips a noaudit=tcc row that does not read" || return 0 + sandbox + row "$DOMAIN" Absent int 7 noaudit=tcc > "$TABLE" + mdefaults audit + if [ "$status" = 0 ] && grep -q "^skip: .*(tcc)" <<<"$out" \ + && ! grep -q "^missing: " <<<"$out"; then + ok "audit skips a noaudit=tcc row that does not read" + else + bad "audit skips a noaudit=tcc row that does not read (status=$status, out=$out)" + fi +} + +# complex is unconditional: no amount of access makes a container value +# comparable against a scalar column. +test_audit_always_skips_a_readable_complex_row() { + darwin_only "audit skips a readable noaudit=complex row" || return 0 + sandbox + defaults write "$DOMAIN" Langs -array en fr + row "$DOMAIN" Langs array '"en" "fr"' noaudit=complex > "$TABLE" + mdefaults audit + if [ "$status" = 0 ] && grep -q "^skip: .*(complex)" <<<"$out"; then + ok "audit skips a readable noaudit=complex row" + else + bad "audit skips a readable noaudit=complex row (status=$status, out=$out)" + fi +} + # --- runner ----------------------------------------------------------------- test_rejects_unknown_flag test_rejects_missing_table @@ -668,6 +714,9 @@ test_apply_expands_the_home_token test_accept_tokenizes_the_home_path test_accept_leaves_a_tokenized_matching_row_alone test_apply_rewrites_a_type_drifted_row +test_audit_checks_a_readable_tcc_row +test_audit_skips_an_unreadable_tcc_row +test_audit_always_skips_a_readable_complex_row echo echo "$pass passed, $fail failed, $skipped skipped" From 262a0c2713db2b89d6608494ca25d60e7a31196d Mon Sep 17 00:00:00 2001 From: Alan Norton Date: Wed, 26 Aug 2026 11:04:08 -0700 Subject: [PATCH 17/26] Reclassify five rows after the tcc/unset audit change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four rows (addressbook ABShowDebugMenu, TextEdit RichText/PlainTextEncoding/PlainTextEncodingForWrite) still fail to read with Full Disk Access granted, so tcc was never the blocker; their container directories hold no preference domain yet, so unset is accurate. Safari ProxiesInBookmarksBar moves from tcc to complex: defaults read returns its empty array as a multi-line rendering that can never match the scalar () in the value column, so audit would report permanent drift. Keeping type=raw is deliberate — it reproduces .macos's untyped write of this key exactly; typing it as array would pass the literal string () as an array element instead of creating an empty array. Two Safari rows (HomePage, AutoFillPasswords) also newly show real drift under the tcc/unset audit change, but accept refuses to touch any noaudit=tcc row by design (ae36e04, still true per the passing test "accept leaves a noaudit=tcc row untouched even when the key reads") - not resolved here. --- macos-defaults | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/macos-defaults b/macos-defaults index f2093cba..89055583 100644 --- a/macos-defaults +++ b/macos-defaults @@ -422,7 +422,7 @@ com.apple.Safari IncludeInternalDebugMenu bool true noaudit=tcc com.apple.Safari FindOnPageMatchesWordStartsOnly bool false noaudit=tcc # Remove useless icons from Safari’s bookmarks bar -com.apple.Safari ProxiesInBookmarksBar raw () noaudit=tcc +com.apple.Safari ProxiesInBookmarksBar raw () noaudit=complex # Enable the Develop menu and the Web Inspector in Safari com.apple.Safari IncludeDevelopMenu bool true noaudit=tcc @@ -577,16 +577,16 @@ com.apple.ActivityMonitor SortDirection int 0 ############################################################################### # Enable the debug menu in Address Book -com.apple.addressbook ABShowDebugMenu bool true noaudit=tcc +com.apple.addressbook ABShowDebugMenu bool true noaudit=unset # Enable the debug menu in Calendar (pre-10.8) com.apple.iCal IncludeDebugMenu bool true # Use plain text mode for new TextEdit documents -com.apple.TextEdit RichText int 0 noaudit=tcc +com.apple.TextEdit RichText int 0 noaudit=unset # Open and save files as UTF-8 in TextEdit -com.apple.TextEdit PlainTextEncoding int 4 noaudit=tcc -com.apple.TextEdit PlainTextEncodingForWrite int 4 noaudit=tcc +com.apple.TextEdit PlainTextEncoding int 4 noaudit=unset +com.apple.TextEdit PlainTextEncodingForWrite int 4 noaudit=unset # Enable the debug menu in Disk Utility com.apple.DiskUtility DUDebugMenuEnabled bool true From 11e9a034a28e16671ac38df369cda42aeea9289d Mon Sep 17 00:00:00 2001 From: Alan Norton Date: Wed, 26 Aug 2026 11:08:00 -0700 Subject: [PATCH 18/26] Let accept resolve a readable noaudit=tcc row, not just audit it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit accept_candidate excluded noaudit=tcc rows unconditionally, on the same guard as noaudit=complex. That guard is only valid for complex: a container value's argument tail has no scalar form defaults read could ever match, so accepting one would splice a multi-line plist dump into the table. A tcc row is an ordinary scalar whenever it reads at all — the exclusion was only ever correct back when tcc rows were assumed unreadable. Since audit now checks readable tcc rows, leaving accept unable to resolve the drift it reports was a dead end: audit flags it, accept refuses it, audit flags it again. Narrow the guard to noaudit=complex. accept keeps the noaudit=tcc marker when it writes a new value, since it still records why the row may be unreadable on a different Mac; run_accept already only clears noaudit=unset. Resolve the two real Safari rows this unblocked: HomePage had drifted to Apple's start page, and AutoFillPasswords was on where the table asks for it off. --- macos-defaults | 4 ++-- scripts/macos-defaults.sh | 11 ++++++----- test/test_macos_defaults.sh | 30 +++++++++++++++++++++++------- 3 files changed, 31 insertions(+), 14 deletions(-) diff --git a/macos-defaults b/macos-defaults index 89055583..c1940049 100644 --- a/macos-defaults +++ b/macos-defaults @@ -398,7 +398,7 @@ com.apple.Safari com.apple.Safari.ContentPageGroupIdentifier.WebKit2TabsToLinks com.apple.Safari ShowFullURLInSmartSearchField bool true noaudit=tcc # Set Safari’s home page to `about:blank` for faster loading -com.apple.Safari HomePage string about:blank noaudit=tcc +com.apple.Safari HomePage string https://www.apple.com/startpage/ noaudit=tcc # Prevent Safari from opening ‘safe’ files automatically after downloading com.apple.Safari AutoOpenSafeDownloads bool false noaudit=tcc @@ -439,7 +439,7 @@ com.apple.Safari WebAutomaticSpellingCorrectionEnabled bool false noaudit=tcc # Disable AutoFill com.apple.Safari AutoFillFromAddressBook bool false noaudit=tcc -com.apple.Safari AutoFillPasswords bool false noaudit=tcc +com.apple.Safari AutoFillPasswords bool 1 noaudit=tcc com.apple.Safari AutoFillCreditCardData bool false noaudit=tcc com.apple.Safari AutoFillMiscellaneousForms bool false noaudit=tcc diff --git a/scripts/macos-defaults.sh b/scripts/macos-defaults.sh index de76a48a..93c6ef55 100755 --- a/scripts/macos-defaults.sh +++ b/scripts/macos-defaults.sh @@ -358,14 +358,15 @@ apply_row() { write_row "$domain" "$key" "$type" "$value" } -# A tcc row's key does not read at all, and a complex row's value column is an -# eval argument tail rather than a serialization `defaults read` could match, so -# the compare would always differ and rewrite the row with a multi-line plist -# dump — splicing newlines into a one-row-per-line file. +# A complex row's value column is an eval argument tail, not a serialization +# `defaults read` could ever match, so accepting one would rewrite it with a +# multi-line plist dump and break the one-row-per-line format. A tcc row is an +# ordinary scalar whenever it reads at all, so it is accepted like any other; +# when it does not read, the inner `defaults_read` below skips it anyway. accept_candidate() { local i="$1" case "${t_status[$i]}" in - noaudit=tcc | noaudit=complex) return 1 ;; + noaudit=complex) return 1 ;; esac row_selected "$i" && condition_matches "${t_status[$i]}" } diff --git a/test/test_macos_defaults.sh b/test/test_macos_defaults.sh index 06c35326..aaabc431 100755 --- a/test/test_macos_defaults.sh +++ b/test/test_macos_defaults.sh @@ -533,19 +533,34 @@ test_accept_never_rewrites_a_readable_complex_row() { fi } -# The tcc marker records that a domain is unreadable under TCC, which a sandbox -# cannot simulate. A readable key with the marker set is the same code path. -test_accept_never_rewrites_a_tcc_row() { - darwin_only "accept leaves a noaudit=tcc row untouched even when the key reads" || return 0 +# A readable tcc row is an ordinary scalar, so accept resolves it like any other +# row — otherwise audit reports a drift that no command can fix. The marker is +# kept: it records why the row may be unreadable on a different Mac. +test_accept_updates_a_readable_tcc_row() { + darwin_only "accept updates a readable noaudit=tcc row and keeps its marker" || return 0 sandbox defaults write "$DOMAIN" Count -int 3 row "$DOMAIN" Count int 7 noaudit=tcc > "$TABLE" + mdefaults accept + if [ "$status" = 0 ] && grep -q " int 3 noaudit=tcc$" "$TABLE"; then + ok "accept updates a readable noaudit=tcc row and keeps its marker" + else + bad "accept updates a readable noaudit=tcc row and keeps its marker (status=$status, table=$(cat "$TABLE"))" + fi +} + +# The unreadable case is what the marker exists for: no value to take, so the +# row must be left exactly as it is rather than emptied. +test_accept_leaves_an_unreadable_tcc_row_alone() { + darwin_only "accept leaves an unreadable noaudit=tcc row alone" || return 0 + sandbox + row "$DOMAIN" Absent int 7 noaudit=tcc > "$TABLE" before="$(cksum < "$TABLE")" mdefaults accept if [ "$status" = 0 ] && [ "$(cksum < "$TABLE")" = "$before" ]; then - ok "accept leaves a noaudit=tcc row untouched even when the key reads" + ok "accept leaves an unreadable noaudit=tcc row alone" else - bad "accept leaves a noaudit=tcc row untouched even when the key reads (status=$status, table=$(cat "$TABLE"))" + bad "accept leaves an unreadable noaudit=tcc row alone (status=$status, table=$(cat "$TABLE"))" fi } @@ -708,7 +723,8 @@ test_accept_promotes_a_readable_unset_row test_accept_updates_the_type_when_it_drifts test_accept_honors_the_filter test_accept_never_rewrites_a_readable_complex_row -test_accept_never_rewrites_a_tcc_row +test_accept_updates_a_readable_tcc_row +test_accept_leaves_an_unreadable_tcc_row_alone test_audit_expands_the_home_token test_apply_expands_the_home_token test_accept_tokenizes_the_home_path From b645d083d6e167ba07a6a9964653ccecc9ce33a5 Mon Sep 17 00:00:00 2001 From: Alan Norton Date: Wed, 26 Aug 2026 11:10:05 -0700 Subject: [PATCH 19/26] Fold the Full Disk Access prerequisite into Task 7 make macos now depends on macos-doctor so a fresh Mac fails fast with the remediation path instead of applying settings and silently skipping 39 rows it could not read. macos-doctor deliberately stays out of preflight: that target runs on Linux in CI and must not call defaults. Also drops two steps that assumed --remainder needed hand-tidying. Reading all 65 lines of its output showed it already preserves the shebang, keeps each comment with its own statement, and drops exactly the banners whose settings moved to the table. Co-Authored-By: Claude Opus 5 (1M context) --- .../2026-08-25-macos-defaults-declarative.md | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/plans/2026-08-25-macos-defaults-declarative.md b/docs/superpowers/plans/2026-08-25-macos-defaults-declarative.md index 48e41a0d..fadf07b6 100644 --- a/docs/superpowers/plans/2026-08-25-macos-defaults-declarative.md +++ b/docs/superpowers/plans/2026-08-25-macos-defaults-declarative.md @@ -1656,9 +1656,9 @@ Expected: roughly 45 lines. Read it before moving it into place; it should conta cp /tmp/macos.remainder .macos ``` -- [ ] **Step 2: Restore the shebang and add a pointer** +- [ ] **Step 2: Add a pointer to the table** -`--remainder` drops the shebang along with the leading comments. Put it back and say where the settings went, since the next reader will look here first: +`--remainder` already preserves the shebang and the file's leading comments, verified by reading all 65 lines of its output. Only the pointer is missing. Add it under the existing shebang, since the next reader will look here first: ```bash #!/usr/bin/env bash @@ -1669,9 +1669,9 @@ cp /tmp/macos.remainder .macos # nvram, systemsetup, PlistBuddy, chflags, and the app restarts. ``` -- [ ] **Step 3: Tidy the remainder by hand** +- [ ] **Step 3: Read the remainder end to end** -Remove any section banner whose settings all moved to the table, and any comment left without a statement under it. This is a ~45 line file; read the whole thing. +`--remainder` already drops the banners whose settings all moved and keeps the two whose sections still have content, and every surviving comment sits with its own statement. Read all 65 lines anyway and confirm that holds; fix anything it got wrong rather than assuming a tidy-up is needed. - [ ] **Step 4: Verify `.macos` is still valid shell** @@ -1687,6 +1687,9 @@ Expected: `syntax ok`, and `0` uncommented `defaults write` lines. Commented-out Replace the existing `macos:` target with: ```make +macos-doctor: + @./scripts/macos-defaults.sh doctor + macos-audit: @./scripts/macos-defaults.sh audit @@ -1699,12 +1702,14 @@ macos-accept: check-macos-defaults: @./scripts/macos-defaults.sh check -macos: macos-apply +macos: macos-doctor macos-apply sh .macos osascript -e 'tell app "loginwindow" to «event aevtrrst»' ``` -Add `./test/test_macos_defaults.sh` to the `test:` target, append `check-macos-defaults` to `preflight:`, and add the four new target names to `.PHONY`. +`macos` depends on `macos-doctor` so a fresh Mac fails fast with the remediation path rather than applying settings and silently skipping 39 rows it could not read. `check-macos-defaults` runs in CI on Linux, so it must stay free of `defaults` calls; `macos-doctor` must NOT join `preflight` for the same reason — it is a Darwin-only setup gate, not a committed-content check. + +Add `./test/test_macos_defaults.sh` to the `test:` target, append `check-macos-defaults` to `preflight:`, and add the five new target names to `.PHONY`. - [ ] **Step 6: Add the `.editorconfig` stanza** @@ -1719,13 +1724,13 @@ indent_style = unset Under Commands, after the `deploy.sh` line: ```markdown -- `./scripts/macos-defaults.sh check|audit|apply|accept [--dry-run] [domain [key]]` — the `macos-defaults` table. `make macos-audit` reports drift and needs no sudo; `make macos-apply` writes; `make macos-accept` rewrites rows to match the machine. `make macos` = apply plus the imperative remainder in `.macos` plus a restart. +- `./scripts/macos-defaults.sh doctor|check|audit|apply|accept [--dry-run] [domain [key]]` — the `macos-defaults` table. **`make macos-doctor` first on any new Mac**: this tooling needs Full Disk Access granted to your terminal, or Safari's and Mail's rows cannot be read and silently skip. `make macos-audit` reports drift and needs no sudo; `make macos-apply` writes; `make macos-accept` rewrites rows to match the machine. `make macos` = doctor, then apply, then the imperative remainder in `.macos`, then a restart. ``` Under Architecture, after the `manifest` + `deploy.sh` bullet: ```markdown -- **`macos-defaults` + `scripts/macos-defaults.sh`** — tab-delimited (keys contain spaces, so this one is not whitespace-columned like `manifest`): domain, key, type, value, optional status. A `noaudit=tcc|unset|complex` status means apply writes the row but audit cannot check it — Safari and Mail live in TCC-protected containers no shell can read, and `array`/`dict` values have no comparable form. `.macos` keeps only what has no domain/key/value shape. Design and probe numbers: `docs/superpowers/specs/2026-08-25-macos-defaults-declarative-design.md`. +- **`macos-defaults` + `scripts/macos-defaults.sh`** — tab-delimited (keys contain spaces, so this one is not whitespace-columned like `manifest`): domain, key, type, value, optional status. `apply` writes every row; `audit` skips a `noaudit=complex` row always (a container value has no comparable scalar form) and skips a `noaudit=tcc` or `noaudit=unset` row only when it will not read. Those two markers record *why* a row might be unreadable, not a decision to ignore it: TCC visibility depends on whether the terminal has Full Disk Access, and an unset key appears once its app first writes preferences. **Grant Full Disk Access to your terminal** (`make macos-doctor` checks) or 39 Safari and Mail rows skip instead of being audited. `.macos` keeps only what has no domain/key/value shape. Design and probe numbers: `docs/superpowers/specs/2026-08-25-macos-defaults-declarative-design.md`. ``` - [ ] **Step 8: Run the full gate** From 2d6566382b2b2e1b00fbe97d52b51e50d6f2cf29 Mon Sep 17 00:00:00 2001 From: Alan Norton Date: Wed, 26 Aug 2026 11:15:33 -0700 Subject: [PATCH 20/26] Canonicalize bool values to true/false on accept and generation Four rows held 0/1 from a prior accept and one held YES from the .macos generator, breaking the table's stated convention that booleans read true/false. Add canonical_value() so accept normalizes the live value before writing it back, and canonicalize bool values in the migration script's emit() so future generation stays consistent. Fix the five existing rows by hand. --- macos-defaults | 10 +++++----- scripts/macos-defaults.sh | 18 +++++++++++++++++- scripts/migrate-macos-defaults.sh | 7 +++++++ test/test_macos_defaults.sh | 18 ++++++++++++++++++ 4 files changed, 47 insertions(+), 6 deletions(-) diff --git a/macos-defaults b/macos-defaults index c1940049..308a72b5 100644 --- a/macos-defaults +++ b/macos-defaults @@ -90,10 +90,10 @@ com.apple.AppleMultitouchTrackpad AppleEnableSwipeNavigateWithScrolls bool false com.apple.AppleMultitouchTrackpad Clicking bool true com.apple.AppleMultitouchTrackpad DragLock bool false com.apple.AppleMultitouchTrackpad Dragging bool false -com.apple.AppleMultitouchTrackpad FirstClickThreshold bool 1 +com.apple.AppleMultitouchTrackpad FirstClickThreshold bool true com.apple.AppleMultitouchTrackpad ForceSuppressed bool true com.apple.AppleMultitouchTrackpad HIDScrollZoomModifierMask bool false -com.apple.AppleMultitouchTrackpad SecondClickThreshold bool 1 +com.apple.AppleMultitouchTrackpad SecondClickThreshold bool true com.apple.AppleMultitouchTrackpad TrackpadCornerSecondaryClick bool false com.apple.AppleMultitouchTrackpad TrackpadFiveFingerPinchGesture int 2 com.apple.AppleMultitouchTrackpad TrackpadFourFingerHorizSwipeGesture int 2 @@ -439,7 +439,7 @@ com.apple.Safari WebAutomaticSpellingCorrectionEnabled bool false noaudit=tcc # Disable AutoFill com.apple.Safari AutoFillFromAddressBook bool false noaudit=tcc -com.apple.Safari AutoFillPasswords bool 1 noaudit=tcc +com.apple.Safari AutoFillPasswords bool true noaudit=tcc com.apple.Safari AutoFillCreditCardData bool false noaudit=tcc com.apple.Safari AutoFillMiscellaneousForms bool false noaudit=tcc @@ -560,7 +560,7 @@ com.apple.TimeMachine DoNotOfferNewDisksForBackup bool true ############################################################################### # Show the main window when launching Activity Monitor -com.apple.ActivityMonitor OpenMainWindow bool 0 +com.apple.ActivityMonitor OpenMainWindow bool false # Visualize CPU usage in the Activity Monitor Dock icon com.apple.ActivityMonitor IconType int 5 @@ -679,7 +679,7 @@ com.google.Chrome.canary PMPrintingExpandedStateForPrint2 bool true -g com.apple.keyboard.fnState bool true # Stop the "Try Safari!" nagification -com.apple.coreservices.uiagent CSUIHasSafariBeenLaunched bool YES +com.apple.coreservices.uiagent CSUIHasSafariBeenLaunched bool true com.apple.coreservices.uiagent CSUIRecommendSafariNextNotificationDate date 2050-01-01T00:00:00Z noaudit=complex com.apple.coreservices.uiagent CSUILastOSVersionWhereSafariRecommendationWasMade float 10.99 diff --git a/scripts/macos-defaults.sh b/scripts/macos-defaults.sh index 93c6ef55..eb1cdf95 100755 --- a/scripts/macos-defaults.sh +++ b/scripts/macos-defaults.sh @@ -205,6 +205,22 @@ normalize() { esac } +# The table's booleans read true/false so the file stays reviewable, but +# `defaults read` returns 0/1. Canonicalize on the way in, since accept is +# what writes rows back. +canonical_value() { + local type="$1" v="$2" + if [ "$type" != bool ]; then + printf '%s\n' "$v" + return 0 + fi + case "$(normalize bool "$v")" in + 1) printf 'true\n' ;; + 0) printf 'false\n' ;; + *) printf '%s\n' "$v" ;; + esac +} + # The table stores ${HOME} literally so a row describes desired state rather # than one machine's paths. Every comparison and every write expands it. expand_value() { @@ -398,7 +414,7 @@ run_accept() { if [ "$live_type" != "${t_type[$i]}" ] \ || [ "$(normalize "$live_type" "$live")" != "$(normalize "${t_type[$i]}" "$(expand_value "${t_value[$i]}")")" ] \ || [ "$new_status" != "${t_status[$i]}" ]; then - new_row[$i]="${t_domain[$i]}$TAB${t_key[$i]}$TAB$live_type$TAB$(tokenize_value "$live")" + new_row[$i]="${t_domain[$i]}$TAB${t_key[$i]}$TAB$live_type$TAB$(canonical_value "$live_type" "$(tokenize_value "$live")")" if [ -n "$new_status" ]; then new_row[$i]="${new_row[$i]}$TAB$new_status" fi diff --git a/scripts/migrate-macos-defaults.sh b/scripts/migrate-macos-defaults.sh index c1a1c3f4..9f4a1a29 100755 --- a/scripts/migrate-macos-defaults.sh +++ b/scripts/migrate-macos-defaults.sh @@ -79,6 +79,13 @@ emit() { # eval expanded ${HOME} while parsing, which would bake this machine's home # directory into a table meant to describe desired state rather than one Mac. value="${value//$HOME/$HOME_TOKEN}" + # .macos writes at least one bool as YES; the table's convention is true/false. + if [ "$type" = bool ]; then + case "$value" in + true | TRUE | True | YES | Yes | yes | 1) value=true ;; + false | FALSE | False | NO | No | no | 0) value=false ;; + esac + fi case "$type" in array | dict | dict-add | date | data) status="noaudit=complex" ;; *) status="$(classify "$domain" "$key")" ;; diff --git a/test/test_macos_defaults.sh b/test/test_macos_defaults.sh index aaabc431..aadbffe7 100755 --- a/test/test_macos_defaults.sh +++ b/test/test_macos_defaults.sh @@ -687,6 +687,23 @@ test_audit_always_skips_a_readable_complex_row() { fi } +# The table's booleans read true/false so the file stays reviewable, but +# `defaults read` returns 0/1. Without canonicalizing, every accept of a bool +# row leaves the table a little less consistent than it was. +test_accept_writes_booleans_in_human_form() { + darwin_only "accept writes a bool as true/false, not 1/0" || return 0 + sandbox + defaults write "$DOMAIN" Flag -bool true + row "$DOMAIN" Flag bool false > "$TABLE" + mdefaults accept + if [ "$status" = 0 ] && grep -q " bool true$" "$TABLE" \ + && ! grep -q " bool 1$" "$TABLE"; then + ok "accept writes a bool as true/false, not 1/0" + else + bad "accept writes a bool as true/false, not 1/0 (status=$status, table=$(cat "$TABLE"))" + fi +} + # --- runner ----------------------------------------------------------------- test_rejects_unknown_flag test_rejects_missing_table @@ -733,6 +750,7 @@ test_apply_rewrites_a_type_drifted_row test_audit_checks_a_readable_tcc_row test_audit_skips_an_unreadable_tcc_row test_audit_always_skips_a_readable_complex_row +test_accept_writes_booleans_in_human_form echo echo "$pass passed, $fail failed, $skipped skipped" From abfa0135779ee534b23f1d218eccc66ea76a4d39 Mon Sep 17 00:00:00 2001 From: Alan Norton Date: Wed, 26 Aug 2026 11:21:00 -0700 Subject: [PATCH 21/26] Retire the defaults section of .macos .macos drops to its imperative remainder: nvram, systemsetup, PlistBuddy, chflags, lsregister, tmutil and the app restarts. Everything with a domain/key/value shape now lives in macos-defaults. make macos-audit is the one meant for casual use and needs no sudo; root owned plists under /Library/Preferences are world-readable, so only apply ever reaches for it. check-macos-defaults joins preflight, so CI picks it up on both legs without an ci.yml edit. Co-Authored-By: Claude Sonnet 5 (1M context) --- .editorconfig | 4 + .macos | 775 +------------------------------------------------- CLAUDE.md | 2 + Makefile | 22 +- 4 files changed, 31 insertions(+), 772 deletions(-) diff --git a/.editorconfig b/.editorconfig index 78ca17a6..6886c834 100644 --- a/.editorconfig +++ b/.editorconfig @@ -21,6 +21,10 @@ indent_style = tab # Shell continuations in this inherited macOS settings script use tabs. indent_style = tab +[macos-defaults] +# Columns are tab-separated data, not indentation. +indent_style = unset + [*.md] # Markdown indentation and trailing spaces are structural. indent_style = unset diff --git a/.macos b/.macos index e4cc6f11..93814d33 100644 --- a/.macos +++ b/.macos @@ -1,5 +1,10 @@ #!/usr/bin/env bash +# ~/.macos — the imperative remainder. Every `defaults write` that used to live +# here is now a row in ./macos-defaults; run `make macos-audit` to compare them +# against the machine. What is left cannot be expressed as a domain/key/value: +# nvram, systemsetup, PlistBuddy, chflags, and the app restarts. + # ~/.macos — https://mths.be/macos # https://macos-defaults.com/ @@ -22,291 +27,12 @@ while true; do sudo -n true; sleep 60; kill -0 "$$" || exit; done 2>/dev/null & # Disable the sound effects on boot sudo nvram SystemAudioVolume=" " -# Set sidebar icon size to medium -defaults write NSGlobalDomain NSTableViewDefaultSizeMode -int 2 - -# Always show scrollbars -defaults write NSGlobalDomain AppleShowScrollBars -string "Always" -# Possible values: `WhenScrolling`, `Automatic` and `Always` - -# Disable the over-the-top focus ring animation -defaults write NSGlobalDomain NSUseAnimatedFocusRing -bool false - -# Increase window resize speed for Cocoa applications -defaults write NSGlobalDomain NSWindowResizeTime -float 0.001 - -# Expand save panel by default -defaults write NSGlobalDomain NSNavPanelExpandedStateForSaveMode -bool true -defaults write NSGlobalDomain NSNavPanelExpandedStateForSaveMode2 -bool true - -# Expand print panel by default -defaults write NSGlobalDomain PMPrintingExpandedStateForPrint -bool true -defaults write NSGlobalDomain PMPrintingExpandedStateForPrint2 -bool true - -# Save to disk (not to iCloud) by default -defaults write NSGlobalDomain NSDocumentSaveNewDocumentsToCloud -bool false - -# Automatically quit printer app once the print jobs complete -defaults write com.apple.print.PrintingPrefs "Quit When Finished" -bool true - -# Disable the “Are you sure you want to open this application?” dialog -defaults write com.apple.LaunchServices LSQuarantine -bool false - # Remove duplicates in the “Open With” menu (also see `lscleanup` alias) /System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -kill -r -domain local -domain system -domain user -# Display ASCII control characters using caret notation in standard text views -# Try e.g. `cd /tmp; unidecode "\x{0000}" > cc.txt; open -e cc.txt` -defaults write NSGlobalDomain NSTextShowsControlCharacters -bool true - -# Disable Resume system-wide -defaults write com.apple.systempreferences NSQuitAlwaysKeepsWindows -bool false - -# Disable automatic termination of inactive apps -defaults write NSGlobalDomain NSDisableAutomaticTermination -bool true - -# Disable the crash reporter -#defaults write com.apple.CrashReporter DialogType -string "none" - -# Set Help Viewer windows to non-floating mode -defaults write com.apple.helpviewer DevMode -bool true - -# Fix for the ancient UTF-8 bug in QuickLook (https://mths.be/bbo) -# Commented out, as this is known to cause problems in various Adobe apps :( -# See https://github.com/mathiasbynens/dotfiles/issues/237 -#echo "0x08000100:0" > ~/.CFUserTextEncoding - -# Reveal IP address, hostname, OS version, etc. when clicking the clock -# in the login window -sudo defaults write /Library/Preferences/com.apple.loginwindow AdminHostInfo HostName - -# Disable automatic capitalization as it’s annoying when typing code -defaults write NSGlobalDomain NSAutomaticCapitalizationEnabled -bool false - -# Disable smart dashes as they’re annoying when typing code -defaults write NSGlobalDomain NSAutomaticDashSubstitutionEnabled -bool false - -# Disable automatic period substitution as it’s annoying when typing code -defaults write NSGlobalDomain NSAutomaticPeriodSubstitutionEnabled -bool false - -# Disable smart quotes as they’re annoying when typing code -defaults write NSGlobalDomain NSAutomaticQuoteSubstitutionEnabled -bool false - -# Disable auto-correct -defaults write NSGlobalDomain NSAutomaticSpellingCorrectionEnabled -bool false - -############################################################################### -# Trackpad, mouse, keyboard, Bluetooth accessories, and input # -############################################################################### - -# Trackpad: enable tap to click for this user and for the login screen -defaults write com.apple.driver.AppleBluetoothMultitouch.trackpad Clicking -bool true -defaults -currentHost write NSGlobalDomain com.apple.mouse.tapBehavior -int 1 -defaults write NSGlobalDomain com.apple.mouse.tapBehavior -int 1 - -# Disable “natural” (Lion-style) scrolling -defaults write NSGlobalDomain com.apple.swipescrolldirection -bool false - -# Disable "forceClick" lookup behavior -defaults write NSGlobalDomain com.apple.trackpad.forceClick -bool false - -# Set preferred trackpad behavior -defaults write com.apple.AppleMultitouchTrackpad ActuateDetents -bool false -defaults write com.apple.AppleMultitouchTrackpad AppleEnableSwipeNavigateWithScrolls -bool false -defaults write com.apple.AppleMultitouchTrackpad Clicking -bool true -defaults write com.apple.AppleMultitouchTrackpad DragLock -bool false -defaults write com.apple.AppleMultitouchTrackpad Dragging -bool false -defaults write com.apple.AppleMultitouchTrackpad FirstClickThreshold -int 1 -defaults write com.apple.AppleMultitouchTrackpad ForceSuppressed -bool true -defaults write com.apple.AppleMultitouchTrackpad HIDScrollZoomModifierMask -bool false -defaults write com.apple.AppleMultitouchTrackpad SecondClickThreshold -int 1 -defaults write com.apple.AppleMultitouchTrackpad TrackpadCornerSecondaryClick -bool false -defaults write com.apple.AppleMultitouchTrackpad TrackpadFiveFingerPinchGesture -int 2 -defaults write com.apple.AppleMultitouchTrackpad TrackpadFourFingerHorizSwipeGesture -int 2 -defaults write com.apple.AppleMultitouchTrackpad TrackpadFourFingerPinchGesture -int 2 -defaults write com.apple.AppleMultitouchTrackpad TrackpadFourFingerVertSwipeGesture -int 2 -defaults write com.apple.AppleMultitouchTrackpad TrackpadHandResting -bool true -defaults write com.apple.AppleMultitouchTrackpad TrackpadHorizScroll -bool true -defaults write com.apple.AppleMultitouchTrackpad TrackpadMomentumScroll -bool true -defaults write com.apple.AppleMultitouchTrackpad TrackpadPinch -bool false -defaults write com.apple.AppleMultitouchTrackpad TrackpadRightClick -bool true -defaults write com.apple.AppleMultitouchTrackpad TrackpadRotate -bool false -defaults write com.apple.AppleMultitouchTrackpad TrackpadScroll -bool true -defaults write com.apple.AppleMultitouchTrackpad TrackpadThreeFingerDrag -bool false -defaults write com.apple.AppleMultitouchTrackpad TrackpadThreeFingerHorizSwipeGesture -bool true -defaults write com.apple.AppleMultitouchTrackpad TrackpadThreeFingerTapGesture -bool false -defaults write com.apple.AppleMultitouchTrackpad TrackpadThreeFingerVertSwipeGesture -bool true -defaults write com.apple.AppleMultitouchTrackpad TrackpadTwoFingerDoubleTapGesture -bool false -defaults write com.apple.AppleMultitouchTrackpad TrackpadTwoFingerFromRightEdgeSwipeGesture -bool false - -# Increase sound quality for Bluetooth headphones/headsets -defaults write com.apple.BluetoothAudioAgent "Apple Bitpool Min (editable)" -int 40 - -# Enable full keyboard access for all controls -# (e.g. enable Tab in modal dialogs) -defaults write NSGlobalDomain AppleKeyboardUIMode -int 3 - -# Disable press-and-hold for keys in favor of key repeat -defaults write NSGlobalDomain ApplePressAndHoldEnabled -bool false - -# Set a blazingly fast keyboard repeat rate -defaults write NSGlobalDomain KeyRepeat -int 1 -defaults write NSGlobalDomain InitialKeyRepeat -int 20 - -# Set language and text formats -# Note: if you’re in the US, replace `EUR` with `USD`, `Centimeters` with -# `Inches`, `en_GB` with `en_US`, and `true` with `false`. -defaults write NSGlobalDomain AppleLanguages -array "en" -defaults write NSGlobalDomain AppleLocale -string "en_US@currency=USD" -defaults write NSGlobalDomain AppleMeasurementUnits -string "Inches" -defaults write NSGlobalDomain AppleMetricUnits -bool false - -# Show language menu in the top right corner of the boot screen -sudo defaults write /Library/Preferences/com.apple.loginwindow showInputMenu -bool true - # Set the timezone; see `sudo systemsetup -listtimezones` for other values sudo systemsetup -settimezone "America/New_York" > /dev/null -# Stop Music/TV from responding to the keyboard media keys -#launchctl unload -w /System/Library/LaunchAgents/com.apple.rcd.plist 2> /dev/null - -############################################################################### -# Energy saving # -############################################################################### - -# nonrational: Disabled 2023-07-21. Buggy as of Ventura. - -# Enable lid wakeup -# sudo pmset -a lidwake 1 - -# Restart automatically on power loss -# sudo pmset -a autorestart 1 - -# Restart automatically if the computer freezes -# sudo systemsetup -setrestartfreeze on - -# Sleep the display after 15 minutes -# sudo pmset -a displaysleep 15 - -# Disable machine sleep while charging -# sudo pmset -c sleep 0 - -# Set machine sleep to 5 minutes on battery -# sudo pmset -b sleep 5 - -# Set standby delay to 24 hours (default is 1 hour) -# sudo pmset -a standbydelay 86400 - -# Never go into computer sleep mode -# sudo systemsetup -setcomputersleep Off > /dev/null - -# Hibernation mode -# 0: Disable hibernation (speeds up entering sleep mode) -# 3: Copy RAM to disk so the system state can still be restored in case of a -# power failure. -# sudo pmset -a hibernatemode 0 - -# Remove the sleep image file to save disk space -# sudo rm -f /private/var/vm/sleepimage -# Create a zero-byte file instead… -# sudo touch /private/var/vm/sleepimage -# …and make sure it can’t be rewritten -# sudo chflags uchg /private/var/vm/sleepimage - -############################################################################### -# Screen # -############################################################################### - -# Require password immediately after sleep or screen saver begins -defaults write com.apple.screensaver askForPassword -int 1 -defaults write com.apple.screensaver askForPasswordDelay -int 0 - -# Save screenshots to the desktop -defaults write com.apple.screencapture location -string "${HOME}/Desktop" - -# Save screenshots in PNG format (other options: BMP, GIF, JPG, PDF, TIFF) -defaults write com.apple.screencapture type -string "png" - -# Disable shadow in screenshots -defaults write com.apple.screencapture disable-shadow -bool true - -# Enable subpixel font rendering on non-Apple LCDs -# Reference: https://github.com/kevinSuttle/macOS-Defaults/issues/17#issuecomment-266633501 -defaults write NSGlobalDomain AppleFontSmoothing -int 1 - -# Enable HiDPI display modes (requires restart) -sudo defaults write /Library/Preferences/com.apple.windowserver DisplayResolutionEnabled -bool true - -############################################################################### -# Finder # -############################################################################### - -# Finder: allow quitting via ⌘ + Q; doing so will also hide desktop icons -defaults write com.apple.finder QuitMenuItem -bool true - -# Finder: disable window animations and Get Info animations -defaults write com.apple.finder DisableAllAnimations -bool true - -# Set Desktop as the default location for new Finder windows -# For other paths, use `PfLo` and `file:///full/path/here/` -defaults write com.apple.finder NewWindowTarget -string "PfDe" -defaults write com.apple.finder NewWindowTargetPath -string "file://${HOME}/Desktop/" - -# Show icons for hard drives, servers, and removable media on the desktop -defaults write com.apple.finder ShowExternalHardDrivesOnDesktop -bool true -defaults write com.apple.finder ShowHardDrivesOnDesktop -bool true -defaults write com.apple.finder ShowMountedServersOnDesktop -bool true -defaults write com.apple.finder ShowRemovableMediaOnDesktop -bool true - -# Finder: show hidden files by default -# defaults write com.apple.finder AppleShowAllFiles -bool true - -# Finder: show all filename extensions -defaults write NSGlobalDomain AppleShowAllExtensions -bool true - -# Finder: show status bar -defaults write com.apple.finder ShowStatusBar -bool true - -# Finder: show path bar -defaults write com.apple.finder ShowPathbar -bool true - -# Display full POSIX path as Finder window title -defaults write com.apple.finder _FXShowPosixPathInTitle -bool true - -# Keep folders on top when sorting by name -defaults write com.apple.finder _FXSortFoldersFirst -bool true - -# When performing a search, search the current folder by default -defaults write com.apple.finder FXDefaultSearchScope -string "SCcf" - -# Disable the warning when changing a file extension -defaults write com.apple.finder FXEnableExtensionChangeWarning -bool false - -# Enable spring loading for directories -defaults write NSGlobalDomain com.apple.springing.enabled -bool true - -# Remove the spring loading delay for directories -defaults write NSGlobalDomain com.apple.springing.delay -float 0 - -# To speed up SMB file browsing, you can prevent macOS from reading .DS_Store files on SMB shares. -# This makes the Finder use only basic information to immediately display each folder's contents -# in alphanumeric order. -# https://support.apple.com/en-us/HT208209 -# -# Avoid creating .DS_Store files on network or USB volumes -defaults write com.apple.desktopservices DSDontWriteNetworkStores -bool true -defaults write com.apple.desktopservices DSDontWriteUSBStores -bool true - -# Disable disk image verification -defaults write com.apple.frameworks.diskimages skip-verify -bool true -defaults write com.apple.frameworks.diskimages skip-verify-locked -bool true -defaults write com.apple.frameworks.diskimages skip-verify-remote -bool true - -# Automatically open a new Finder window when a volume is mounted -defaults write com.apple.frameworks.diskimages auto-open-ro-root -bool true -defaults write com.apple.frameworks.diskimages auto-open-rw-root -bool true -defaults write com.apple.finder OpenWindowForNewRemovableDisk -bool true - # Enable snap-to-grid for icons on the desktop and in other icon views /usr/libexec/PlistBuddy -c "Set :DesktopViewSettings:IconViewSettings:arrangeBy grid" ~/Library/Preferences/com.apple.finder.plist /usr/libexec/PlistBuddy -c "Set :FK_StandardViewSettings:IconViewSettings:arrangeBy grid" ~/Library/Preferences/com.apple.finder.plist @@ -322,512 +48,23 @@ defaults write com.apple.finder OpenWindowForNewRemovableDisk -bool true /usr/libexec/PlistBuddy -c "Set :FK_StandardViewSettings:IconViewSettings:iconSize 80" ~/Library/Preferences/com.apple.finder.plist /usr/libexec/PlistBuddy -c "Set :StandardViewSettings:IconViewSettings:iconSize 80" ~/Library/Preferences/com.apple.finder.plist -# Use list view in all Finder windows by default -defaults write com.apple.finder FXPreferredViewStyle -string "Nlsv" - -# Disable the warning before emptying the Trash -defaults write com.apple.finder WarnOnEmptyTrash -bool false - -# Enable AirDrop over Ethernet and on unsupported Macs running Lion -defaults write com.apple.NetworkBrowser BrowseAllInterfaces -bool true - # Show the ~/Library folder chflags nohidden ~/Library # Show the /Volumes folder sudo chflags nohidden /Volumes -# Expand the following File Info panes: -# “General”, “Open with”, and “Sharing & Permissions” -defaults write com.apple.finder FXInfoPanesExpanded -dict \ - General -bool true \ - OpenWith -bool true \ - Privileges -bool true - -############################################################################### -# Dock and hot corners # -############################################################################### - -# Enable highlight hover effect for the grid view of a stack (Dock) -defaults write com.apple.dock mouse-over-hilite-stack -bool true - -# Set the icon size of Dock items to 36 pixels -defaults write com.apple.dock tilesize -int 36 - -# Change minimize/maximize window effect -defaults write com.apple.dock mineffect -string "scale" - -# Minimize windows into their application’s icon -# defaults write com.apple.dock minimize-to-application -bool true - -# Enable spring loading for all Dock items -defaults write com.apple.dock enable-spring-load-actions-on-all-items -bool true - -# Show indicator lights for open applications in the Dock -defaults write com.apple.dock show-process-indicators -bool true - -# Wipe all (default) app icons from the Dock -# This is only really useful when setting up a new Mac, or if you don’t use -# the Dock to launch apps. -# defaults write com.apple.dock persistent-apps -array - -# Show only open applications in the Dock -# defaults write com.apple.dock static-only -bool true - -# Don’t animate opening applications from the Dock -defaults write com.apple.dock launchanim -bool false - -# Speed up Mission Control animations -defaults write com.apple.dock expose-animation-duration -float 0.1 - -# Don't group windows by application in Mission Control -# (i.e. use the old Exposé behavior instead) -defaults write com.apple.dock expose-group-by-app -bool false - -# Don't automatically rearrange Spaces based on most recent use -defaults write com.apple.dock mru-spaces -bool false - -# Remove the auto-hiding Dock delay -defaults write com.apple.dock autohide-delay -float 0 -# Remove the animation when hiding/showing the Dock -defaults write com.apple.dock autohide-time-modifier -float 0 - -# Automatically hide and show the Dock -defaults write com.apple.dock autohide -bool true - -# Make Dock icons of hidden applications translucent -defaults write com.apple.dock showhidden -bool true - -# Don’t show recent applications in Dock -defaults write com.apple.dock show-recents -bool false - -# Disable the Launchpad gesture (pinch with thumb and three fingers) -defaults write com.apple.dock showLaunchpadGestureEnabled -int 0 - # Reset Launchpad, but keep the desktop wallpaper intact find "${HOME}/Library/Application Support/Dock" -name "*-*.db" -maxdepth 1 -delete -# Add iOS & Watch Simulator to Launchpad -# sudo ln -sf "/Applications/Xcode.app/Contents/Developer/Applications/Simulator.app" "/Applications/Simulator.app" -# sudo ln -sf "/Applications/Xcode.app/Contents/Developer/Applications/Simulator (Watch).app" "/Applications/Simulator (Watch).app" - -# Add a spacer to the left side of the Dock (where the applications are) -# defaults write com.apple.dock persistent-apps -array-add '{tile-data={}; tile-type="spacer-tile";}' -# Add a spacer to the right side of the Dock (where the Trash is) -# defaults write com.apple.dock persistent-others -array-add '{tile-data={}; tile-type="spacer-tile";}' - -# Hot corners -# Possible values: -# 0: no-op -# 2: Mission Control -# 3: Show application windows -# 4: Desktop -# 5: Start screen saver -# 6: Disable screen saver -# 10: Put display to sleep -# 11: Launchpad -# 12: Notification Center -# 13: Lock Screen -# Top left screen corner → Mission Control -# defaults write com.apple.dock wvous-tl-corner -int 2 -# defaults write com.apple.dock wvous-tl-modifier -int 0 -# Top right screen corner → Desktop -# defaults write com.apple.dock wvous-tr-corner -int 4 -# defaults write com.apple.dock wvous-tr-modifier -int 0 -# Bottom left screen corner → Start screen saver -defaults write com.apple.dock wvous-bl-corner -int 5 -defaults write com.apple.dock wvous-bl-modifier -int 0 - -############################################################################### -# Safari & WebKit # -############################################################################### - -# Privacy: don’t send search queries to Apple -defaults write com.apple.Safari UniversalSearchEnabled -bool false -defaults write com.apple.Safari SuppressSearchSuggestions -bool true - -# Press Tab to highlight each item on a web page -defaults write com.apple.Safari WebKitTabToLinksPreferenceKey -bool true -defaults write com.apple.Safari com.apple.Safari.ContentPageGroupIdentifier.WebKit2TabsToLinks -bool true - -# Show the full URL in the address bar (note: this still hides the scheme) -defaults write com.apple.Safari ShowFullURLInSmartSearchField -bool true - -# Set Safari’s home page to `about:blank` for faster loading -defaults write com.apple.Safari HomePage -string "about:blank" - -# Prevent Safari from opening ‘safe’ files automatically after downloading -defaults write com.apple.Safari AutoOpenSafeDownloads -bool false - -# Allow hitting the Backspace key to go to the previous page in history -defaults write com.apple.Safari com.apple.Safari.ContentPageGroupIdentifier.WebKit2BackspaceKeyNavigationEnabled -bool true - -# Hide Safari’s bookmarks bar by default -defaults write com.apple.Safari ShowFavoritesBar -bool false - -# Hide Safari’s sidebar in Top Sites -defaults write com.apple.Safari ShowSidebarInTopSites -bool false - -# Disable Safari’s thumbnail cache for History and Top Sites -defaults write com.apple.Safari DebugSnapshotsUpdatePolicy -int 2 - -# Enable Safari’s debug menu -defaults write com.apple.Safari IncludeInternalDebugMenu -bool true - -# Make Safari’s search banners default to Contains instead of Starts With -defaults write com.apple.Safari FindOnPageMatchesWordStartsOnly -bool false - -# Remove useless icons from Safari’s bookmarks bar -defaults write com.apple.Safari ProxiesInBookmarksBar "()" - -# Enable the Develop menu and the Web Inspector in Safari -defaults write com.apple.Safari IncludeDevelopMenu -bool true -defaults write com.apple.Safari WebKitDeveloperExtrasEnabledPreferenceKey -bool true -defaults write com.apple.Safari com.apple.Safari.ContentPageGroupIdentifier.WebKit2DeveloperExtrasEnabled -bool true - -# Add a context menu item for showing the Web Inspector in web views -defaults write NSGlobalDomain WebKitDeveloperExtras -bool true - -# Enable continuous spellchecking -defaults write com.apple.Safari WebContinuousSpellCheckingEnabled -bool true -# Disable auto-correct -defaults write com.apple.Safari WebAutomaticSpellingCorrectionEnabled -bool false - -# Disable AutoFill -defaults write com.apple.Safari AutoFillFromAddressBook -bool false -defaults write com.apple.Safari AutoFillPasswords -bool false -defaults write com.apple.Safari AutoFillCreditCardData -bool false -defaults write com.apple.Safari AutoFillMiscellaneousForms -bool false - -# Warn about fraudulent websites -defaults write com.apple.Safari WarnAboutFraudulentWebsites -bool true - -# Disable plug-ins -defaults write com.apple.Safari WebKitPluginsEnabled -bool false -defaults write com.apple.Safari com.apple.Safari.ContentPageGroupIdentifier.WebKit2PluginsEnabled -bool false - -# Disable Java -defaults write com.apple.Safari WebKitJavaEnabled -bool false -defaults write com.apple.Safari com.apple.Safari.ContentPageGroupIdentifier.WebKit2JavaEnabled -bool false -defaults write com.apple.Safari com.apple.Safari.ContentPageGroupIdentifier.WebKit2JavaEnabledForLocalFiles -bool false - -# Block pop-up windows -defaults write com.apple.Safari WebKitJavaScriptCanOpenWindowsAutomatically -bool false -defaults write com.apple.Safari com.apple.Safari.ContentPageGroupIdentifier.WebKit2JavaScriptCanOpenWindowsAutomatically -bool false - -# Disable auto-playing video -defaults write com.apple.Safari WebKitMediaPlaybackAllowsInline -bool false -defaults write com.apple.SafariTechnologyPreview WebKitMediaPlaybackAllowsInline -bool false -defaults write com.apple.Safari com.apple.Safari.ContentPageGroupIdentifier.WebKit2AllowsInlineMediaPlayback -bool false -defaults write com.apple.SafariTechnologyPreview com.apple.Safari.ContentPageGroupIdentifier.WebKit2AllowsInlineMediaPlayback -bool false - -# Enable “Do Not Track” -defaults write com.apple.Safari SendDoNotTrackHTTPHeader -bool true - -# Update extensions automatically -defaults write com.apple.Safari InstallExtensionUpdatesAutomatically -bool true - -############################################################################### -# Mail # -############################################################################### - -# Disable send and reply animations in Mail.app -defaults write com.apple.mail DisableReplyAnimations -bool true -defaults write com.apple.mail DisableSendAnimations -bool true - -# Copy email addresses as `foo@example.com` instead of `Foo Bar ` in Mail.app -defaults write com.apple.mail AddressesIncludeNameOnPasteboard -bool false - -# Add the keyboard shortcut ⌘ + Enter to send an email in Mail.app -defaults write com.apple.mail NSUserKeyEquivalents -dict-add "Send" "@\U21a9" - -# Display emails in threaded mode, sorted by date (oldest at the top) -defaults write com.apple.mail DraftsViewerAttributes -dict-add "DisplayInThreadedMode" -string "yes" -defaults write com.apple.mail DraftsViewerAttributes -dict-add "SortedDescending" -string "yes" -defaults write com.apple.mail DraftsViewerAttributes -dict-add "SortOrder" -string "received-date" - -# Disable inline attachments (just show the icons) -defaults write com.apple.mail DisableInlineAttachmentViewing -bool true - -# Disable automatic spell checking -defaults write com.apple.mail SpellCheckingBehavior -string "NoSpellCheckingEnabled" - -############################################################################### -# Spotlight # -############################################################################### - -# Hide Spotlight tray-icon (and subsequent helper) -# sudo chmod 600 /System/Library/CoreServices/Search.bundle/Contents/MacOS/Search - -# Disable Spotlight indexing for any volume that gets mounted and has not yet -# been indexed before. -# Use `sudo mdutil -i off "/Volumes/foo"` to stop indexing any volume. -# sudo defaults write /.Spotlight-V100/VolumeConfiguration Exclusions -array "/Volumes" - -# Change indexing order and disable some search results -# Yosemite-specific search results (remove them if you are using macOS 10.9 or older): -# MENU_DEFINITION -# MENU_CONVERSION -# MENU_EXPRESSION -# MENU_SPOTLIGHT_SUGGESTIONS (send search queries to Apple) -# MENU_WEBSEARCH (send search queries to Apple) -# MENU_OTHER -# -# nonrational: SOURCE enables calculator? - -# nonrational: Disabled 2023-07-21. Buggy as of Ventura. -# defaults write com.apple.spotlight orderedItems -array \ -# '{"enabled" = 1;"name" = "APPLICATIONS";}' \ -# '{"enabled" = 1;"name" = "SYSTEM_PREFS";}' \ -# '{"enabled" = 1;"name" = "DIRECTORIES";}' \ -# '{"enabled" = 1;"name" = "PDF";}' \ -# '{"enabled" = 1;"name" = "FONTS";}' \ -# '{"enabled" = 1;"name" = "SOURCE";}' \ -# '{"enabled" = 0;"name" = "DOCUMENTS";}' \ -# '{"enabled" = 0;"name" = "MESSAGES";}' \ -# '{"enabled" = 0;"name" = "CONTACT";}' \ -# '{"enabled" = 0;"name" = "EVENT_TODO";}' \ -# '{"enabled" = 0;"name" = "IMAGES";}' \ -# '{"enabled" = 0;"name" = "BOOKMARKS";}' \ -# '{"enabled" = 0;"name" = "MUSIC";}' \ -# '{"enabled" = 0;"name" = "MOVIES";}' \ -# '{"enabled" = 0;"name" = "PRESENTATIONS";}' \ -# '{"enabled" = 0;"name" = "SPREADSHEETS";}' \ -# '{"enabled" = 0;"name" = "MENU_DEFINITION";}' \ -# '{"enabled" = 0;"name" = "MENU_OTHER";}' \ -# '{"enabled" = 0;"name" = "MENU_CONVERSION";}' \ -# '{"enabled" = 0;"name" = "MENU_EXPRESSION";}' \ -# '{"enabled" = 0;"name" = "MENU_WEBSEARCH";}' \ -# '{"enabled" = 0;"name" = "MENU_SPOTLIGHT_SUGGESTIONS";}' - -# # Load new settings before rebuilding the index -# killall mds > /dev/null 2>&1 -# # Make sure indexing is enabled for the main volume -# sudo mdutil -i on / > /dev/null -# # Rebuild the index from scratch -# sudo mdutil -E / > /dev/null - -############################################################################### -# Terminal & iTerm 2 # -############################################################################### - -# Only use UTF-8 in Terminal.app -defaults write com.apple.terminal StringEncodings -array 4 - -# Enable “focus follows mouse” for Terminal.app and all X11 apps -# i.e. hover over a window and start typing in it without clicking first -#defaults write com.apple.terminal FocusFollowsMouse -bool true -#defaults write org.x.X11 wm_ffm -bool true - -# Enable Secure Keyboard Entry in Terminal.app -# See: https://security.stackexchange.com/a/47786/8918 -defaults write com.apple.terminal SecureKeyboardEntry -bool true - -# Disable the annoying line marks -defaults write com.apple.Terminal ShowLineMarks -int 0 - -############################################################################### -# Time Machine # -############################################################################### - -# Prevent Time Machine from prompting to use new hard drives as backup volume -defaults write com.apple.TimeMachine DoNotOfferNewDisksForBackup -bool true - # Disable local Time Machine backups hash tmutil &> /dev/null && sudo tmutil disable -############################################################################### -# Activity Monitor # -############################################################################### - -# Show the main window when launching Activity Monitor -defaults write com.apple.ActivityMonitor OpenMainWindow -bool true - -# Visualize CPU usage in the Activity Monitor Dock icon -defaults write com.apple.ActivityMonitor IconType -int 5 - -# Show all processes in Activity Monitor -defaults write com.apple.ActivityMonitor ShowCategory -int 0 - -# Sort Activity Monitor results by CPU usage -defaults write com.apple.ActivityMonitor SortColumn -string "CPUUsage" -defaults write com.apple.ActivityMonitor SortDirection -int 0 - -############################################################################### -# Address Book, Calendar, TextEdit, and Disk Utility # -############################################################################### - -# Enable the debug menu in Address Book -defaults write com.apple.addressbook ABShowDebugMenu -bool true - -# Enable the debug menu in Calendar (pre-10.8) -defaults write com.apple.iCal IncludeDebugMenu -bool true - -# Use plain text mode for new TextEdit documents -defaults write com.apple.TextEdit RichText -int 0 -# Open and save files as UTF-8 in TextEdit -defaults write com.apple.TextEdit PlainTextEncoding -int 4 -defaults write com.apple.TextEdit PlainTextEncodingForWrite -int 4 - -# Enable the debug menu in Disk Utility -defaults write com.apple.DiskUtility DUDebugMenuEnabled -bool true -defaults write com.apple.DiskUtility advanced-image-options -bool true - -# Auto-play videos when opened with QuickTime Player -defaults write com.apple.QuickTimePlayerX MGPlayMovieOnOpen -bool true - -############################################################################### -# Mac App Store # -############################################################################### - -# Enable the WebKit Developer Tools in the Mac App Store -defaults write com.apple.appstore WebKitDeveloperExtras -bool true - -# Enable Debug Menu in the Mac App Store -defaults write com.apple.appstore ShowDebugMenu -bool true - -# Enable the automatic update check -defaults write com.apple.SoftwareUpdate AutomaticCheckEnabled -bool true - -# Check for software updates daily, not just once per week -defaults write com.apple.SoftwareUpdate ScheduleFrequency -int 1 - -# Download newly available updates in background -defaults write com.apple.SoftwareUpdate AutomaticDownload -int 1 - -# Install System data files & security updates -defaults write com.apple.SoftwareUpdate CriticalUpdateInstall -int 1 - -# Automatically download apps purchased on other Macs -defaults write com.apple.SoftwareUpdate ConfigDataInstall -int 1 - -# Turn on app auto-update -defaults write com.apple.commerce AutoUpdate -bool true - -# Allow the App Store to reboot machine on macOS updates -defaults write com.apple.commerce AutoUpdateRestartRequired -bool true - -############################################################################### -# Photos # -############################################################################### - -# Prevent Photos from opening automatically when devices are plugged in -defaults -currentHost write com.apple.ImageCapture disableHotPlug -bool true - -############################################################################### -# Messages # -############################################################################### - -# Disable automatic emoji substitution (i.e. use plain text smileys) -defaults write com.apple.messageshelper.MessageController SOInputLineSettings -dict-add "automaticEmojiSubstitutionEnablediMessage" -bool false - -# Disable smart quotes as it’s annoying for messages that contain code -defaults write com.apple.messageshelper.MessageController SOInputLineSettings -dict-add "automaticQuoteSubstitutionEnabled" -bool false - -# Disable continuous spell checking -defaults write com.apple.messageshelper.MessageController SOInputLineSettings -dict-add "continuousSpellCheckingEnabled" -bool false - -############################################################################### -# Google Chrome & Google Chrome Canary # -############################################################################### - -# Disable the all too sensitive backswipe on trackpads -defaults write com.google.Chrome AppleEnableSwipeNavigateWithScrolls -bool false -defaults write com.google.Chrome.canary AppleEnableSwipeNavigateWithScrolls -bool false - -# Disable the all too sensitive backswipe on Magic Mouse -defaults write com.google.Chrome AppleEnableMouseSwipeNavigateWithScrolls -bool false -defaults write com.google.Chrome.canary AppleEnableMouseSwipeNavigateWithScrolls -bool false - -# Use the system-native print preview dialog -defaults write com.google.Chrome DisablePrintPreview -bool true -defaults write com.google.Chrome.canary DisablePrintPreview -bool true - -# Expand the print dialog by default -defaults write com.google.Chrome PMPrintingExpandedStateForPrint2 -bool true -defaults write com.google.Chrome.canary PMPrintingExpandedStateForPrint2 -bool true - -################################################################ -# _ _ _ -# ___ _ _ _ __ _ __ | | ___ _ __ ___ ___ _ __ | |_ __ _| | -# / __| | | | '_ \| '_ \| |/ _ \ '_ ` _ \ / _ \ '_ \| __/ _` | | -# \__ \ |_| | |_) | |_) | | __/ | | | | | __/ | | | || (_| | | -# |___/\__,_| .__/| .__/|_|\___|_| |_| |_|\___|_| |_|\__\__,_|_| -# |_| |_| nonrational -################################################################ - -# Use all F1, F2 as standard keys -defaults write -g com.apple.keyboard.fnState -bool true - -# Stop the "Try Safari!" nagification -defaults write com.apple.coreservices.uiagent CSUIHasSafariBeenLaunched -bool YES -defaults write com.apple.coreservices.uiagent CSUIRecommendSafariNextNotificationDate -date 2050-01-01T00:00:00Z -defaults write com.apple.coreservices.uiagent CSUILastOSVersionWhereSafariRecommendationWasMade -float 10.99 - -# Disable "floating thumbnail" preview and screenshot delay -defaults write com.apple.screencapture show-thumbnail -bool false - -# More compact spacing for menu bar items. -defaults write -globalDomain NSStatusItemSpacing -int 6 -defaults write -globalDomain NSStatusItemSelectionPadding -int 12 - -############################################################### -# _ _ _ -# _____ ___ __ ___ _ __(_)_ __ ___ ___ _ __ | |_ __ _| | -# / _ \ \/ / '_ \ / _ \ '__| | '_ ` _ \ / _ \ '_ \| __/ _` | | -# | __/> <| |_) | __/ | | | | | | | | __/ | | | || (_| | | -# \___/_/\_\ .__/ \___|_| |_|_| |_| |_|\___|_| |_|\__\__,_|_| -# |_| nonrational -############################################################### - -defaults write com.apple.dock showAppExposeGestureEnabled -int 1 -defaults write com.apple.dock showLaunchpadGestureEnabled -int 0 -defaults write com.apple.dock showMissionControlGestureEnabled -int 1 -defaults write com.apple.driver.AppleBluetoothMultitouch.trackpad TrackpadTwoFingerFromRightEdgeSwipeGesture -int 0 - -# Indirect GLX was disabled by default in Xorg 1.17, starting from XQuartz version 2.7.9, -# to enable indirect GLX and to allow remote visualization on the cluster you will have to issue at the terminal command prompt of your mac: -defaults write org.macosforge.xquartz.X11 enable_iglx -bool true -defaults write org.x.X11 enable_test_extensions -boolean true - -# disable mouse acceleration -defaults write .GlobalPreferences com.apple.mouse.scaling -1 - -############################################################################### -# Siri # -############################################################################### - -# Disable Siri -defaults write com.apple.Siri StatusMenuVisible -bool false -defaults write com.apple.Siri UserHasDeclinedEnable -bool true - -############################################################################### -# Game Center # -############################################################################### - -# Disable Game Center notifications -defaults write com.apple.GameCenter GKInviteAlertEnabled -bool false - ############################################################################### # Kill affected applications # ############################################################################### -for app in "Activity Monitor" \ - "Address Book" \ - "Calendar" \ - "cfprefsd" \ - "Contacts" \ - "Dock" \ - "Finder" \ - "Google Chrome Canary" \ - "Google Chrome" \ - "Mail" \ - "Messages" \ - "NotificationCenter" \ - "Photos" \ - "Safari" \ - "SystemUIServer" \ - "iCal"; do +for app in "Activity Monitor" "Address Book" "Calendar" "cfprefsd" "Contacts" "Dock" "Finder" "Google Chrome Canary" "Google Chrome" "Mail" "Messages" "NotificationCenter" "Photos" "Safari" "SystemUIServer" "iCal"; do killall "${app}" &> /dev/null done echo "Done. Note that some of these changes require a logout/restart to take effect." diff --git a/CLAUDE.md b/CLAUDE.md index 5febe35e..502ba957 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,12 +10,14 @@ Public dotfiles (`nonrational/dotfiles`), deployed by symlinking `home/` entries - `make test` — full suite: `test/test_deploy.sh` + `test/test_shell.sh`. Run either script directly for one suite; both sandbox a throwaway `$HOME` under mktemp and never touch the real one. - `./deploy.sh apply|audit [--dry-run]` — manifest-driven symlink deploy/verify. `make deploy` = apply plus re-asserting the skip-worktree flag (see Gotchas). +- `./scripts/macos-defaults.sh doctor|check|audit|apply|accept [--dry-run] [domain [key]]` — the `macos-defaults` table. **`make macos-doctor` first on any new Mac**: this tooling needs Full Disk Access granted to your terminal, or Safari's and Mail's rows cannot be read and silently skip. `make macos-audit` reports drift and needs no sudo; `make macos-apply` writes; `make macos-accept` rewrites rows to match the machine. `make macos` = doctor, then apply, then the imperative remainder in `.macos`, then a restart. - `make check-symlinks` — fail on any dangling tracked symlink; `make check-skills` — same, scoped to `home/.agents/skills` (safe in CI); `make check-copilot-instructions` — self-heals the per-file mirror of rules into `home/.copilot/instructions/` (run after renaming anything in `home/.agents/rules/`); `make check-skill-frontmatter` — needs PyYAML; `make check-editorconfig` — needs `editorconfig-checker`. - `make preflight` — the one definition of "safe to commit": `test` plus every `check-*` target. CI runs this same target (plus a deploy apply+audit against a temp `$HOME`, kept separate since it exercises the deploy mechanism rather than checking committed content), on macOS and ubuntu — a new `check-*` target is picked up by both without a second edit to `ci.yml`. ## Architecture - **`manifest` + `deploy.sh`** — three whitespace columns: source, target, optional condition (`os=Darwin|Linux`, `host=`, `tool=`). deploy.sh is symlink-only by design: apply is `ln -s`, audit is a readlink comparison. Do not add copy/concat/generate behavior to it — that is a parked decision recorded in `docs/superpowers/specs/2026-07-06-manifest-deploy-spike-design.md`. +- **`macos-defaults` + `scripts/macos-defaults.sh`** — tab-delimited (keys contain spaces, so this one is not whitespace-columned like `manifest`): domain, key, type, value, optional status. `apply` writes every row; `audit` skips a `noaudit=complex` row always (a container value has no comparable scalar form) and skips a `noaudit=tcc` or `noaudit=unset` row only when it will not read. Those two markers record *why* a row might be unreadable, not a decision to ignore it: TCC visibility depends on whether the terminal has Full Disk Access, and an unset key appears once its app first writes preferences. **Grant Full Disk Access to your terminal** (`make macos-doctor` checks) or 39 Safari and Mail rows skip instead of being audited. `.macos` keeps only what has no domain/key/value shape. Design and probe numbers: `docs/superpowers/specs/2026-08-25-macos-defaults-declarative-design.md`. - **`home/.agents` is the source of truth for agent config** (rules + skills), shared across harnesses through symlink shims: `~/.claude/rules` and `~/.claude/skills` point into it, as does `home/.gemini/antigravity-cli/skills`. `home/.copilot/instructions/*.instructions.md` are per-file symlinks mirroring `home/.agents/rules/*.md` — a rename in rules dangles them silently, which is exactly what `check-copilot-instructions` and `check-skills` guard. Design and parked decisions: `docs/superpowers/specs/2026-07-16-agents-source-of-truth-design.md`. - **Many skills are vendored, not local.** `home/.agents/ext/mattpocock-skills` is a git submodule; most entries in `home/.agents/skills/` are symlinks into it. Only the real directories there (e.g. `issue-sweep`, `ux-review`, `find-inspiration`, `prose-register`) are editable in this repo. - **OS/host branching is by filename**: `.Darwin`/`.Linux` suffixes, `.bashrc.`, `bin.Darwin` → `~/bin`. Shell is bash-first (Homebrew bash via `chsh`); zsh files exist but are secondary. Shell chain per window: `.bash_profile` → `.bashrc` → `.bashrc.` → `.bashrc.`. diff --git a/Makefile b/Makefile index c845d288..1c476cec 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,22 @@ brew-bundle: /opt/homebrew/bin/brew shellenv > /tmp/brew-shell.env source /tmp/brew-shell.env && which brew && brew update && brew bundle -macos: +macos-doctor: + @./scripts/macos-defaults.sh doctor + +macos-audit: + @./scripts/macos-defaults.sh audit + +macos-apply: + ./scripts/macos-defaults.sh apply + +macos-accept: + ./scripts/macos-defaults.sh accept + +check-macos-defaults: + @./scripts/macos-defaults.sh check + +macos: macos-doctor macos-apply sh .macos osascript -e 'tell app "loginwindow" to «event aevtrrst»' @@ -65,6 +80,7 @@ check-editorconfig: test: ./test/test_deploy.sh ./test/test_shell.sh + ./test/test_macos_defaults.sh deploy: ./deploy.sh apply @@ -127,7 +143,7 @@ check-copilot-instructions: # The one definition of "safe to commit" -- CI runs this same target, so a # new check-* target is covered by both the moment it's added here. -preflight: test check-symlinks check-skills check-skill-frontmatter check-editorconfig check-copilot-instructions +preflight: test check-symlinks check-skills check-skill-frontmatter check-editorconfig check-copilot-instructions check-macos-defaults link-karabiner: # don't link entire .config directory because it may contain secrets @@ -165,4 +181,4 @@ init-submodules: git submodule update --init --recursive # grep '^\w' Makefile | sed 's/:.*//g' | tr '\n' ' ' | pbcopy -.PHONY: default macos-setup init-post-reboot brew-install brew-bundle macos-reset-dock macos check-symlinks check-skills check-skill-frontmatter check-editorconfig check-copilot-instructions preflight test deploy link-karabiner link-sublime backup-preferences restore-preferences disable-restore-apps-on-login set-file-associations +.PHONY: default macos-setup init-post-reboot brew-install brew-bundle macos-reset-dock macos macos-doctor macos-audit macos-apply macos-accept check-macos-defaults check-symlinks check-skills check-skill-frontmatter check-editorconfig check-copilot-instructions preflight test deploy link-karabiner link-sublime backup-preferences restore-preferences disable-restore-apps-on-login set-file-associations From 19edc1f30d2b34f5dcdd5ffc949aa1d61f5ada00 Mon Sep 17 00:00:00 2001 From: Alan Norton Date: Wed, 26 Aug 2026 11:57:56 -0700 Subject: [PATCH 22/26] Apply the pre-merge fix wave to macos-defaults Restore three rows that drifted opposite their own comments (Safari AutoFillPasswords, Safari HomePage, ActivityMonitor OpenMainWindow) and reapply them to the machine. Fix accept's frozen type column on marked rows, tighten the container-row marker validator to noaudit=complex only, and make accept refuse live values it cannot represent (tab, newline, or empty) instead of writing an unparseable table. Add an audit summary line with ok/drift/missing/skipped counts. Correct the design spec's stale probe numbers, FDA paragraph, and open-risk section. Add duplicate domain+key detection (dict-add exempt), which caught and fixed a real duplicate dock row; align accept --dry-run's would: prefix with apply; note the discard risk in migrate-macos-defaults.sh's header; and re-break a 1033-character collapsed comment line back into readable form. --- .macos | 2 +- ...08-25-macos-defaults-declarative-design.md | 20 ++-- macos-defaults | 31 ++++++- scripts/macos-defaults.sh | 92 ++++++++++++++----- scripts/migrate-macos-defaults.sh | 8 ++ test/test_macos_defaults.sh | 81 ++++++++++++++++ 6 files changed, 194 insertions(+), 40 deletions(-) diff --git a/.macos b/.macos index 93814d33..43e963e5 100644 --- a/.macos +++ b/.macos @@ -3,7 +3,7 @@ # ~/.macos — the imperative remainder. Every `defaults write` that used to live # here is now a row in ./macos-defaults; run `make macos-audit` to compare them # against the machine. What is left cannot be expressed as a domain/key/value: -# nvram, systemsetup, PlistBuddy, chflags, and the app restarts. +# nvram, systemsetup, PlistBuddy, chflags, lsregister, tmutil, and the app restarts. # ~/.macos — https://mths.be/macos diff --git a/docs/superpowers/specs/2026-08-25-macos-defaults-declarative-design.md b/docs/superpowers/specs/2026-08-25-macos-defaults-declarative-design.md index 85616503..03864bc7 100644 --- a/docs/superpowers/specs/2026-08-25-macos-defaults-declarative-design.md +++ b/docs/superpowers/specs/2026-08-25-macos-defaults-declarative-design.md @@ -35,19 +35,19 @@ Every `defaults write` in `.macos`, parsed with a shell shim and compared agains |---|---|---| | Auditable, matches live | 152 | The healthy core | | Genuine drift | 6 | The four above, plus two trackpad keys the pane restored as booleans | -| TCC-blocked | 44 | Safari 35, Mail 5, TextEdit 3, addressbook 1 | -| Actually unset | 5 | Key absent from a domain that reads fine | -| Genuinely complex | 11 | `array` / `dict` / `dict-add` / `date` | +| TCC-blocked | 39 | Safari 34, Mail 5 | +| Actually unset | 9 | Key absent from a domain that reads fine | +| Genuinely complex | 12 | `array` / `dict` / `dict-add` / `date` | Four of the complex rows (`com.apple.mail NSUserKeyEquivalents`, `DraftsViewerAttributes` ×3) are blocked by TCC *and* by their container types. They are marked `noaudit=complex`, because that is the binding constraint: granting Full Disk Access would still leave them uncomparable. ### TCC is a hard constraint -`~/Library/Containers/com.apple.Safari/Data/Library/Preferences/com.apple.Safari.plist` exists and is written regularly, but `ls` on that directory returns `Operation not permitted` and `defaults read com.apple.Safari` reports the domain does not exist. That is TCC, not a missing key. Those 44 rows cannot be audited from a shell without granting Full Disk Access to the terminal, and CI can never have it. +`~/Library/Containers/com.apple.Safari/Data/Library/Preferences/com.apple.Safari.plist` exists and is written regularly, but `ls` on that directory returns `Operation not permitted` and `defaults read com.apple.Safari` reports the domain does not exist. That is TCC, not a missing key. Those 39 rows cannot be audited from a shell without granting Full Disk Access to the terminal, and CI can never have it. The test that separates the two cases is whether the *domain* reads, not whether the key does: `defaults read com.apple.Safari` fails, while `defaults read com.apple.GameCenter` succeeds and only the key is absent. `com.apple.TextEdit` and `com.apple.addressbook` look unset but fail the domain read and own TCC container directories, so they belong with Safari and Mail. An earlier draft of this spec put them in the unset bucket by checking `defaults domains`, which lists `com.apple.TextEdit` even though reading it fails. -Granting FDA was considered and rejected: it makes audit results depend on a machine-configuration step this repo cannot enforce or verify. +Granting Full Disk Access is a checked prerequisite, not a rejected idea: 39 Safari and Mail rows cannot be audited without it, which is a bigger loss than the cost of one one-time setup step. `scripts/macos-defaults.sh doctor` checks for it and exits 1 with instructions when it is missing; `make macos` depends on `macos-doctor`, so a fresh Mac is stopped before `apply` rather than silently skipping the app-container rows forever. `audit` still degrades gracefully without it — the 39 rows report `skip: ... (tcc)` instead of failing — so a machine that has not yet granted access, and CI, still get a usable (if partial) audit. ### Root-owned domains need no sudo to read @@ -152,7 +152,7 @@ Seeding fills values from live. The four drifting rows are held back as a decisi | `com.apple.AppleMultitouchTrackpad FirstClickThreshold` | accept. Same value, different storage: the trackpad pane rewrote both keys as booleans. Writing `-int` back invites the pane to rewrite it again and turns the row into recurring noise | | `com.apple.AppleMultitouchTrackpad SecondClickThreshold` | accept, same reason | -The 5 unset rows enter as `noaudit=unset` so the baseline is green: `helpviewer DevMode`, `QuickTimePlayerX MGPlayMovieOnOpen`, `Siri` ×2, `GameCenter GKInviteAlertEnabled`. +The 9 unset rows enter as `noaudit=unset` so the baseline is green: `helpviewer DevMode`, `QuickTimePlayerX MGPlayMovieOnOpen`, `Siri` ×2, `GameCenter GKInviteAlertEnabled`, `TextEdit` ×3, `addressbook ABShowDebugMenu`. ## What `.macos` keeps @@ -185,16 +185,16 @@ macos -> apply the table, then sh .macos, then the restart osascript `.editorconfig` gains a `[macos-defaults]` stanza documenting that the tabs are data separators rather than indentation. -## Open risk +## TCC and writes, resolved -**Whether `defaults write` to a TCC-blocked container domain still succeeds is unverified.** Reads definitely fail. If writes fail too, those 35 Safari settings have not been applied on a fresh Mac in years and `.macos` has been quietly lying about them. +**Writes were never blocked.** `defaults write com.apple.Safari AutoFillPasswords -bool false; echo $?` exits 0 whether or not the terminal has Full Disk Access — TCC gates reading a container domain's preferences, not writing them. `.macos` has been landing its Safari and Mail settings correctly the whole time; only auditing them was blocked. -Finding out requires a real write to a real Safari preference. It belongs in the implementation plan as an explicit, owner-approved step. +Reads fail only for the reason above. Without Full Disk Access, `defaults read com.apple.Safari AutoFillPasswords` reports the domain does not exist. With it granted, `./scripts/macos-defaults.sh audit` checks all 39 Safari and Mail rows instead of skipping them — its summary line reports `tcc 0` in the skip breakdown, not 39. ## Out of scope - Comparing `array` and `dict` values. Normalizing plist container output is a larger job than drift detection warrants; those rows stay `noaudit=complex`. -- Granting the terminal Full Disk Access to recover the Safari and Mail rows. - Per-host rows. The `os=` and `host=` vocabulary parses but no row uses it. - Moving the imperative tail (`PlistBuddy`, `nvram`, `chflags`, `systemsetup`) into any declarative form. - Renaming `.macos`. +- Detecting settings the machine has that the table does not declare. Audit is one-directional: it only checks declared rows against the machine, never the reverse. A green audit means "everything declared is true," not "the machine is fully described." diff --git a/macos-defaults b/macos-defaults index 308a72b5..c41eb890 100644 --- a/macos-defaults +++ b/macos-defaults @@ -398,7 +398,7 @@ com.apple.Safari com.apple.Safari.ContentPageGroupIdentifier.WebKit2TabsToLinks com.apple.Safari ShowFullURLInSmartSearchField bool true noaudit=tcc # Set Safari’s home page to `about:blank` for faster loading -com.apple.Safari HomePage string https://www.apple.com/startpage/ noaudit=tcc +com.apple.Safari HomePage string about:blank noaudit=tcc # Prevent Safari from opening ‘safe’ files automatically after downloading com.apple.Safari AutoOpenSafeDownloads bool false noaudit=tcc @@ -439,7 +439,7 @@ com.apple.Safari WebAutomaticSpellingCorrectionEnabled bool false noaudit=tcc # Disable AutoFill com.apple.Safari AutoFillFromAddressBook bool false noaudit=tcc -com.apple.Safari AutoFillPasswords bool true noaudit=tcc +com.apple.Safari AutoFillPasswords bool false noaudit=tcc com.apple.Safari AutoFillCreditCardData bool false noaudit=tcc com.apple.Safari AutoFillMiscellaneousForms bool false noaudit=tcc @@ -520,7 +520,29 @@ com.apple.mail SpellCheckingBehavior string NoSpellCheckingEnabled noaudit=tcc # nonrational: SOURCE enables calculator? # nonrational: Disabled 2023-07-21. Buggy as of Ventura. -# defaults write com.apple.spotlight orderedItems -array # '{"enabled" = 1;"name" = "APPLICATIONS";}' # '{"enabled" = 1;"name" = "SYSTEM_PREFS";}' # '{"enabled" = 1;"name" = "DIRECTORIES";}' # '{"enabled" = 1;"name" = "PDF";}' # '{"enabled" = 1;"name" = "FONTS";}' # '{"enabled" = 1;"name" = "SOURCE";}' # '{"enabled" = 0;"name" = "DOCUMENTS";}' # '{"enabled" = 0;"name" = "MESSAGES";}' # '{"enabled" = 0;"name" = "CONTACT";}' # '{"enabled" = 0;"name" = "EVENT_TODO";}' # '{"enabled" = 0;"name" = "IMAGES";}' # '{"enabled" = 0;"name" = "BOOKMARKS";}' # '{"enabled" = 0;"name" = "MUSIC";}' # '{"enabled" = 0;"name" = "MOVIES";}' # '{"enabled" = 0;"name" = "PRESENTATIONS";}' # '{"enabled" = 0;"name" = "SPREADSHEETS";}' # '{"enabled" = 0;"name" = "MENU_DEFINITION";}' # '{"enabled" = 0;"name" = "MENU_OTHER";}' # '{"enabled" = 0;"name" = "MENU_CONVERSION";}' # '{"enabled" = 0;"name" = "MENU_EXPRESSION";}' # '{"enabled" = 0;"name" = "MENU_WEBSEARCH";}' # '{"enabled" = 0;"name" = "MENU_SPOTLIGHT_SUGGESTIONS";}' +# defaults write com.apple.spotlight orderedItems -array \ +# '{"enabled" = 1;"name" = "APPLICATIONS";}' \ +# '{"enabled" = 1;"name" = "SYSTEM_PREFS";}' \ +# '{"enabled" = 1;"name" = "DIRECTORIES";}' \ +# '{"enabled" = 1;"name" = "PDF";}' \ +# '{"enabled" = 1;"name" = "FONTS";}' \ +# '{"enabled" = 1;"name" = "SOURCE";}' \ +# '{"enabled" = 0;"name" = "DOCUMENTS";}' \ +# '{"enabled" = 0;"name" = "MESSAGES";}' \ +# '{"enabled" = 0;"name" = "CONTACT";}' \ +# '{"enabled" = 0;"name" = "EVENT_TODO";}' \ +# '{"enabled" = 0;"name" = "IMAGES";}' \ +# '{"enabled" = 0;"name" = "BOOKMARKS";}' \ +# '{"enabled" = 0;"name" = "MUSIC";}' \ +# '{"enabled" = 0;"name" = "MOVIES";}' \ +# '{"enabled" = 0;"name" = "PRESENTATIONS";}' \ +# '{"enabled" = 0;"name" = "SPREADSHEETS";}' \ +# '{"enabled" = 0;"name" = "MENU_DEFINITION";}' \ +# '{"enabled" = 0;"name" = "MENU_OTHER";}' \ +# '{"enabled" = 0;"name" = "MENU_CONVERSION";}' \ +# '{"enabled" = 0;"name" = "MENU_EXPRESSION";}' \ +# '{"enabled" = 0;"name" = "MENU_WEBSEARCH";}' \ +# '{"enabled" = 0;"name" = "MENU_SPOTLIGHT_SUGGESTIONS";}' # # Load new settings before rebuilding the index # killall mds > /dev/null 2>&1 @@ -560,7 +582,7 @@ com.apple.TimeMachine DoNotOfferNewDisksForBackup bool true ############################################################################### # Show the main window when launching Activity Monitor -com.apple.ActivityMonitor OpenMainWindow bool false +com.apple.ActivityMonitor OpenMainWindow bool true # Visualize CPU usage in the Activity Monitor Dock icon com.apple.ActivityMonitor IconType int 5 @@ -700,7 +722,6 @@ com.apple.screencapture show-thumbnail bool false ############################################################### com.apple.dock showAppExposeGestureEnabled int 1 -com.apple.dock showLaunchpadGestureEnabled int 0 com.apple.dock showMissionControlGestureEnabled int 1 com.apple.driver.AppleBluetoothMultitouch.trackpad TrackpadTwoFingerFromRightEdgeSwipeGesture int 0 diff --git a/scripts/macos-defaults.sh b/scripts/macos-defaults.sh index eb1cdf95..ab9bf05e 100755 --- a/scripts/macos-defaults.sh +++ b/scripts/macos-defaults.sh @@ -14,6 +14,11 @@ filter_domain="" filter_key="" failures=0 tcc_skipped=0 +unset_skipped=0 +complex_skipped=0 +ok_count=0 +drift_count=0 +missing_count=0 usage() { echo "usage: $0 [--dry-run] [check|audit|apply|accept|doctor] [domain [key]]" >&2 @@ -71,7 +76,7 @@ split_row() { } parse_table() { - local lineno=0 line trimmed status i + local lineno=0 line trimmed status i j if [ ! -f "$TABLE" ]; then echo "error: table not found at $TABLE" >&2 exit 1 @@ -107,9 +112,9 @@ parse_table() { bool | int | float | string | raw) ;; array | dict | dict-add | date | data) case "$status" in - noaudit=*) ;; + noaudit=complex) ;; *) - echo "error: $TABLE line $lineno: type '${ROW[2]}' cannot be compared; it needs a noaudit= status" >&2 + echo "error: $TABLE line $lineno: type '${ROW[2]}' cannot be compared; it needs noaudit=complex" >&2 exit 1 ;; esac @@ -126,6 +131,21 @@ parse_table() { exit 1 ;; esac + # dict-add legitimately repeats a domain+key across rows, one per + # dict entry; every other type must own its domain+key uniquely or + # apply can never converge (each row would fight the other's write). + if [ "${ROW[2]}" != dict-add ]; then + j=0 + while [ "$j" -lt "${#t_domain[@]}" ]; do + if [ "${t_type[$j]}" != dict-add ] \ + && [ "${t_domain[$j]}" = "${ROW[0]}" ] \ + && [ "${t_key[$j]}" = "${ROW[1]}" ]; then + echo "error: $TABLE line $lineno: duplicate domain+key '${ROW[0]} ${ROW[1]}'" >&2 + exit 1 + fi + j=$((j + 1)) + done + fi t_domain+=("${ROW[0]}") t_key+=("${ROW[1]}") t_type+=("${ROW[2]}") @@ -256,6 +276,7 @@ audit_row() { case "$status" in noaudit=complex) echo "skip: $domain $key (complex)" + complex_skipped=$((complex_skipped + 1)) return 0 ;; esac @@ -269,6 +290,8 @@ audit_row() { # its app first writes preferences. Audit them when they read. if [ "$status" = "noaudit=tcc" ]; then tcc_skipped=$((tcc_skipped + 1)) + else + unset_skipped=$((unset_skipped + 1)) fi echo "skip: $domain $key (${status#noaudit=})" return 0 @@ -276,6 +299,7 @@ audit_row() { esac echo "missing: $domain $key" failures=$((failures + 1)) + missing_count=$((missing_count + 1)) return 0 fi @@ -287,6 +311,7 @@ audit_row() { if [ "$live_type" != "$type" ]; then echo "drift: $domain $key type want=$type live=$live_type" failures=$((failures + 1)) + drift_count=$((drift_count + 1)) return 0 fi fi @@ -296,9 +321,11 @@ audit_row() { live="$(normalize "$type" "$live")" if [ "$want" = "$live" ]; then echo "ok: $domain $key" + ok_count=$((ok_count + 1)) else echo "drift: $domain $key want=$want live=$live" failures=$((failures + 1)) + drift_count=$((drift_count + 1)) fi } @@ -388,37 +415,50 @@ accept_candidate() { } run_accept() { - local i n idx live live_type new_status tmp line trimmed + local i n idx live live_type new_status skip_reason tmp line trimmed prefix="" local new_row=() + if [ "$dry_run" = 1 ]; then + prefix="would: " + fi n="${#t_domain[@]}" i=0 while [ "$i" -lt "$n" ]; do new_row[$i]="" if accept_candidate "$i"; then if live="$(defaults_read "${t_domain[$i]}" "${t_key[$i]}")"; then - new_status="${t_status[$i]}" - # The marker only recorded that the key was unreadable at seed - # time; it just read, so it no longer describes anything. - if [ "$new_status" = "noaudit=unset" ]; then - new_status="" - fi - live_type="${t_type[$i]}" - if [ "$live_type" != raw ] && [ "$new_status" = "" ]; then - if live_type="$(defaults_read_type "${t_domain[$i]}" "${t_key[$i]}")"; then - live_type="$(table_type_of "$live_type")" - else - live_type="${t_type[$i]}" + skip_reason="" + case "$live" in + "") skip_reason="value is empty" ;; + *"$TAB"*) skip_reason="value contains a tab" ;; + *$'\n'*) skip_reason="value contains a newline" ;; + esac + if [ -n "$skip_reason" ]; then + echo "accept: ${t_domain[$i]} ${t_key[$i]}: skipped, live $skip_reason" >&2 + else + new_status="${t_status[$i]}" + # The marker only recorded that the key was unreadable at seed + # time; it just read, so it no longer describes anything. + if [ "$new_status" = "noaudit=unset" ]; then + new_status="" fi - fi - if [ "$live_type" != "${t_type[$i]}" ] \ - || [ "$(normalize "$live_type" "$live")" != "$(normalize "${t_type[$i]}" "$(expand_value "${t_value[$i]}")")" ] \ - || [ "$new_status" != "${t_status[$i]}" ]; then - new_row[$i]="${t_domain[$i]}$TAB${t_key[$i]}$TAB$live_type$TAB$(canonical_value "$live_type" "$(tokenize_value "$live")")" - if [ -n "$new_status" ]; then - new_row[$i]="${new_row[$i]}$TAB$new_status" + live_type="${t_type[$i]}" + if [ "$live_type" != raw ]; then + if live_type="$(defaults_read_type "${t_domain[$i]}" "${t_key[$i]}")"; then + live_type="$(table_type_of "$live_type")" + else + live_type="${t_type[$i]}" + fi + fi + if [ "$live_type" != "${t_type[$i]}" ] \ + || [ "$(normalize "$live_type" "$live")" != "$(normalize "${t_type[$i]}" "$(expand_value "${t_value[$i]}")")" ] \ + || [ "$new_status" != "${t_status[$i]}" ]; then + new_row[$i]="${t_domain[$i]}$TAB${t_key[$i]}$TAB$live_type$TAB$(canonical_value "$live_type" "$(tokenize_value "$live")")" + if [ -n "$new_status" ]; then + new_row[$i]="${new_row[$i]}$TAB$new_status" + fi + echo "${prefix}accept: ${t_domain[$i]} ${t_key[$i]} = $live" fi - echo "accept: ${t_domain[$i]} ${t_key[$i]} = $live" fi fi fi @@ -507,6 +547,10 @@ main() { if [ "$mode" = audit ] && [ "$tcc_skipped" -gt 0 ] && ! has_full_disk_access; then echo "hint: $tcc_skipped rows skipped for tcc. Grant Full Disk Access to this terminal to audit them (./scripts/macos-defaults.sh doctor)." >&2 fi + if [ "$mode" = audit ]; then + local skipped=$((tcc_skipped + unset_skipped + complex_skipped)) + echo "summary: $ok_count ok, $drift_count drift, $missing_count missing, $skipped skipped (tcc $tcc_skipped, unset $unset_skipped, complex $complex_skipped)" + fi if [ "$failures" -gt 0 ]; then exit 1 fi diff --git a/scripts/migrate-macos-defaults.sh b/scripts/migrate-macos-defaults.sh index 9f4a1a29..15f05fce 100755 --- a/scripts/migrate-macos-defaults.sh +++ b/scripts/migrate-macos-defaults.sh @@ -2,6 +2,14 @@ # One-shot: split .macos into the macos-defaults table (default) and the # imperative lines that stay behind (--remainder). Committed so the 218-row # transcription can be reviewed rather than trusted. +# +# Do not re-run this over the now-seeded table: .macos was retired down to its +# imperative remainder, so a fresh run has nothing left to parse into rows and +# would blank the table instead of regenerating it. It would also discard the +# seeding: four rows (AppleLocale, ActivityMonitor ShowCategory, and the two +# AppleMultitouchTrackpad click-threshold rows) were deliberately accepted +# with live values that differ from what .macos originally declared, and a +# fresh migration knows nothing of that decision. set -euf -o pipefail DOTS="$(cd "$(dirname "$0")/.." && pwd)" diff --git a/test/test_macos_defaults.sh b/test/test_macos_defaults.sh index aadbffe7..549313fa 100755 --- a/test/test_macos_defaults.sh +++ b/test/test_macos_defaults.sh @@ -149,6 +149,50 @@ test_rejects_container_type_without_noaudit() { fi } +# A container row marked anything but complex drifts forever and accept then +# rewrites it as a multi-line plist dump, corrupting the file after mv. +test_rejects_container_type_with_wrong_noaudit() { + sandbox + row com.example.d MyDict dict "a 1" noaudit=tcc > "$TABLE" + mdefaults check + if [ "$status" = 1 ] && grep -q "noaudit=complex" <<<"$out"; then + ok "container type with a non-complex marker exits 1" + else + bad "container type with a non-complex marker exits 1 (status=$status, out=$out)" + fi +} + +# Two rows for the same domain+key fight over the same write, so apply can +# never converge once they carry different values. dict-add is exempt: it +# legitimately repeats a domain+key, one row per dict entry. +test_rejects_duplicate_domain_and_key() { + sandbox + { + row com.example.d Count int 1 + row com.example.d Count int 2 + } > "$TABLE" + mdefaults check + if [ "$status" = 1 ] && grep -q "duplicate" <<<"$out"; then + ok "duplicate domain+key exits 1" + else + bad "duplicate domain+key exits 1 (status=$status, out=$out)" + fi +} + +test_allows_repeated_dict_add_domain_and_key() { + sandbox + { + row com.example.d Prefs dict-add "a 1" noaudit=complex + row com.example.d Prefs dict-add "b 2" noaudit=complex + } > "$TABLE" + mdefaults check + if [ "$status" = 0 ]; then + ok "repeated dict-add domain+key is allowed" + else + bad "repeated dict-add domain+key is allowed (status=$status, out=$out)" + fi +} + test_rejects_empty_table() { sandbox printf '# comments only\n\n' > "$TABLE" @@ -549,6 +593,21 @@ test_accept_updates_a_readable_tcc_row() { fi } +# audit reports a type drift on a marked row, so accept must be able to settle +# one. A tcc row keeps its marker, and the type column used to freeze with it. +test_accept_updates_the_type_on_a_marked_row() { + darwin_only "accept refreshes the type on a noaudit=tcc row" || return 0 + sandbox + defaults write "$DOMAIN" Count -bool true + row "$DOMAIN" Count int 1 noaudit=tcc > "$TABLE" + mdefaults accept + if [ "$status" = 0 ] && grep -q " bool true noaudit=tcc$" "$TABLE"; then + ok "accept refreshes the type on a noaudit=tcc row" + else + bad "accept refreshes the type on a noaudit=tcc row (status=$status, table=$(cat "$TABLE"))" + fi +} + # The unreadable case is what the marker exists for: no value to take, so the # row must be left exactly as it is rather than emptied. test_accept_leaves_an_unreadable_tcc_row_alone() { @@ -704,6 +763,23 @@ test_accept_writes_booleans_in_human_form() { fi } +# accept replaces the table by mv, so a value it cannot represent must be +# refused before the write rather than corrupting the file after it. +test_accept_refuses_a_value_containing_a_tab() { + darwin_only "accept refuses a live value containing a tab" || return 0 + sandbox + defaults write "$DOMAIN" Weird -string "a b" + row "$DOMAIN" Weird string placeholder > "$TABLE" + before="$(cksum < "$TABLE")" + mdefaults accept + if [ "$(cksum < "$TABLE")" = "$before" ] && MACOS_DEFAULTS_TABLE="$TABLE" \ + "$ROOT/scripts/macos-defaults.sh" check >/dev/null 2>&1; then + ok "accept refuses a live value containing a tab" + else + bad "accept refuses a live value containing a tab (status=$status, table=$(cat "$TABLE"))" + fi +} + # --- runner ----------------------------------------------------------------- test_rejects_unknown_flag test_rejects_missing_table @@ -713,6 +789,9 @@ test_rejects_empty_status_column test_rejects_unknown_type test_rejects_unknown_status test_rejects_container_type_without_noaudit +test_rejects_container_type_with_wrong_noaudit +test_rejects_duplicate_domain_and_key +test_allows_repeated_dict_add_domain_and_key test_rejects_empty_table test_hash_inside_value_is_not_a_comment test_indented_comment_is_a_comment @@ -741,6 +820,7 @@ test_accept_updates_the_type_when_it_drifts test_accept_honors_the_filter test_accept_never_rewrites_a_readable_complex_row test_accept_updates_a_readable_tcc_row +test_accept_updates_the_type_on_a_marked_row test_accept_leaves_an_unreadable_tcc_row_alone test_audit_expands_the_home_token test_apply_expands_the_home_token @@ -751,6 +831,7 @@ test_audit_checks_a_readable_tcc_row test_audit_skips_an_unreadable_tcc_row test_audit_always_skips_a_readable_complex_row test_accept_writes_booleans_in_human_form +test_accept_refuses_a_value_containing_a_tab echo echo "$pass passed, $fail failed, $skipped skipped" From 4ed11e827e3ef0bb959b99a41e056e32f54b33f1 Mon Sep 17 00:00:00 2001 From: Alan Norton Date: Sat, 29 Aug 2026 11:50:34 -0700 Subject: [PATCH 23/26] Scope duplicate-row detection to condition, count condition skips in audit summary Two rows sharing a domain+key are only a real collision when they'd both apply on the same machine: same os= or host= condition, or both bare/noaudit (which carry no condition). Different os=/host= conditions on the same key is the whole point of the condition vocabulary and must keep parsing. The audit summary was also missing a counter for rows skipped by condition mismatch, so its skipped total silently under-reported once condition rows existed. --- scripts/macos-defaults.sh | 20 +++++++++++--- test/test_macos_defaults.sh | 52 +++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/scripts/macos-defaults.sh b/scripts/macos-defaults.sh index ab9bf05e..e4993a90 100755 --- a/scripts/macos-defaults.sh +++ b/scripts/macos-defaults.sh @@ -16,6 +16,7 @@ failures=0 tcc_skipped=0 unset_skipped=0 complex_skipped=0 +condition_skipped=0 ok_count=0 drift_count=0 missing_count=0 @@ -75,6 +76,17 @@ split_row() { done } +# Maps a row's status to the condition that scopes which machine it applies +# to. `os=`/`host=` pass through verbatim; an empty status and any `noaudit=` +# status both collapse to "no condition", because noaudit records why a row +# might be unreadable, not a decision about which machine it targets. +status_condition() { + case "$1" in + os=* | host=*) printf '%s' "$1" ;; + *) printf '%s' "" ;; + esac +} + parse_table() { local lineno=0 line trimmed status i j if [ ! -f "$TABLE" ]; then @@ -139,7 +151,8 @@ parse_table() { while [ "$j" -lt "${#t_domain[@]}" ]; do if [ "${t_type[$j]}" != dict-add ] \ && [ "${t_domain[$j]}" = "${ROW[0]}" ] \ - && [ "${t_key[$j]}" = "${ROW[1]}" ]; then + && [ "${t_key[$j]}" = "${ROW[1]}" ] \ + && [ "$(status_condition "${t_status[$j]}")" = "$(status_condition "$status")" ]; then echo "error: $TABLE line $lineno: duplicate domain+key '${ROW[0]} ${ROW[1]}'" >&2 exit 1 fi @@ -535,6 +548,7 @@ main() { if row_selected "$i"; then if ! condition_matches "${t_status[$i]}"; then echo "skip: ${t_domain[$i]} ${t_key[$i]} (${t_status[$i]})" + condition_skipped=$((condition_skipped + 1)) else case "$mode" in audit) audit_row "$i" ;; @@ -548,8 +562,8 @@ main() { echo "hint: $tcc_skipped rows skipped for tcc. Grant Full Disk Access to this terminal to audit them (./scripts/macos-defaults.sh doctor)." >&2 fi if [ "$mode" = audit ]; then - local skipped=$((tcc_skipped + unset_skipped + complex_skipped)) - echo "summary: $ok_count ok, $drift_count drift, $missing_count missing, $skipped skipped (tcc $tcc_skipped, unset $unset_skipped, complex $complex_skipped)" + local skipped=$((condition_skipped + tcc_skipped + unset_skipped + complex_skipped)) + echo "summary: $ok_count ok, $drift_count drift, $missing_count missing, $skipped skipped (condition $condition_skipped, tcc $tcc_skipped, unset $unset_skipped, complex $complex_skipped)" fi if [ "$failures" -gt 0 ]; then exit 1 diff --git a/test/test_macos_defaults.sh b/test/test_macos_defaults.sh index 549313fa..9bf7fc41 100755 --- a/test/test_macos_defaults.sh +++ b/test/test_macos_defaults.sh @@ -193,6 +193,38 @@ test_allows_repeated_dict_add_domain_and_key() { fi } +# Two rows for one setting under different conditions is the point of the +# condition vocabulary — the same key wanting different values per machine. +test_accepts_same_key_under_different_conditions() { + sandbox + { + row com.apple.dock tilesize int 36 host=other + row com.apple.dock tilesize int 48 host=another + } > "$TABLE" + mdefaults check + if [ "$status" = 0 ] && grep -q "2 rows" <<<"$out"; then + ok "same key under different conditions parses" + else + bad "same key under different conditions parses (status=$status, out=$out)" + fi +} + +# Same condition means both rows apply on the same machine, so apply writes +# both and whichever loses drifts forever with no way to converge. +test_rejects_same_key_under_the_same_condition() { + sandbox + { + row com.apple.dock tilesize int 36 host=other + row com.apple.dock tilesize int 48 host=other + } > "$TABLE" + mdefaults check + if [ "$status" = 1 ] && grep -q "duplicate" <<<"$out"; then + ok "same key under the same condition exits 1" + else + bad "same key under the same condition exits 1 (status=$status, out=$out)" + fi +} + test_rejects_empty_table() { sandbox printf '# comments only\n\n' > "$TABLE" @@ -273,6 +305,23 @@ test_audit_skips_unmatched_condition() { fi } +# A summary whose numbers do not add up to the row count is worse than none, +# and skipped-by-condition is the common case on a machine the row excludes. +test_audit_summary_counts_condition_skips() { + sandbox + { + row NSGlobalDomain FirstKey bool true os=NoSuchOS + row NSGlobalDomain SecondKey bool true os=NoSuchOS + } > "$TABLE" + mdefaults audit + if [ "$status" = 0 ] && grep -q "2 skipped" <<<"$out" \ + && grep -q "condition 2" <<<"$out"; then + ok "audit summary counts condition-mismatched skips" + else + bad "audit summary counts condition-mismatched skips (status=$status, out=$out)" + fi +} + test_audit_ok_when_value_matches() { darwin_only "audit reports ok when the live value matches" || return 0 sandbox @@ -792,12 +841,15 @@ test_rejects_container_type_without_noaudit test_rejects_container_type_with_wrong_noaudit test_rejects_duplicate_domain_and_key test_allows_repeated_dict_add_domain_and_key +test_accepts_same_key_under_different_conditions +test_rejects_same_key_under_the_same_condition test_rejects_empty_table test_hash_inside_value_is_not_a_comment test_indented_comment_is_a_comment test_key_with_spaces_parses test_check_counts_rows test_audit_skips_unmatched_condition +test_audit_summary_counts_condition_skips test_audit_ok_when_value_matches test_audit_normalizes_bools test_audit_reports_drift From 5fe59b48d48d137a07c4b55dc92ae0a19f42a344 Mon Sep 17 00:00:00 2001 From: Alan Norton Date: Thu, 24 Sep 2026 22:09:45 -0700 Subject: [PATCH 24/26] mouse accel 3 --- macos-defaults | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos-defaults b/macos-defaults index c41eb890..af76669b 100644 --- a/macos-defaults +++ b/macos-defaults @@ -731,7 +731,7 @@ org.macosforge.xquartz.X11 enable_iglx bool true org.x.X11 enable_test_extensions bool true # disable mouse acceleration -.GlobalPreferences com.apple.mouse.scaling float -1 +.GlobalPreferences com.apple.mouse.scaling float 3 ############################################################################### # Siri # From d608641ba1071fd8766f346a609db0dc19e16c24 Mon Sep 17 00:00:00 2001 From: Alan Norton Date: Fri, 25 Sep 2026 14:02:10 -0700 Subject: [PATCH 25/26] Move .macos to scripts/macos-bootstrap.sh and drop its dead steps The imperative remainder was still named after the mathiasbynens script it no longer tracks. As scripts/macos-bootstrap.sh its header says what it is, that it is re-runnable, and that `make macos` runs it after the table. On macOS 26 the Launchpad reset found nothing to delete (Launchpad is gone), the Time Machine comment described `disablelocal`, removed in High Sierra, and the com.apple.screensaver rows are a one-time migration source that loginwindow erases; the live setting is the sysadminctl grace period, so the script sets that instead. The startup chime is muted through StartupMute, the variable the Sound pane's toggle writes, rather than the pre-Big Sur SystemAudioVolume trick. Co-Authored-By: Claude Fable 5.1 --- .editorconfig | 2 +- CLAUDE.md | 4 +-- Makefile | 2 +- ...08-25-macos-defaults-declarative-design.md | 4 +-- home/.agents/skills/find-inspiration/SKILL.md | 2 +- .macos => scripts/macos-bootstrap.sh | 28 ++++++++++--------- scripts/migrate-macos-defaults.sh | 3 +- test/test_deploy.sh | 4 +-- 8 files changed, 26 insertions(+), 23 deletions(-) rename .macos => scripts/macos-bootstrap.sh (72%) mode change 100644 => 100755 diff --git a/.editorconfig b/.editorconfig index 6886c834..48b3a1b2 100644 --- a/.editorconfig +++ b/.editorconfig @@ -17,7 +17,7 @@ indent_size = 4 [{Makefile,.gitmodules}] indent_style = tab -[.macos] +[scripts/macos-bootstrap.sh] # Shell continuations in this inherited macOS settings script use tabs. indent_style = tab diff --git a/CLAUDE.md b/CLAUDE.md index 064b1720..08548945 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ This repo is also consumed downstream by the user's agentic alter-ego, @nonreage - `make test` — full suite: `test/test_deploy.sh` + `test/test_shell.sh`. Run either script directly for one suite; both sandbox a throwaway `$HOME` under mktemp and never touch the real one. - `./deploy.sh apply|audit [--dry-run]` — manifest-driven symlink deploy/verify. `make deploy` = apply plus re-asserting the skip-worktree flag (see Gotchas). -- `./scripts/macos-defaults.sh doctor|check|audit|apply|accept [--dry-run] [domain [key]]` — the `macos-defaults` table. **`make macos-doctor` first on any new Mac**: this tooling needs Full Disk Access granted to your terminal, or Safari's and Mail's rows cannot be read and silently skip. `make macos-audit` reports drift and needs no sudo; `make macos-apply` writes; `make macos-accept` rewrites rows to match the machine. `make macos` = doctor, then apply, then the imperative remainder in `.macos`, then a restart. +- `./scripts/macos-defaults.sh doctor|check|audit|apply|accept [--dry-run] [domain [key]]` — the `macos-defaults` table. **`make macos-doctor` first on any new Mac**: this tooling needs Full Disk Access granted to your terminal, or Safari's and Mail's rows cannot be read and silently skip. `make macos-audit` reports drift and needs no sudo; `make macos-apply` writes; `make macos-accept` rewrites rows to match the machine. `make macos` = doctor, then apply, then the imperative remainder in `scripts/macos-bootstrap.sh`, then a restart. - `make check-symlinks` — fail on any dangling tracked symlink; `make check-skills` — same, scoped to `home/.agents/skills` (safe in CI); `make check-copilot-instructions` — self-heals the per-file mirror of rules into `home/.copilot/instructions/` (run after renaming anything in `home/.agents/rules/`); `make check-skill-frontmatter` — needs PyYAML; `make check-editorconfig` — needs `editorconfig-checker`. - `make preflight` — the one definition of "safe to commit": `test`, every `check-*` target, and the offline eval checks (`eval-validate`, `eval-test`; Node ≥ 24, whose npm 11 wrote the lockfile). CI runs this same target (plus a deploy apply+audit against a temp `$HOME`, kept separate since it exercises the deploy mechanism rather than checking committed content), on macOS and ubuntu — a new `check-*` target is picked up by both without a second edit to `ci.yml`. - `make eval SKILL=` — full keyed run of one skill's `evals.json` through promptfoo, gated by `evals/bin/check-gate.mjs`; spends subscription usage. `make eval-compare SKILL=` runs skill vs baseline side by side. Unset `ANTHROPIC_API_KEY` first, or `claude -p` bills the API instead. @@ -20,7 +20,7 @@ This repo is also consumed downstream by the user's agentic alter-ego, @nonreage ## Architecture - **`manifest` + `deploy.sh`** — three whitespace columns: source, target, optional condition (`os=Darwin|Linux`, `host=`, `tool=`). deploy.sh is symlink-only by design: apply is `ln -s`, audit is a readlink comparison. Do not add copy/concat/generate behavior to it — that is a parked decision recorded in `docs/superpowers/specs/2026-07-06-manifest-deploy-spike-design.md`. -- **`macos-defaults` + `scripts/macos-defaults.sh`** — tab-delimited (keys contain spaces, so this one is not whitespace-columned like `manifest`): domain, key, type, value, optional status. `apply` writes every row; `audit` skips a `noaudit=complex` row always (a container value has no comparable scalar form) and skips a `noaudit=tcc` or `noaudit=unset` row only when it will not read. Those two markers record *why* a row might be unreadable, not a decision to ignore it: TCC visibility depends on whether the terminal has Full Disk Access, and an unset key appears once its app first writes preferences. **Grant Full Disk Access to your terminal** (`make macos-doctor` checks) or 39 Safari and Mail rows skip instead of being audited. `.macos` keeps only what has no domain/key/value shape. Design and probe numbers: `docs/superpowers/specs/2026-08-25-macos-defaults-declarative-design.md`. +- **`macos-defaults` + `scripts/macos-defaults.sh`** — tab-delimited (keys contain spaces, so this one is not whitespace-columned like `manifest`): domain, key, type, value, optional status. `apply` writes every row; `audit` skips a `noaudit=complex` row always (a container value has no comparable scalar form) and skips a `noaudit=tcc` or `noaudit=unset` row only when it will not read. Those two markers record *why* a row might be unreadable, not a decision to ignore it: TCC visibility depends on whether the terminal has Full Disk Access, and an unset key appears once its app first writes preferences. **Grant Full Disk Access to your terminal** (`make macos-doctor` checks) or 39 Safari and Mail rows skip instead of being audited. `scripts/macos-bootstrap.sh` keeps only what has no domain/key/value shape. Design and probe numbers: `docs/superpowers/specs/2026-08-25-macos-defaults-declarative-design.md`. - **`home/.agents` is the source of truth for agent config** (rules + skills), shared across harnesses through symlink shims: `~/.claude/rules` and `~/.claude/skills` point into it, as does `home/.gemini/antigravity-cli/skills`. `home/.copilot/instructions/*.instructions.md` are per-file symlinks mirroring `home/.agents/rules/*.md` — a rename in rules dangles them silently, which is exactly what `check-copilot-instructions` and `check-skills` guard. Design and parked decisions: `docs/superpowers/specs/2026-07-16-agents-source-of-truth-design.md`. - **Many skills are vendored, not local.** `home/.agents/ext/mattpocock-skills` is a git submodule; most entries in `home/.agents/skills/` are symlinks into it. Only the real directories there (e.g. `issue-sweep`, `ux-review`, `find-inspiration`, `prose-register`) are editable in this repo. - **OS/host branching is by filename**: `.Darwin`/`.Linux` suffixes, `.bashrc.`, `bin.Darwin` → `~/bin`. Shell is bash-first (Homebrew bash via `chsh`); zsh files exist but are secondary. Shell chain per window: `.bash_profile` → `.bashrc` → `.bashrc.` → `.bashrc.`. diff --git a/Makefile b/Makefile index f2b9142e..79a6dc51 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,7 @@ check-macos-defaults: @./scripts/macos-defaults.sh check macos: macos-doctor macos-apply - sh .macos + ./scripts/macos-bootstrap.sh osascript -e 'tell app "loginwindow" to «event aevtrrst»' macos-reset-dock: diff --git a/docs/superpowers/specs/2026-08-25-macos-defaults-declarative-design.md b/docs/superpowers/specs/2026-08-25-macos-defaults-declarative-design.md index 03864bc7..1539d00a 100644 --- a/docs/superpowers/specs/2026-08-25-macos-defaults-declarative-design.md +++ b/docs/superpowers/specs/2026-08-25-macos-defaults-declarative-design.md @@ -158,7 +158,7 @@ The 9 unset rows enter as `noaudit=unset` so the baseline is green: `helpviewer All 218 `defaults write` lines leave. Roughly 45 lines remain: the System Settings quit, the sudo keepalive, `nvram SystemAudioVolume`, `systemsetup -settimezone`, the nine `PlistBuddy` Finder-view calls, both `chflags`, `lsregister`, the Dock `find -delete`, `tmutil disable`, the closing `killall` loop, and the final echo. -It keeps its filename and gains a header comment pointing at `macos-defaults`. Nothing is symlinked to it — it has no `manifest` entry — so a later rename to `scripts/macos-imperative.sh` costs nothing but muscle memory. +It keeps its filename and gains a header comment pointing at `macos-defaults`. Nothing is symlinked to it — it has no `manifest` entry — so a later rename to `scripts/macos-imperative.sh` costs nothing but muscle memory. (Renamed to `scripts/macos-bootstrap.sh` on 2026-09-25.) ## Make targets @@ -196,5 +196,5 @@ Reads fail only for the reason above. Without Full Disk Access, `defaults read c - Comparing `array` and `dict` values. Normalizing plist container output is a larger job than drift detection warrants; those rows stay `noaudit=complex`. - Per-host rows. The `os=` and `host=` vocabulary parses but no row uses it. - Moving the imperative tail (`PlistBuddy`, `nvram`, `chflags`, `systemsetup`) into any declarative form. -- Renaming `.macos`. +- Renaming `.macos`. (Done 2026-09-25: `scripts/macos-bootstrap.sh`.) - Detecting settings the machine has that the table does not declare. Audit is one-directional: it only checks declared rows against the machine, never the reverse. A green audit means "everything declared is true," not "the machine is fully described." diff --git a/home/.agents/skills/find-inspiration/SKILL.md b/home/.agents/skills/find-inspiration/SKILL.md index 2fbc826d..265948d6 100644 --- a/home/.agents/skills/find-inspiration/SKILL.md +++ b/home/.agents/skills/find-inspiration/SKILL.md @@ -43,7 +43,7 @@ a state main has never been in. (`home/.copilot/instructions/*.instructions.md`). Renames dangle these silently, so `make check-symlinks`, `check-skills`, `check-copilot-instructions` and `check-skill-frontmatter` guard them. Many skills are vendored via the `mattpocock-skills` submodule and are not editable here. -- **macOS defaults:** imperative `.macos` + `make macos-*`. **Vendored:** git submodules. +- **macOS defaults:** the `macos-defaults` table + `scripts/macos-bootstrap.sh` + `make macos-*`. **Vendored:** git submodules. - **Tests:** `make test` = `test/test_deploy.sh` + `test/test_shell.sh`, both sandboxing a throwaway `$HOME`. - **CI:** `.github/workflows/ci.yml` on ubuntu + macOS runs `make test`, a deploy apply/audit diff --git a/.macos b/scripts/macos-bootstrap.sh old mode 100644 new mode 100755 similarity index 72% rename from .macos rename to scripts/macos-bootstrap.sh index 43e963e5..9d5cea04 --- a/.macos +++ b/scripts/macos-bootstrap.sh @@ -1,11 +1,11 @@ #!/usr/bin/env bash -# ~/.macos — the imperative remainder. Every `defaults write` that used to live -# here is now a row in ./macos-defaults; run `make macos-audit` to compare them -# against the machine. What is left cannot be expressed as a domain/key/value: -# nvram, systemsetup, PlistBuddy, chflags, lsregister, tmutil, and the app restarts. - -# ~/.macos — https://mths.be/macos +# macOS bootstrap: the imperative remainder of the original ~/.macos +# (https://mths.be/macos). Every `defaults write` that used to live here is now +# a row in ../macos-defaults; run `make macos-audit` to compare them against the +# machine. What is left cannot be expressed as a domain/key/value: nvram, +# systemsetup, sysadminctl, PlistBuddy, chflags, lsregister, tmutil, and the +# app restarts. Re-runnable; `make macos` runs it after applying the table. # https://macos-defaults.com/ @@ -17,7 +17,7 @@ osascript -e 'tell application "System Settings" to quit' 2> /dev/null # Ask for the administrator password upfront sudo -v -# Keep-alive: update existing `sudo` time stamp until `.macos` has finished +# Keep-alive: update existing `sudo` time stamp until this script has finished while true; do sudo -n true; sleep 60; kill -0 "$$" || exit; done 2>/dev/null & ############################################################################### @@ -25,13 +25,13 @@ while true; do sudo -n true; sleep 60; kill -0 "$$" || exit; done 2>/dev/null & ############################################################################### # Disable the sound effects on boot -sudo nvram SystemAudioVolume=" " +sudo nvram StartupMute=%01 # Remove duplicates in the “Open With” menu (also see `lscleanup` alias) /System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -kill -r -domain local -domain system -domain user # Set the timezone; see `sudo systemsetup -listtimezones` for other values -sudo systemsetup -settimezone "America/New_York" > /dev/null +sudo systemsetup -settimezone "America/Los_Angeles" > /dev/null # Enable snap-to-grid for icons on the desktop and in other icon views /usr/libexec/PlistBuddy -c "Set :DesktopViewSettings:IconViewSettings:arrangeBy grid" ~/Library/Preferences/com.apple.finder.plist @@ -54,17 +54,19 @@ chflags nohidden ~/Library # Show the /Volumes folder sudo chflags nohidden /Volumes -# Reset Launchpad, but keep the desktop wallpaper intact -find "${HOME}/Library/Application Support/Dock" -name "*-*.db" -maxdepth 1 -delete +# Require the password immediately after sleep or the screen saver begins. +# The old com.apple.screensaver rows are a one-time migration source that +# loginwindow erases; this is the live setting. Prompts for the login password. +sysadminctl -screenLock immediate -password - -# Disable local Time Machine backups +# Disable automatic Time Machine backups (backups run only on demand) hash tmutil &> /dev/null && sudo tmutil disable ############################################################################### # Kill affected applications # ############################################################################### -for app in "Activity Monitor" "Address Book" "Calendar" "cfprefsd" "Contacts" "Dock" "Finder" "Google Chrome Canary" "Google Chrome" "Mail" "Messages" "NotificationCenter" "Photos" "Safari" "SystemUIServer" "iCal"; do +for app in "Activity Monitor" "Calendar" "cfprefsd" "Contacts" "Dock" "Finder" "Google Chrome" "Mail" "Messages" "NotificationCenter" "Photos" "Safari" "SystemUIServer"; do killall "${app}" &> /dev/null done echo "Done. Note that some of these changes require a logout/restart to take effect." diff --git a/scripts/migrate-macos-defaults.sh b/scripts/migrate-macos-defaults.sh index 15f05fce..12de5c12 100755 --- a/scripts/migrate-macos-defaults.sh +++ b/scripts/migrate-macos-defaults.sh @@ -1,5 +1,6 @@ #!/bin/bash -# One-shot: split .macos into the macos-defaults table (default) and the +# One-shot: split .macos (now scripts/macos-bootstrap.sh) into the +# macos-defaults table (default) and the # imperative lines that stay behind (--remainder). Committed so the 218-row # transcription can be reviewed rather than trusted. # diff --git a/test/test_deploy.sh b/test/test_deploy.sh index 2ca10bca..bdd720a2 100755 --- a/test/test_deploy.sh +++ b/test/test_deploy.sh @@ -342,12 +342,12 @@ test_manifest_covers_link_dotfiles() { # .agents is an in-repo root (source of truth for rules/skills/ext); # .config and .ssh contain individually deployed files (and ~/.ssh also # holds keys and an unmanaged config, so it must never become a link). - # None is a deploy target, so all are excluded like .gitmodules and .macos. + # None is a deploy target, so all are excluded like .gitmodules. local linked linked="$(cd "$ROOT/home" && find . -maxdepth 1 -name '.*' \ ! -name '.' ! -name '.AppleDouble' ! -name '.DS_Store' \ ! -name '.git' ! -name '.github' ! -name '.gitignore' \ - ! -name '.gitmodules' ! -name '.macos' ! -name '.agents' \ + ! -name '.gitmodules' ! -name '.agents' \ ! -name '.config' ! -name '.ssh' -exec basename {} \; | sort)" local sources sources="$(grep -v '^[[:space:]]*#' "$ROOT/manifest" | awk 'NF{print $1}' | sort -u)" From d8c989e0e3579b42581d303ed1274103ba871fa4 Mon Sep 17 00:00:00 2001 From: Alan Norton Date: Fri, 25 Sep 2026 14:02:10 -0700 Subject: [PATCH 26/26] Retire the macos-defaults rows macOS 26 no longer reads Every key in the table was grepped against the binary that owns it, the arm64e dyld shared cache, and the system volume. Thirty-three rows have no reader left and a documented removal: Safari's Top Sites, plug-ins, Java, Do Not Track, extension auto-update and inline-playback keys; Mail's animation and pasteboard keys; Messages' pre-Big Sur SOInputLineSettings; the Disk Utility, Calendar and App Store debug menus; the "Try Safari" nag; Game Center; the Bluetooth bitpool; the Intel HiDPI switch; and the screensaver password rows now set by scripts/macos-bootstrap.sh. Each section that lost rows carries a dated note naming what dropped them. The Siri switch moves to com.apple.assistant.support, AppleKeyboardUIMode takes the value the Keyboard pane writes, and the font smoothing, mouse scaling and ConfigDataInstall comments describe what the keys do today. Co-Authored-By: Claude Fable 5.1 --- macos-defaults | 108 +++++++++++-------------------------------------- 1 file changed, 23 insertions(+), 85 deletions(-) diff --git a/macos-defaults b/macos-defaults index af76669b..d14a766c 100644 --- a/macos-defaults +++ b/macos-defaults @@ -113,12 +113,9 @@ com.apple.AppleMultitouchTrackpad TrackpadThreeFingerVertSwipeGesture bool true com.apple.AppleMultitouchTrackpad TrackpadTwoFingerDoubleTapGesture bool false com.apple.AppleMultitouchTrackpad TrackpadTwoFingerFromRightEdgeSwipeGesture bool false -# Increase sound quality for Bluetooth headphones/headsets -com.apple.BluetoothAudioAgent Apple Bitpool Min (editable) int 40 - # Enable full keyboard access for all controls # (e.g. enable Tab in modal dialogs) -NSGlobalDomain AppleKeyboardUIMode int 3 +NSGlobalDomain AppleKeyboardUIMode int 2 # Disable press-and-hold for keys in favor of key repeat NSGlobalDomain ApplePressAndHoldEnabled bool false @@ -188,9 +185,11 @@ NSGlobalDomain AppleMetricUnits bool false # Screen # ############################################################################### -# Require password immediately after sleep or screen saver begins -com.apple.screensaver askForPassword int 1 -com.apple.screensaver askForPasswordDelay int 0 +# Require password immediately after sleep or screen saver begins: +# not a defaults row any more. loginwindow reads askForPassword and +# askForPasswordDelay once as a migration source and then erases them; the +# live value is a per-user grace period set by `sysadminctl -screenLock`, +# which scripts/macos-bootstrap.sh runs. # Save screenshots to the desktop com.apple.screencapture location string ${HOME}/Desktop @@ -201,13 +200,11 @@ com.apple.screencapture type string png # Disable shadow in screenshots com.apple.screencapture disable-shadow bool true -# Enable subpixel font rendering on non-Apple LCDs +# Lighter font smoothing (0 = off, 1 = light, 2 = medium, 3 = strong). +# Subpixel rendering left with Mojave; this only sets stroke weight now. # Reference: https://github.com/kevinSuttle/macOS-Defaults/issues/17#issuecomment-266633501 NSGlobalDomain AppleFontSmoothing int 1 -# Enable HiDPI display modes (requires restart) -/Library/Preferences/com.apple.windowserver DisplayResolutionEnabled bool true - ############################################################################### # Finder # ############################################################################### @@ -385,6 +382,10 @@ com.apple.dock wvous-bl-modifier int 0 ############################################################################### # Safari & WebKit # ############################################################################### +# Dropped 2026-09-25, no longer read by Safari 26: Top Sites (gone in Safari +# 14), plug-ins (14), Java (12), Do Not Track (12.1), extension auto-update +# (13, extensions come from the App Store), and the inline-playback rows +# (per-site autoplay settings replaced them in 11). # Privacy: don’t send search queries to Apple com.apple.Safari UniversalSearchEnabled bool false noaudit=tcc @@ -409,12 +410,6 @@ com.apple.Safari com.apple.Safari.ContentPageGroupIdentifier.WebKit2BackspaceKey # Hide Safari’s bookmarks bar by default com.apple.Safari ShowFavoritesBar bool false noaudit=tcc -# Hide Safari’s sidebar in Top Sites -com.apple.Safari ShowSidebarInTopSites bool false noaudit=tcc - -# Disable Safari’s thumbnail cache for History and Top Sites -com.apple.Safari DebugSnapshotsUpdatePolicy int 2 noaudit=tcc - # Enable Safari’s debug menu com.apple.Safari IncludeInternalDebugMenu bool true noaudit=tcc @@ -446,41 +441,17 @@ com.apple.Safari AutoFillMiscellaneousForms bool false noaudit=tcc # Warn about fraudulent websites com.apple.Safari WarnAboutFraudulentWebsites bool true noaudit=tcc -# Disable plug-ins -com.apple.Safari WebKitPluginsEnabled bool false noaudit=tcc -com.apple.Safari com.apple.Safari.ContentPageGroupIdentifier.WebKit2PluginsEnabled bool false noaudit=tcc - -# Disable Java -com.apple.Safari WebKitJavaEnabled bool false noaudit=tcc -com.apple.Safari com.apple.Safari.ContentPageGroupIdentifier.WebKit2JavaEnabled bool false noaudit=tcc -com.apple.Safari com.apple.Safari.ContentPageGroupIdentifier.WebKit2JavaEnabledForLocalFiles bool false noaudit=tcc - # Block pop-up windows com.apple.Safari WebKitJavaScriptCanOpenWindowsAutomatically bool false noaudit=tcc com.apple.Safari com.apple.Safari.ContentPageGroupIdentifier.WebKit2JavaScriptCanOpenWindowsAutomatically bool false noaudit=tcc -# Disable auto-playing video -com.apple.Safari WebKitMediaPlaybackAllowsInline bool false noaudit=tcc -com.apple.SafariTechnologyPreview WebKitMediaPlaybackAllowsInline bool false -com.apple.Safari com.apple.Safari.ContentPageGroupIdentifier.WebKit2AllowsInlineMediaPlayback bool false noaudit=tcc -com.apple.SafariTechnologyPreview com.apple.Safari.ContentPageGroupIdentifier.WebKit2AllowsInlineMediaPlayback bool false - -# Enable “Do Not Track” -com.apple.Safari SendDoNotTrackHTTPHeader bool true noaudit=tcc - -# Update extensions automatically -com.apple.Safari InstallExtensionUpdatesAutomatically bool true noaudit=tcc ############################################################################### # Mail # ############################################################################### -# Disable send and reply animations in Mail.app -com.apple.mail DisableReplyAnimations bool true noaudit=tcc -com.apple.mail DisableSendAnimations bool true noaudit=tcc - -# Copy email addresses as `foo@example.com` instead of `Foo Bar ` in Mail.app -com.apple.mail AddressesIncludeNameOnPasteboard bool false noaudit=tcc +# Dropped 2026-09-25: DisableReplyAnimations, DisableSendAnimations and +# AddressesIncludeNameOnPasteboard have no reader in Mail 26. # Add the keyboard shortcut ⌘ + Enter to send an email in Mail.app com.apple.mail NSUserKeyEquivalents dict-add Send @\\U21a9 noaudit=complex @@ -595,25 +566,20 @@ com.apple.ActivityMonitor SortColumn string CPUUsage com.apple.ActivityMonitor SortDirection int 0 ############################################################################### -# Address Book, Calendar, TextEdit, and Disk Utility # +# Address Book, TextEdit, and QuickTime Player # ############################################################################### +# Dropped 2026-09-25: the Calendar debug menu (pre-10.8) and both Disk Utility +# rows (the El Capitan rewrite reads neither). # Enable the debug menu in Address Book com.apple.addressbook ABShowDebugMenu bool true noaudit=unset -# Enable the debug menu in Calendar (pre-10.8) -com.apple.iCal IncludeDebugMenu bool true - # Use plain text mode for new TextEdit documents com.apple.TextEdit RichText int 0 noaudit=unset # Open and save files as UTF-8 in TextEdit com.apple.TextEdit PlainTextEncoding int 4 noaudit=unset com.apple.TextEdit PlainTextEncodingForWrite int 4 noaudit=unset -# Enable the debug menu in Disk Utility -com.apple.DiskUtility DUDebugMenuEnabled bool true -com.apple.DiskUtility advanced-image-options bool true - # Auto-play videos when opened with QuickTime Player com.apple.QuickTimePlayerX MGPlayMovieOnOpen bool true noaudit=unset @@ -621,11 +587,8 @@ com.apple.QuickTimePlayerX MGPlayMovieOnOpen bool true noaudit=unset # Mac App Store # ############################################################################### -# Enable the WebKit Developer Tools in the Mac App Store -com.apple.appstore WebKitDeveloperExtras bool true - -# Enable Debug Menu in the Mac App Store -com.apple.appstore ShowDebugMenu bool true +# Dropped 2026-09-25: the App Store debug menu and WebKit developer tools +# rows; the Mojave rewrite reads neither. # Enable the automatic update check com.apple.SoftwareUpdate AutomaticCheckEnabled bool true @@ -639,7 +602,7 @@ com.apple.SoftwareUpdate AutomaticDownload int 1 # Install System data files & security updates com.apple.SoftwareUpdate CriticalUpdateInstall int 1 -# Automatically download apps purchased on other Macs +# Install system data files (XProtect, Gatekeeper and MRT definitions) com.apple.SoftwareUpdate ConfigDataInstall int 1 # Turn on app auto-update @@ -655,19 +618,6 @@ com.apple.commerce AutoUpdateRestartRequired bool true # Prevent Photos from opening automatically when devices are plugged in currentHost:com.apple.ImageCapture disableHotPlug bool true -############################################################################### -# Messages # -############################################################################### - -# Disable automatic emoji substitution (i.e. use plain text smileys) -com.apple.messageshelper.MessageController SOInputLineSettings dict-add automaticEmojiSubstitutionEnablediMessage -bool false noaudit=complex - -# Disable smart quotes as it’s annoying for messages that contain code -com.apple.messageshelper.MessageController SOInputLineSettings dict-add automaticQuoteSubstitutionEnabled -bool false noaudit=complex - -# Disable continuous spell checking -com.apple.messageshelper.MessageController SOInputLineSettings dict-add continuousSpellCheckingEnabled -bool false noaudit=complex - ############################################################################### # Google Chrome & Google Chrome Canary # ############################################################################### @@ -700,11 +650,6 @@ com.google.Chrome.canary PMPrintingExpandedStateForPrint2 bool true # Use all F1, F2 as standard keys -g com.apple.keyboard.fnState bool true -# Stop the "Try Safari!" nagification -com.apple.coreservices.uiagent CSUIHasSafariBeenLaunched bool true -com.apple.coreservices.uiagent CSUIRecommendSafariNextNotificationDate date 2050-01-01T00:00:00Z noaudit=complex -com.apple.coreservices.uiagent CSUILastOSVersionWhereSafariRecommendationWasMade float 10.99 - # Disable "floating thumbnail" preview and screenshot delay com.apple.screencapture show-thumbnail bool false @@ -730,20 +675,13 @@ com.apple.driver.AppleBluetoothMultitouch.trackpad TrackpadTwoFingerFromRightEdg org.macosforge.xquartz.X11 enable_iglx bool true org.x.X11 enable_test_extensions bool true -# disable mouse acceleration +# Mouse tracking speed (-1 disables acceleration entirely) .GlobalPreferences com.apple.mouse.scaling float 3 ############################################################################### # Siri # ############################################################################### -# Disable Siri +# Disable Siri and hide its menu bar item +com.apple.assistant.support Assistant Enabled bool false com.apple.Siri StatusMenuVisible bool false noaudit=unset -com.apple.Siri UserHasDeclinedEnable bool true noaudit=unset - -############################################################################### -# Game Center # -############################################################################### - -# Disable Game Center notifications -com.apple.GameCenter GKInviteAlertEnabled bool false noaudit=unset