Skip to content

feat(atomic): manage settings.json declaratively with a merging activation - #2730

Merged
cameronraysmith merged 2 commits into
mainfrom
fm/vx-atomic-declarative-settings
Aug 17, 2026
Merged

feat(atomic): manage settings.json declaratively with a merging activation#2730
cameronraysmith merged 2 commits into
mainfrom
fm/vx-atomic-declarative-settings

Conversation

@cameronraysmith

Copy link
Copy Markdown
Owner

What

~/.atomic/agent/settings.json becomes nix-managed for three keys — theme, enableInstallTelemetry, packages — written by a merging activation that leaves every key atomic writes itself untouched.

Why

atomic reads ~/.pi/agent as a legacy config root, but only while its own root is empty: a populated ~/.atomic/agent/settings.json overrides the pi one rather than merging with it. That file now exists on this host, written by atomic's first-run wizard, which copied a snapshot of the nix package set across and froze it. Home-manager keeps writing ~/.pi/agent/settings.json and atomic no longer reads it, so declared changes silently never arrive. The drift has already started: nix declares theme = "catppuccin-mocha", atomic's file says "dark".

Installing a nix-generated file over atomic's is not an option. atomic writes onboardedVersion, lastChangelogVersion, the provider/model/thinking selection and the analytics flag into that same file, and dropping onboardedVersion re-runs the first-run wizard on every activation. That is exactly what pi's upstream mutableSettings whole-file copy does, and why pi's lastChangelogVersion was observed disappearing across an activation.

How

  • modules/home/ai/agent-settings.nix (new) holds options.aiAgentSettings.{theme,enableInstallTelemetry,packages}, following the existing aiSkills precedent. Both pi and atomic inherit from it, so the package set and theme exist once rather than twice.
  • modules/home/ai/atomic/default.nix gains configDir and settings options and a home.activation.atomicMergeSettings entry that runs the merge script.
  • modules/home/ai/atomic/merge-settings.sh (new, wrapped in writeShellApplication) does the overlay: shallow jq merge of the declared object onto the existing one, so packages is replaced wholesale and never appended; a target that is not a JSON object aborts with exit 1 and no write; an absent target gets the declared keys alone; the write is a temp file in the same directory followed by mv. Output is key-sorted, so repeated runs are byte-identical.

defaultProvider, defaultModel and defaultThinkingLevel are deliberately not declared — the captain runs openai-codex while the file says openrouter, so declaring them would fight whatever actually selects the model.

Known and accepted limitation, recorded in a comment in the script: this can add or update a nix-owned key but not retract one. A key nix stops declaring keeps its last value. No deletion mechanism was built.

Effect on this host at the next activation

theme changes dark -> catppuccin-mocha. The nine other keys — enableAnalytics, lastChangelogVersion, firstRunOnboardingStartedVersion, onboardedVersion, defaultProvider, defaultModel, defaultThinkingLevel, plus the already-correct enableInstallTelemetry and packages — survive unchanged. onboardedVersion surviving is what suppresses the first-run wizard. auth.json and models-store.json are untouched.

Pi behaviour is unchanged

The pi module change is the minimum needed to factor the shared value out: the three literals become inherit (config.aiAgentSettings) .... Nothing else about pi moves — its whole-file install -Dm644 activation is left exactly as it was. This is provably behaviour-preserving: piCodingAgentMutableSettings.data evaluates to the same store path, /nix/store/j5rqxddywksmv2p9z5gm4631lk8xxvpp-pi-coding-agent-settings.json, at this branch and at the parent commit, and builtins.attrNames over the settings attrset is unchanged (pi-agent-environment-structural's slowModeSettingsShape).

Demonstrations

Real before/after runs against scratch copies of the live file — never the live file, and no activation was run — driving the exact store-path executable and declared JSON the activation would use.

Merge preserves agent-written keys. Starting from a copy of the current live file:

theme: "dark"  ->  "catppuccin-mocha"
diff <(jq -S 'del(.theme)' before.json) <(jq -S 'del(.theme)' after.json)   # empty
diff <(jq -S .packages declared.json) <(jq -S .packages after.json)         # empty

All nine non-theme keys survive byte-for-byte, and packages equals the declared list exactly.

Corrupt input fails loudly and leaves the file intact.

$ printf '{"onboardedVersion": "0.9.13", "theme": "dark"' > settings.json   # truncated
$ atomic-merge-settings declared.json settings.json
atomic-merge-settings: .../settings.json is not a JSON object; refusing to overwrite it
exit status: 1
sha256 before = sha256 after = e1995c7f...   # unchanged, and no temp file left behind

A valid-but-non-object [1,2,3] is refused the same way. The home-manager activation script runs under set -eu, so exit 1 aborts activation rather than being swallowed.

Idempotence. Three consecutive runs are byte-identical, sha256 569c30c5....

Wholesale packages replacement. A stale extra entry planted in the target's packages is gone after the merge, rather than being kept alongside the declared ones.

Absent target. Parent directory created; file holds exactly the three declared keys at mode 644.

Checks run

Selection is the surface a new home-manager settings writer touches: the activation packages that embed it, the configuration-shape checks, and the existing pi assertions about the settings expression it now shares.

  • home-manager-crs58, home-manager-cameron — the two users whose aggregates include this module (verified by evaluating for all six). These build the activation script and run shellcheck over the merge script via writeShellApplication.
  • structure-aggregate-eval-failure, structure-darwin-configurations, structure-home-configurations, structure-inventory-class-discovery, structure-inventory-machines, structure-nixos-configurations.
  • pi-agent-environment-structural — asserts pi's settings shape, slowModeSettingsShape, compactionRetained and the extension selectors, all of which now come through aiAgentSettings.
  • pi-agent-environment-smoke — drives a real pi over RPC against the live packageEntries; the real regression test for the shared package list.

All green.

Deliberately left out: pi-agent-environment-policy (permission-gate TypeScript, untouched by this diff) and package-atomic / package-atomic-test-help (the atomic derivation itself, untouched). No atomic-side regulator was added to the checks suite — nothing in the repo asserts anything about atomic's config — but that gap predates this change and closing it is a separate decision.

Build only; the machine was not activated.

`theme`, `enableInstallTelemetry` and `packages` are about to be written
into `~/.atomic/agent/settings.json` as well as `~/.pi/agent/settings.json`.
Declaring the values a second time in the atomic module would reproduce the
defect that makes that write necessary: atomic's first-run wizard already
copied a snapshot of this package set into its own root and froze it, and the
two files have already drifted once, nix declaring `catppuccin-mocha` against
the wizard-written `dark`.

The values move to `options.aiAgentSettings` in the same homeManager.ai
aggregate, following the `aiSkills` precedent, and pi inherits the three keys
from there.

Behaviour-preserving: the generated settings file is the same store path
before and after,
`/nix/store/j5rqxddywksmv2p9z5gm4631lk8xxvpp-pi-coding-agent-settings.json`,
compared by evaluating `piCodingAgentMutableSettings.data` at this worktree
and at the parent commit. `builtins.attrNames` over the settings attrset is
unchanged too, which is what `pi-agent-environment-structural` asserts as
`slowModeSettingsShape`.

Verified with `pi-agent-environment-structural` (reads
`programs.pi-coding-agent.settings` directly), `pi-agent-environment-smoke`
(drives a real pi over RPC using the live `packageEntries`) and
`home-manager-crs58` plus `home-manager-cameron` (the two users whose
aggregates include this module). `pi-agent-environment-policy` was left out:
it exercises the permission-gate TypeScript, which this does not touch.
atomic reads `~/.pi/agent` as a legacy config root, but only while its own
root is empty: a populated `~/.atomic/agent/settings.json` overrides the pi
one rather than merging with it, measured with marker packages registered in
both roots. That file now exists on this host, written by atomic's first-run
wizard, so nothing declared for pi reaches atomic any more and the two have
already drifted, `theme` reading `dark` against the declared
`catppuccin-mocha`.

Installing a nix-generated file over it is not an option: atomic writes
`onboardedVersion`, `lastChangelogVersion`, the provider and model selection
and the analytics flag into the same file, and dropping `onboardedVersion`
re-runs the first-run wizard on every activation. So the activation overlays
only the three nix-owned keys (`theme`, `enableInstallTelemetry`, `packages`)
onto whatever is there, from `aiAgentSettings`, the same expression that feeds
pi's settings. `packages` is replaced wholesale rather than merged, so
dropping a package from nix genuinely drops it.

The merge is a `writeShellApplication` rather than inline activation text so
the same executable that activation runs can be driven against scratch copies.
A target that is not a JSON object aborts with a nonzero status and leaves the
file untouched; the activation script runs under `set -eu`, so that aborts the
activation rather than being swallowed. The write is a temp file in the same
directory followed by `mv`, so an interrupted run cannot truncate the target.
`auth.json` and `models-store.json` stay unmanaged.

Known limitation, recorded in the script: this can add or update a nix-owned
key but not retract one. A key nix stops declaring keeps its last value.

Demonstrated against scratch copies of the live file, never the live file
itself, with the exact store-path executable and declared JSON that activation
would use:

- Merge: `theme` goes `dark` -> `catppuccin-mocha`; a `diff` of before and
  after with `.theme` deleted is empty, so the other nine keys survive
  byte-for-byte, and the merged `packages` diffs clean against the declared
  list.
- Corrupt input: a truncated object exits 1 with a diagnostic and the file's
  sha256 is unchanged; a valid-but-non-object `[1,2,3]` is refused the same
  way; no temp file is left behind.
- Absent target: the parent directory is created and the file holds exactly
  the three declared keys at mode 644.
- Idempotence: three consecutive runs are byte-identical (sha256
  569c30c5...).
- Wholesale replacement: a stale extra package planted in `packages` is gone
  after the merge.

Verified with `home-manager-crs58` and `home-manager-cameron`, the two users
whose aggregates include this module and the checks that build the activation
script and run shellcheck over the merge script; the six `structure-*` checks,
which cover the shape of the configurations this module feeds; and
`pi-agent-environment-structural` and `-smoke`, which assert pi's settings
shape and load its extensions for real. Left out: `pi-agent-environment-policy`
(permission-gate TypeScript, untouched) and `package-atomic*` (the package
derivation, untouched). No atomic regulator was added to the checks suite; that
gap predates this change and adding one remains a separate decision.
@cameronraysmith
cameronraysmith marked this pull request as ready for review August 17, 2026 02:05
@cameronraysmith
cameronraysmith merged commit eb13f0a into main Aug 17, 2026
6 checks passed
@cameronraysmith
cameronraysmith deleted the fm/vx-atomic-declarative-settings branch August 17, 2026 02:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant