diff --git a/claude/README.md b/claude/README.md index b9e0d49..73d61f0 100644 --- a/claude/README.md +++ b/claude/README.md @@ -247,14 +247,21 @@ These keys in `~/.claude/settings.json` are preserved across regenerations: - `enabledPlugins`: plugin activation state - `extraKnownMarketplaces`: managed by `configure_marketplaces()` in claudeconfig.sh -## Marketplaces and per-project plugins +## Per-repo project setup + +Two scripts stamp settings into an individual repo's `.claude/`, each solving +a different visibility problem: `cloud-project-setup.sh` writes what a _cloud_ +session needs to see (so it must be committed), `local-project-setup.sh` +writes what only _this machine_ needs (so it must not be). + +### Marketplaces and per-project plugins (cloud) `marketplaces.jsonc` is the single source of truth for marketplaces (alias -> GitHub repo) and named plugin `profiles` (`core`, `dev`; default `dev`). Two consumers read it: - `claudeconfig.sh` clones the marketplaces globally (`configure_marketplaces()`). -- `claude-project-setup.sh [DIR] [--profile NAME] [--dry-run]` writes a repo's +- `cloud-project-setup.sh [DIR] [--profile NAME] [--dry-run]` writes a repo's **committed** `.claude/settings.json` (`extraKnownMarketplaces` + `enabledPlugins`) so Claude Code on the web picks the plugins up. It merges into existing settings (permissions/hooks survive). See [ADR 0041](../doc/adr/0041-project-level-claude-plugin-bootstrap.md). @@ -263,6 +270,28 @@ Plugin keys are `@`; the alias is the key in `marketplaces`, and plugin names must match each repo's `.claude-plugin/marketplace.json`. +### Cross-repo filesystem access (local) + +`cross-repo-access.jsonc` maps a repo name to the sibling repos its sessions +routinely need to read/write directly (e.g. a `pickleclaw` session running +`git`/deploy commands against `picklehome`). The sandbox only auto-grants +write access to a session's own working directory, so without this, those +cross-repo commands fail with `Operation not permitted` and fall back to +`dangerouslyDisableSandbox`. + +- `local-project-setup.sh [DIR] [--dry-run]` writes a repo's **gitignored** + `.claude/settings.local.json` (`permissions.additionalDirectories`), listing + each sibling as a directory alongside `DIR` (works under both + `~/github.com/technicalpickles/` and pickled-coi's `~/projects/`). It merges + into existing local settings the same way `cloud-project-setup.sh` merges + into committed settings. See [ADR 0057](../doc/adr/0057-local-cross-repo-filesystem-access.md). + +This one is deliberately the opposite of the cloud case: the paths are +machine-specific, so they belong in the gitignored `settings.local.json`, not +the committed `settings.json`. Since it's gitignored, re-run this script on +any other machine (or a fresh clone) where the same repo needs the same +cross-repo access. + ## MCP servers `mcp-servers.jsonc` is the single source of truth for MCP servers registered diff --git a/claude/cross-repo-access.jsonc b/claude/cross-repo-access.jsonc new file mode 100644 index 0000000..1d73ede --- /dev/null +++ b/claude/cross-repo-access.jsonc @@ -0,0 +1,20 @@ +{ + // Single source of truth for which personal repos' Claude Code sessions + // routinely reach into a sibling repo directly (git add/commit/push, deploy + // scripts, etc. run with `cd`/`git -C` against the sibling). + // + // The sandbox only auto-grants write access to a session's own working + // directory (plus its worktree's main-repo .git). A command that `cd`s into + // a different repo entirely gets "Operation not permitted" on that repo's + // .git/index.lock, .git/config, or .git/FETCH_HEAD, and falls back to + // dangerouslyDisableSandbox. See ADR 0057. + // + // Consumed by local-project-setup.sh, which resolves each sibling as a + // directory alongside the target repo and writes it into the target's + // gitignored .claude/settings.local.json as permissions.additionalDirectories. + // + // Keys and values are repo directory names (not paths) -- local-project-setup.sh + // resolves the actual path per machine. + "pickleclaw": ["picklehome", "openclaw-workspace"], + "picklehome": ["pickleclaw"], +} diff --git a/claude/marketplaces.jsonc b/claude/marketplaces.jsonc index 5c9ae9d..7241fb6 100644 --- a/claude/marketplaces.jsonc +++ b/claude/marketplaces.jsonc @@ -3,7 +3,7 @@ // // Consumed by: // - claudeconfig.sh -> configure_marketplaces() clones these globally - // - claude-project-setup.sh -> writes a repo's committed .claude/settings.json + // - cloud-project-setup.sh -> writes a repo's committed .claude/settings.json // (extraKnownMarketplaces + enabledPlugins) so // Claude Code on the web picks the plugins up. // diff --git a/claudeconfig.sh b/claudeconfig.sh index 4b2e1f2..8837b10 100755 --- a/claudeconfig.sh +++ b/claudeconfig.sh @@ -44,7 +44,7 @@ while [ $# -gt 0 ]; do done # read_json (JSONC parser) now lives in functions.sh, shared with -# claude-project-setup.sh. +# cloud-project-setup.sh and local-project-setup.sh. # Detect role (uses existing DOTPICKLES_ROLE from environment) ROLE="${DOTPICKLES_ROLE:-home}" @@ -578,7 +578,7 @@ configure_marketplaces() { mkdir -p "$marketplaces_dir" # Marketplaces come from the shared manifest (single source of truth, also - # read by claude-project-setup.sh). Format per line: "marketplace-id:owner/repo". + # read by cloud-project-setup.sh). Format per line: "marketplace-id:owner/repo". local manifest="$DIR/claude/marketplaces.jsonc" if [ ! -f "$manifest" ]; then echo "Error: $manifest not found" diff --git a/claude-project-setup.sh b/cloud-project-setup.sh similarity index 95% rename from claude-project-setup.sh rename to cloud-project-setup.sh index ce50e4a..ac85427 100755 --- a/claude-project-setup.sh +++ b/cloud-project-setup.sh @@ -14,7 +14,7 @@ MANIFEST="$DIR/claude/marketplaces.jsonc" usage() { cat << 'EOF' -Usage: claude-project-setup.sh [TARGET_DIR] [--profile NAME] [--list-profiles] [--dry-run] +Usage: cloud-project-setup.sh [TARGET_DIR] [--profile NAME] [--list-profiles] [--dry-run] Writes /.claude/settings.json with extraKnownMarketplaces + enabledPlugins for the chosen profile, MERGING into any existing settings @@ -68,7 +68,7 @@ while [ $# -gt 0 ]; do ;; -*) echo "Error: unknown option: $1" >&2 - echo "Run 'claude-project-setup.sh --help' for usage." >&2 + echo "Run 'cloud-project-setup.sh --help' for usage." >&2 exit 2 ;; *) @@ -116,7 +116,7 @@ if ! generated="$(echo "$manifest_json" | jq -e --arg profile "$PROFILE" ' } ' 2>&1)"; then echo "Error: $generated" >&2 - echo "Run 'claude-project-setup.sh --list-profiles' to see valid profiles." >&2 + echo "Run 'cloud-project-setup.sh --list-profiles' to see valid profiles." >&2 exit 2 fi diff --git a/doc/adr/0041-project-level-claude-plugin-bootstrap.md b/doc/adr/0041-project-level-claude-plugin-bootstrap.md index e4b309a..5ef5586 100644 --- a/doc/adr/0041-project-level-claude-plugin-bootstrap.md +++ b/doc/adr/0041-project-level-claude-plugin-bootstrap.md @@ -6,6 +6,12 @@ Date: 2026-06-28 Accepted +Note (2026-09-14): `claude-project-setup.sh` was renamed to +`cloud-project-setup.sh` to distinguish it from the new +`local-project-setup.sh` (see [ADR 0057](0057-local-cross-repo-filesystem-access.md)), +which solves an unrelated, machine-local problem. The decision below is +unchanged; only the filename is. + ## Context Claude Code on the web (cloud) clones a repo fresh into an ephemeral container diff --git a/doc/adr/0057-local-cross-repo-filesystem-access.md b/doc/adr/0057-local-cross-repo-filesystem-access.md new file mode 100644 index 0000000..ffeb071 --- /dev/null +++ b/doc/adr/0057-local-cross-repo-filesystem-access.md @@ -0,0 +1,132 @@ +# 57. Local cross-repo filesystem access + +Date: 2026-09-14 + +## Status + +Accepted + +## Context + +A `cq` audit of unsandboxed Bash commands over a week (2026-09-07 to +2026-09-14) found 671 calls run with `dangerouslyDisableSandbox`, 144 of them +git-related. The single largest identifiable cause (~60 of the 144, across +sessions in `pickleclaw`, `picklehome`, and `openclaw-workspace`) was a +session rooted in one personal repo running a git command against a +_different_ sibling repo -- e.g. a `pickleclaw` session doing +`cd ~/github.com/technicalpickles/picklehome && git commit ...` to land a +homelab config change, or a `picklehome` bridge worktree merging back into +`pickleclaw`. + +The sandbox's default write grant covers only a session's own working +directory (plus, for a linked git worktree, its main repo's shared `.git` -- +built into Claude Code itself, no config needed). A command that `cd`s or +`git -C`s into an unrelated repo entirely falls outside both, and fails with +`Operation not permitted` on that repo's `.git/index.lock`, `.git/config`, or +`.git/FETCH_HEAD`. The retry is `dangerouslyDisableSandbox`, every time, +forever, because nothing about the failure is transient. + +This is a different problem from the one [ADR 0041](0041-project-level-claude-plugin-bootstrap.md) +solved. That ADR's `cloud-project-setup.sh` stamps a repo's **committed** +`.claude/settings.json` with plugin config, because a cloud session clones a +repo fresh with no global `~/.claude` and can only see what's checked in. +Cross-repo filesystem access is the opposite: the correct path +(`~/github.com/technicalpickles/picklehome` on this Mac, +`~/projects/picklehome` on the `pickled-coi` VM) is inherently +machine-specific. Checking an absolute path like that into a repo's committed +settings would be silently wrong wherever the layout differs -- exactly the +failure mode ADR 0041 rejected `settings.local.json` to avoid, just pointed +the other direction. + +## Decision + +Add `claude/cross-repo-access.jsonc`, a manifest mapping a repo name to the +sibling repos its sessions are known to reach into directly (currently +`pickleclaw` -> `[picklehome, openclaw-workspace]`, `picklehome` -> +`[pickleclaw]`, derived from the `cq` audit). + +Add `local-project-setup.sh [TARGET_DIR] [--dry-run]`, mirroring +`cloud-project-setup.sh`'s shape (manifest-driven, `jq` merge preserving +other keys, atomic write + `jq empty` validation, one-time `.backup`) but +writing `permissions.additionalDirectories` into `TARGET_DIR`'s **gitignored** +`.claude/settings.local.json` instead. Each declared sibling resolves to a +directory alongside `TARGET_DIR`, so the same manifest entry produces the +right path under any parent directory convention without role-detection +logic. `additionalDirectories` (not `sandbox.filesystem.allowWrite`) is the +right primitive here: it grants ordinary read+write to the whole sibling +tree, matching what a command doing real work there needs, rather than +chasing individual `.git/*` paths one at a time. + +Renamed `claude-project-setup.sh` to `cloud-project-setup.sh` to make the +split legible: `cloud-project-setup.sh` writes what a cloud session needs to +see (must be committed), `local-project-setup.sh` writes what only this +machine needs (must not be committed). See the note added to ADR 0041. + +Because `settings.local.json` is gitignored, this manifest only takes effect +once `local-project-setup.sh` is run against each affected repo on each +machine -- it does not propagate through `claudeconfig.sh` or `git pull`. It +is deliberately not wired into `claudeconfig.sh`'s automatic run, matching +`cloud-project-setup.sh`'s existing manual-per-repo pattern (ADR 0041): the +set of repos that need this is small and known, not every clone. + +### Alternatives Considered + +1. **Wildcard `sandbox.filesystem.allowWrite` for all personal repos** + (`~/github.com/technicalpickles/*/.git` or broader) in the global `home` + role. + + - Pros: covers any future repo automatically, no manifest to maintain. + - Cons: removes isolation between every personal repo for every session, + not just the specific pairs that actually cross-reference each other. + Widens `allowWrite`, which per the sandboxing docs is meant for narrow + tool-state paths, not general read+write to whole repos. + - Rejected: blast radius too broad for the actual, small set of repos + involved. + +2. **Narrow `allowWrite` entries for the specific failing paths** + (`.git/index.lock`, `.git/config`, `.git/FETCH_HEAD`, + `.git/worktrees/*/index.lock`). + + - Pros: minimal new write access. + - Cons: whack-a-mole -- any new cross-repo operation (reading a file, + running a script in the sibling repo, `just deploy-*`) hits a path not + on the list. `additionalDirectories` already exists as the primitive for + "treat this directory like a working directory." + - Rejected: solves the symptom, not the shape of the actual workflow. + +3. **Global `home` role entry, same as the SSH relay fix** + (`claude/roles/home.jsonc`). + - Pros: one place, consistent with how the SSH-over-relay fix was applied. + - Cons: `home.jsonc` is user-scope and applies to _every_ project + regardless of whether it has anything to do with `picklehome` or + `pickleclaw`. The access is a property of a specific pair of repos, not + of the role. + - Rejected: wrong scope; per-repo project settings is what + `additionalDirectories` and workspace trust are designed around. + +## Consequences + +### Positive + +- Removes the largest single identified cause of `dangerouslyDisableSandbox` + git usage (per the `cq` audit), without widening sandbox access beyond the + specific repo pairs that need it. +- `claude/cross-repo-access.jsonc` is one place to see (and extend) which + repos are known to cross-reference each other. +- Consistent with the existing `cloud-project-setup.sh` pattern; the rename + makes the cloud/local split self-explanatory instead of needing this ADR + read first. + +### Negative + +- Gitignored means it does not propagate: a fresh clone or a new machine + needs `local-project-setup.sh` re-run by hand. No automation runs it + today. +- The manifest can drift from reality -- a new cross-repo workflow needs a + manual addition, the same maintenance cost `marketplaces.jsonc` already + has. +- Bootstrapping `local-project-setup.sh` itself needs + `dangerouslyDisableSandbox` when run from a session not rooted in + `TARGET_DIR` (e.g. a dotfiles session writing into `pickleclaw`'s + settings), since that write is itself a cross-repo write. Run it from + inside `TARGET_DIR` to avoid that. diff --git a/doc/adr/README.md b/doc/adr/README.md index 193a204..57dc7af 100644 --- a/doc/adr/README.md +++ b/doc/adr/README.md @@ -55,3 +55,4 @@ - [54. sync-authorized-keys-from-1password-for-inbound-ssh](0054-sync-authorized-keys-from-1password-for-inbound-ssh.md) - [55. agent-git-over-ssh-through-the-relay](0055-agent-git-over-ssh-through-the-relay.md) - [56. auto-mode-classifier-rules-in-role-sources](0056-auto-mode-classifier-rules-in-role-sources.md) +- [57. local-cross-repo-filesystem-access](0057-local-cross-repo-filesystem-access.md) diff --git a/doc/architecture.md b/doc/architecture.md index 9eca1b4..19a6bba 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -10,7 +10,7 @@ The central architectural pattern is **role-based adaptation**. The role is dete The canonical role values are `home`, `work`, `container`, and `claude-code-remote` (see [ADR 0035](adr/0035-canonical-dotpickles-role-names.md) and [ADR 0040](adr/0040-claude-code-remote-role.md)). Detection precedence: `claude-code-remote` when `CLAUDE_CODE_REMOTE=true` (Claude Code on the web; cloud is also a container, so this must win), then `container` inside containers, then `work` for hostnames matching `josh-nichols-*`, otherwise `home`. The same check is duplicated across bash, fish, and zsh because they can't share one snippet: bash scripts get it from `dotpickles_detect_role` in [functions.sh](../functions.sh) (sourced by [install.sh](../install.sh) and standalone setup scripts), while the interactive shells set it themselves in [config/fish/conf.d/dotpickles-role.fish](../config/fish/conf.d/dotpickles-role.fish) and [home/.zshenv](../home/.zshenv). The canonical-name list and a fail-loud guard ([ADR 0036](adr/0036-fail-loud-role-resolution.md)) keep the copies from drifting silently. The fish copy lives in `conf.d/` (not `config.fish`) on purpose: fish sources `conf.d/*.fish` before `config.fish`, and the starship prompt reads `DOTPICKLES_ROLE` at init time, so the role must be set first. -In a Claude Code cloud session, the dotfiles entry point is [claudeconfig.sh](../claudeconfig.sh) on its own (set it as the environment's setup command), not the full [install.sh](../install.sh). The `claude-code-remote` role makes that run lean: the sandbox is off (the cloud container is already isolated), there is no agent SSH identity (git goes through the GitHub integration), and macOS-only bits are inert. Per-repo plugins are a separate mechanism: [claude-project-setup.sh](../claude-project-setup.sh) stamps a committed `.claude/settings.json` (`extraKnownMarketplaces` + `enabledPlugins`) from the shared [claude/marketplaces.jsonc](../claude/marketplaces.jsonc) manifest, since cloud only reads a repo's committed settings (see [ADR 0041](adr/0041-project-level-claude-plugin-bootstrap.md)). +In a Claude Code cloud session, the dotfiles entry point is [claudeconfig.sh](../claudeconfig.sh) on its own (set it as the environment's setup command), not the full [install.sh](../install.sh). The `claude-code-remote` role makes that run lean: the sandbox is off (the cloud container is already isolated), there is no agent SSH identity (git goes through the GitHub integration), and macOS-only bits are inert. Per-repo plugins are a separate mechanism: [cloud-project-setup.sh](../cloud-project-setup.sh) stamps a committed `.claude/settings.json` (`extraKnownMarketplaces` + `enabledPlugins`) from the shared [claude/marketplaces.jsonc](../claude/marketplaces.jsonc) manifest, since cloud only reads a repo's committed settings (see [ADR 0041](adr/0041-project-level-claude-plugin-bootstrap.md)). Its local counterpart, [local-project-setup.sh](../local-project-setup.sh), does the opposite: it stamps a repo's gitignored `.claude/settings.local.json` with `permissions.additionalDirectories` from the [claude/cross-repo-access.jsonc](../claude/cross-repo-access.jsonc) manifest, so a session rooted in one personal repo can read/write a sibling repo it routinely reaches into (e.g. `pickleclaw` deploying into `picklehome`) without the sandbox blocking it (see ADR 0057). ## Symlink-Based File Management diff --git a/functions.sh b/functions.sh index 72ddd65..8855219 100755 --- a/functions.sh +++ b/functions.sh @@ -256,7 +256,7 @@ dotpickles_detect_role() { dotpickles_detect_role # Read a JSON or JSONC file to stdout, stripping comments and trailing commas. -# Shared by claudeconfig.sh and claude-project-setup.sh. +# Shared by claudeconfig.sh, cloud-project-setup.sh, and local-project-setup.sh. # # Uses a single python3 parser rather than the node path it once had. The node # path stripped comments with regexes that were not string-aware, so a glob like diff --git a/local-project-setup.sh b/local-project-setup.sh new file mode 100755 index 0000000..4729284 --- /dev/null +++ b/local-project-setup.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +set -e + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=./functions.sh +source "$DIR/functions.sh" + +MANIFEST="$DIR/claude/cross-repo-access.jsonc" + +# Grants a repo's Claude Code sessions filesystem access to sibling repos it +# routinely reaches into directly (git add/commit/push, deploy scripts, etc. +# run against a different repo via `cd`/`git -C`). The sandbox only +# auto-grants write access to a session's own working directory, so without +# this, those cross-repo commands fail with "Operation not permitted" and +# fall back to dangerouslyDisableSandbox. See ADR 0057. +# +# Unlike cloud-project-setup.sh, this writes the repo's GITIGNORED +# .claude/settings.local.json, not the committed settings.json: sibling repo +# paths are machine-specific (~/github.com/technicalpickles/... here, +# ~/projects/... on pickled-coi), so committing them would be wrong on any +# other machine. Re-run this script on each machine where the repo needs the +# same cross-repo access -- it isn't propagated by git. +# +# Bootstrapping note: writing into TARGET_DIR is itself a cross-repo write if +# your current session isn't rooted in TARGET_DIR -- e.g. running this from a +# dotfiles session against ~/github.com/technicalpickles/pickleclaw hits the +# exact sandbox wall this script exists to fix, and needs +# dangerouslyDisableSandbox for that one-time bootstrap. Run it from inside +# TARGET_DIR itself to avoid that. + +usage() { + cat << 'EOF' +Usage: local-project-setup.sh [TARGET_DIR] [--dry-run] + +Writes /.claude/settings.local.json with +permissions.additionalDirectories, listing the sibling repos TARGET_DIR is +declared to need (from claude/cross-repo-access.jsonc), each resolved as a +directory alongside TARGET_DIR. MERGES into any existing local settings +(other keys survive; additionalDirectories entries are unioned, not +replaced). + +Arguments: + TARGET_DIR project directory to configure (default: current directory) + +Options: + --dry-run print the merged settings.local.json without writing + -h, --help show this help +EOF +} + +if ! command_available jq; then + echo "Error: jq required. Install with: brew install jq" >&2 + exit 1 +fi + +if [ ! -f "$MANIFEST" ]; then + echo "Error: manifest not found: $MANIFEST" >&2 + exit 1 +fi + +TARGET_DIR="" +DRY_RUN="" +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) + DRY_RUN=1 + shift + ;; + -h | --help) + usage + exit 0 + ;; + -*) + echo "Error: unknown option: $1" >&2 + echo "Run 'local-project-setup.sh --help' for usage." >&2 + exit 2 + ;; + *) + if [ -n "$TARGET_DIR" ]; then + echo "Error: unexpected extra argument: $1" >&2 + exit 2 + fi + TARGET_DIR="$1" + shift + ;; + esac +done + +TARGET_DIR="${TARGET_DIR:-$(pwd)}" +if [ ! -d "$TARGET_DIR" ]; then + echo "Error: target is not a directory: $TARGET_DIR" >&2 + exit 2 +fi +TARGET_DIR="$(cd "$TARGET_DIR" && pwd)" +REPO_NAME="$(basename "$TARGET_DIR")" +PARENT_DIR="$(dirname "$TARGET_DIR")" + +echo "🔗 Configuring cross-repo access for: $TARGET_DIR" + +manifest_json="$(read_json "$MANIFEST")" +siblings="$(echo "$manifest_json" | jq -r --arg repo "$REPO_NAME" '.[$repo] // [] | .[]')" + +if [ -z "$siblings" ]; then + echo " No cross-repo access declared for '$REPO_NAME' in claude/cross-repo-access.jsonc -- nothing to do." + exit 0 +fi + +# Resolve each declared sibling as a directory alongside TARGET_DIR, and warn +# (don't fail) if it isn't actually cloned there -- the entry might be for a +# machine that hasn't cloned it yet. +paths_json="[]" +while IFS= read -r sibling; do + [ -z "$sibling" ] && continue + sibling_path="$PARENT_DIR/$sibling" + if [ ! -d "$sibling_path" ]; then + echo " ⚠ $sibling_path does not exist on this machine -- adding it anyway (clone it, or it's a no-op)" >&2 + fi + paths_json="$(echo "$paths_json" | jq --arg p "$sibling_path" '. + [$p]')" +done <<< "$siblings" + +echo " Sibling repos: $(echo "$paths_json" | jq -r 'join(", ")')" + +settings_file="$TARGET_DIR/.claude/settings.local.json" + +existing="{}" +if [ -f "$settings_file" ]; then + if ! existing="$(read_json "$settings_file" 2> /dev/null)"; then + echo "Error: $settings_file exists but is not valid JSON; refusing to overwrite." >&2 + exit 1 + fi +fi + +# Merge: union additionalDirectories (dedup, sorted) rather than replacing it, +# so a manual addition of the user's own survives a re-run. Other keys pass +# through untouched. +merged="$(echo "$existing" | jq --argjson new "$paths_json" ' + .permissions = (.permissions // {}) + | .permissions.additionalDirectories = (((.permissions.additionalDirectories // []) + $new) | unique | sort) +')" + +if [ -n "$DRY_RUN" ]; then + echo " (dry run -- not writing $settings_file)" + echo "$merged" + exit 0 +fi + +mkdir -p "$TARGET_DIR/.claude" +temp_file="$(mktemp)" +echo "$merged" > "$temp_file" +if ! jq empty "$temp_file" 2> /dev/null; then + echo "Error: generated invalid JSON" >&2 + rm -f "$temp_file" + exit 1 +fi + +if [ -f "$settings_file" ] && [ ! -f "$settings_file.backup" ]; then + cp "$settings_file" "$settings_file.backup" + echo " ℹ Backed up existing settings to ${settings_file##*/}.backup" +fi + +mv "$temp_file" "$settings_file" +echo " ✓ Wrote $settings_file (gitignored -- re-run this script on any other machine that needs the same access)"