feat(atomic): manage settings.json declaratively with a merging activation - #2730
Merged
Conversation
`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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
~/.atomic/agent/settings.jsonbecomes 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/agentas a legacy config root, but only while its own root is empty: a populated~/.atomic/agent/settings.jsonoverrides 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.jsonand atomic no longer reads it, so declared changes silently never arrive. The drift has already started: nix declarestheme = "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 droppingonboardedVersionre-runs the first-run wizard on every activation. That is exactly what pi's upstreammutableSettingswhole-file copy does, and why pi'slastChangelogVersionwas observed disappearing across an activation.How
modules/home/ai/agent-settings.nix(new) holdsoptions.aiAgentSettings.{theme,enableInstallTelemetry,packages}, following the existingaiSkillsprecedent. Both pi and atomic inherit from it, so the package set and theme exist once rather than twice.modules/home/ai/atomic/default.nixgainsconfigDirandsettingsoptions and ahome.activation.atomicMergeSettingsentry that runs the merge script.modules/home/ai/atomic/merge-settings.sh(new, wrapped inwriteShellApplication) does the overlay: shallowjqmerge of the declared object onto the existing one, sopackagesis 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 bymv. Output is key-sorted, so repeated runs are byte-identical.defaultProvider,defaultModelanddefaultThinkingLevelare 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
themechangesdark->catppuccin-mocha. The nine other keys —enableAnalytics,lastChangelogVersion,firstRunOnboardingStartedVersion,onboardedVersion,defaultProvider,defaultModel,defaultThinkingLevel, plus the already-correctenableInstallTelemetryandpackages— survive unchanged.onboardedVersionsurviving is what suppresses the first-run wizard.auth.jsonandmodels-store.jsonare 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-fileinstall -Dm644activation is left exactly as it was. This is provably behaviour-preserving:piCodingAgentMutableSettings.dataevaluates to the same store path,/nix/store/j5rqxddywksmv2p9z5gm4631lk8xxvpp-pi-coding-agent-settings.json, at this branch and at the parent commit, andbuiltins.attrNamesover the settings attrset is unchanged (pi-agent-environment-structural'sslowModeSettingsShape).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:
All nine non-
themekeys survive byte-for-byte, andpackagesequals the declared list exactly.Corrupt input fails loudly and leaves the file intact.
A valid-but-non-object
[1,2,3]is refused the same way. The home-manager activation script runs underset -eu, so exit 1 aborts activation rather than being swallowed.Idempotence. Three consecutive runs are byte-identical, sha256
569c30c5....Wholesale
packagesreplacement. A stale extra entry planted in the target'spackagesis 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 viawriteShellApplication.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,compactionRetainedand the extension selectors, all of which now come throughaiAgentSettings.pi-agent-environment-smoke— drives a real pi over RPC against the livepackageEntries; 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) andpackage-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.