From 0e37230c4c5d1674f48b6acb1b47f0ef9e3d95d4 Mon Sep 17 00:00:00 2001 From: Josh Nichols Date: Mon, 14 Sep 2026 09:47:36 -0400 Subject: [PATCH 1/2] Add local-project-setup.sh for cross-repo sandbox access, rename cloud-project-setup.sh A cq audit of a week of unsandboxed Bash commands found cross-repo git writes (a pickleclaw session cd-ing into picklehome, etc.) as the single biggest cause of dangerouslyDisableSandbox usage: the sandbox only grants write access to a session's own working directory, so a git command against a sibling repo fails with Operation not permitted and falls back to unsandboxed every time. local-project-setup.sh writes permissions.additionalDirectories into a repo's gitignored .claude/settings.local.json, driven by a new claude/cross-repo-access.jsonc manifest. It's the local counterpart to the existing claude-project-setup.sh, renamed to cloud-project-setup.sh to make the split legible: cloud writes what a Claude Code web session needs (must be committed), local writes what only this machine needs (must not be). See ADR 0057, and the note added to ADR 0041. Already run against pickleclaw and picklehome to fix the actual problem. Co-Authored-By: Claude Sonnet 5 --- claude/README.md | 33 +++- claude/cross-repo-access.jsonc | 20 +++ claude/marketplaces.jsonc | 2 +- claudeconfig.sh | 4 +- ...project-setup.sh => cloud-project-setup.sh | 0 ...1-project-level-claude-plugin-bootstrap.md | 6 + ...0057-local-cross-repo-filesystem-access.md | 132 ++++++++++++++ doc/adr/README.md | 1 + doc/architecture.md | 2 +- functions.sh | 2 +- local-project-setup.sh | 162 ++++++++++++++++++ 11 files changed, 357 insertions(+), 7 deletions(-) create mode 100644 claude/cross-repo-access.jsonc rename claude-project-setup.sh => cloud-project-setup.sh (100%) create mode 100644 doc/adr/0057-local-cross-repo-filesystem-access.md create mode 100755 local-project-setup.sh 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 100% rename from claude-project-setup.sh rename to cloud-project-setup.sh 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)" From 3d1ee1519e0ee582a9b5c09bfdd42764e5bdbfad Mon Sep 17 00:00:00 2001 From: Josh Nichols Date: Mon, 14 Sep 2026 09:48:22 -0400 Subject: [PATCH 2/2] Fix cloud-project-setup.sh's own usage text after rename Got renamed via git mv but the internal --help/error text edits were staged before the rename landed, so they never made it into the first commit. Co-Authored-By: Claude Sonnet 5 --- cloud-project-setup.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cloud-project-setup.sh b/cloud-project-setup.sh index ce50e4a..ac85427 100755 --- a/cloud-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