●CLI — Claude Code v2.1.259 landed on September 2. It is a dense release focused on org-wide configuration, unattended runs, and permissions — the most practically useful one this week●MCP — A new managedMcpServers setting lets an organization push HTTP/SSE MCP servers to every user. At the same time allowedMcpServers narrowed in scope, so an allowlist no longer filters managed servers out●HEADLESS — The new --permission-prompts none flag denies anything that would prompt, while the active permission mode, auto included, keeps making its own calls. Useful for anything you run unattended●SECURITY — Bash Read() deny rules had gaps: files passed as option values such as --ignore-revs-file=.env, and compounds like cd DIR && cat FILE, slipped past them. Both are now covered●SLACK — Claude Tag no longer judges messages one at a time. It reads the whole channel, along with its memory and your standing instructions, and is roughly 30% better at staying out of conversations●LEARN — Claude Academy is open. It is a free learning hub built on the 4D AI Fluency Framework that Anthropic uses to onboard its own team, with completion tracked in your Claude profile●CLI — Claude Code v2.1.259 landed on September 2. It is a dense release focused on org-wide configuration, unattended runs, and permissions — the most practically useful one this week●MCP — A new managedMcpServers setting lets an organization push HTTP/SSE MCP servers to every user. At the same time allowedMcpServers narrowed in scope, so an allowlist no longer filters managed servers out●HEADLESS — The new --permission-prompts none flag denies anything that would prompt, while the active permission mode, auto included, keeps making its own calls. Useful for anything you run unattended●SECURITY — Bash Read() deny rules had gaps: files passed as option values such as --ignore-revs-file=.env, and compounds like cd DIR && cat FILE, slipped past them. Both are now covered●SLACK — Claude Tag no longer judges messages one at a time. It reads the whole channel, along with its memory and your standing instructions, and is roughly 30% better at staying out of conversations●LEARN — Claude Academy is open. It is a free learning hub built on the 4D AI Fluency Framework that Anthropic uses to onboard its own team, with completion tracked in your Claude profile
The One File I Keep Out of Claude Code's Reach: project.pbxproj
With Xcode project files, the breakage that still opens costs far more than the breakage that refuses to open. Here is what I measured before moving every edit behind a script, and where the line sits today.
The week the new iPhone screen sizes landed, I was updating the constants header across all four of my apps at once. Firebase was moving from CocoaPods to Swift Package Manager in the same stretch, so project.pbxproj was shifting by a few hundred lines a day.
The work was going fine. Builds passed, the simulator behaved. What caught my attention was the final device check on a Release build of my wallpaper app — the low-density image bucket came up empty. Not one asset.
Xcode opened normally. No warnings anywhere. The file references for those resources had simply drifted out of the target's build phase. Somewhere in those few hundred lines, one reference had moved to a different group and quietly left Copy Bundle Resources.
Since that day, project file edits sit outside what Claude Code may touch. Not because I distrust the agent — I cause the exact same accident by hand. The problem was never the editor. It is that this file format does not tell you when it has broken.
Two kinds of breakage, and only one announces itself
Failures in project.pbxproj come in two flavours with almost nothing in common.
The first is structural. A UUID reference dangles, a quote never closes, a section loses its terminator — and Xcode throws an error the moment you open the project. The cost is the minutes you spend before git checkout puts it back.
The second leaves the structure intact and shifts only the meaning. References are alive, Xcode opens, the build succeeds. What changed is which target and which phase a file belongs to. That is the one you only see on a real device.
Aspect
Refuses to open
Opens anyway
Where you notice
The instant you open Xcode
Device check, review, or a user report
Time to notice
Seconds
Days
Caught by CI
Yes — the build fails
No — the build stays green
Recovery
Revert one commit
First find which diff caused it
Recurrence
Low; you learn immediately
High; you never get the lesson
Guarding only against the first was my mistake. Adding a syntax check does nothing whatsoever for the second.
Suspect the change that still opens before the change that refuses to.
Once I had that sentence, the target of my defences moved. What needed protecting was not the file's syntax but the mapping between targets and files.
Direct edits versus a script, in practice
There are two paths. Let Claude Code edit project.pbxproj as text, or have it write a script against the xcodeproj gem and run that instead.
Direct editing is fast, and the diff reads clearly. I ran that way for the first week, and honestly most of the changes landed correctly.
The trouble is how failures survive. With text edits, the only way to verify that a change carried the intended meaning is to read the diff. In a few hundred changed lines, the meaningful edit is often 4 lines. The rest is UUID reordering and whitespace churn, and that is exactly where careful reading stops being careful.
Aspect
Direct text edit
Via xcodeproj script
Unit of change
Lines
Objects: targets, phases, references
How failure appears
Broken syntax, or shifted meaning
The script raises and stops
Re-running
Risks applying the diff twice
Idempotent, so run it as often as you like
What you review
Hundreds of diff lines
Tens of script lines
Rolling out to 3 more apps
Repeat by hand
Same script, different arguments
Setup cost
None
Install the gem, write the script
Applying one change across four apps is where the second column wins outright. Write the script once for the first app and the other three are argument changes — though if you ship a single app, that setup may not pay for itself.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You will be able to decide, from your own repository's shape, whether to let an agent edit project.pbxproj directly or route every change through an xcodeproj script
✦You will be able to catch the silent kind of breakage — builds green, assets missing on device — during a pre-release inventory instead of losing hours the night before you ship
✦You will be able to combine a settings.json deny rule with an idempotent Ruby script so that project file edits enter through exactly 1 door
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
The first thing I scripted was adding the Crashlytics dSYM upload as a Run Script phase. The SPM migration had broken the reference that pointed at ${PODS_ROOT}, and crashes had been arriving unsymbolicated for several days.
Idempotence was the whole game. If running it twice does not land in the same state, a script is more dangerous than a hand edit, not less.
#!/usr/bin/env ruby# add_run_script.rb — adds a Run Script phase, idempotently# gem install xcodeproj# ruby add_run_script.rb MyApp.xcodeproj MyApprequire "xcodeproj"PHASE_NAME = "[Dolice] Upload dSYMs"project_path, target_name = ARGVabort("usage: add_run_script.rb <project.xcodeproj> <target>") unless project_path && target_nameproject = Xcodeproj::Project.open(project_path)target = project.targets.find { |t| t.name == target_name }abort("target not found: #{target_name}") unless target# Look the phase up by name. Looking it up by UUID misses phases created# on another machine, and you end up adding a second copy.phase = target.shell_script_build_phases.find { |p| p.name == PHASE_NAME }phase ||= target.new_shell_script_build_phase(PHASE_NAME)phase.shell_path = "/bin/sh"phase.shell_script = <<~SH set -euo pipefail # After the SPM move this lives in DerivedData, not under Pods BIN="${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run" test -x "$BIN" || { echo "warning: crashlytics run not found at $BIN"; exit 0; } "$BIN"SHphase.input_paths = ["${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}"]phase.run_only_for_deployment_postprocessing = "0"# Pin the position too. Left implicit, the phase can run before artifacts exist.target.build_phases.delete(phase)target.build_phases << phaseproject.saveputs "ok: #{target_name} -> #{PHASE_NAME}"
Everything hinges on the three details below.
Match the existing phase by name, not UUID. UUIDs differ per machine, so a UUID lookup silently misses phases added elsewhere
Overwrite the script body every run. Appending leaves stale lines behind, mixed in with the new ones
Move the phase to the end explicitly. An implicit position eventually runs before the build artifacts are there
I do let Claude Code write and run this script. What it is trusted with is authoring and executing a script — not editing the project file. That distinction pays off later.
Judge a change by whether the inventory matches, not by whether it opens
Scripting does not eliminate shifted meaning. Since it does not, I built something to detect it.
project.pbxproj is an OpenStep property list, so macOS plutil converts it to JSON as-is. No custom parser is needed to pull out the per-target file lists.
#!/usr/bin/env python3"""pbx_inventory.py — lists files by target and build phase python3 pbx_inventory.py MyApp.xcodeproj > before.txt # now make whatever change touches the project file python3 pbx_inventory.py MyApp.xcodeproj > after.txt diff before.txt after.txt"""import jsonimport subprocessimport sysfrom pathlib import PathPHASES = { "PBXSourcesBuildPhase": "Sources", "PBXResourcesBuildPhase": "Resources", "PBXFrameworksBuildPhase": "Frameworks",}def load(xcodeproj: Path) -> dict: pbx = xcodeproj / "project.pbxproj" # plutil reads the OpenStep format directly. Send the conversion to stdout # and never back to the file itself — the "-o -" is the important part. raw = subprocess.run( ["plutil", "-convert", "json", "-o", "-", str(pbx)], capture_output=True, check=True, ).stdout return json.loads(raw)["objects"]def path_of(objects: dict, ref: str) -> str: """Walk up through parent groups to something close to a repo-relative path""" node = objects.get(ref, {}) name = node.get("path") or node.get("name") or ref for key, value in objects.items(): if value.get("isa") == "PBXGroup" and ref in value.get("children", []): parent = path_of(objects, key) return f"{parent}/{name}" if parent else name return namedef main(xcodeproj: str) -> int: objects = load(Path(xcodeproj)) rows = [] for target in (o for o in objects.values() if o.get("isa") == "PBXNativeTarget"): for phase_ref in target.get("buildPhases", []): phase = objects.get(phase_ref, {}) label = PHASES.get(phase.get("isa")) if not label: continue for build_file_ref in phase.get("files", []): file_ref = objects.get(build_file_ref, {}).get("fileRef") if not file_ref: continue rows.append(f"{target['name']}\t{label}\t{path_of(objects, file_ref)}") # Ordering follows UUIDs and shifts every run, so always sort before printing for row in sorted(set(rows)): print(row) return 0if __name__ == "__main__": sys.exit(main(sys.argv[1]))
Three columns come out: target, phase, path. Take one before your work and one after, run diff, and only the unintended arrivals and departures remain.
The accident I opened with was exactly that line disappearing. Invisible while I read hundreds of diff lines, obvious the moment the same change was folded into three columns — less information turned out to be what made it findable.
Where I stumbled
The parent-group walk in path_of scans every object once per reference. On a larger project that gets noticeably slow, so in daily use I precompute a child-to-parent map instead. The version above favours readability.
There is a bonus, too. If plutil fails outright, the project file is genuinely broken. This inventory aims at the silent failure, and picks up the loud one on the way past.
Declare the boundary on the Claude Code side
A rule that lives only in my head collapses on a busy day, so I wrote it into the settings file.
Writing both deny and allow is the point. Blocking edits alone leaves sed and python3 -c as open routes — unless the intended road is marked, a narrow path forms beside the one you closed.
Verify the setting by behaviour once it is in place. A single mistyped key is ignored in silence by most tooling. I asked for a one-line change to project.pbxproj and confirmed the refusal before trusting it. On counting up what you have already permitted, I wrote that out separately in Take Inventory of What You Allowed with Don't Ask Again in Claude Code.
This one is not Xcode's fault, but I hit it the same week, so it belongs here.
My sources live under Dropbox. If a build starts right after the project file is saved, the sync client can pick up an intermediate state and leave something like project.pbxproj (conflicted copy) beside it. Xcode still holds the original, so nothing happens that day. It happens days later, when nobody can say which one is current.
# Keep build artifacts out of sync (this attribute is Dropbox-specific)for d in build DerivedData "*.xcodeproj/project.xcworkspace/xcuserdata"; do find . -type d -path "./$d" -print0 2>/dev/null \ | xargs -0 -I{} xattr -w com.dropbox.ignored 1 {}done# Surface any conflicted copies that already existfind . -name "*conflicted copy*" -o -name "*競合コピー*" | sort
I apply the xattr once, right after creating a working directory. It is now part of what I run immediately after cloning a new app repository.
Where a direct edit is still fine
A rule that forbids everything does not survive contact with a deadline. Three cases stay exempt for me.
Anything under xcuserdata and similar paths that git never tracked
Adding a single new source file. The Xcode GUI is faster, and the inventory diff reads as one line
Rescuing a project that is already broken. Getting it to open comes first; shifted meaning can be recovered later by the inventory
Conversely, what I would recommend always routing through a script is any change touching build phases, target membership, or build settings. Those three are precisely where the silent breakage is born.
Where the line sits now
Roughly half a year in, the number of last-minute scrambles before a release has dropped noticeably. Not because the agent got more accurate. Because there is now one mechanism outside the build that notices when something broke.
I have started reasoning the same way about AdMob configuration and store listing metadata — rather than blocking the change, reshape it so a machine can confirm the change landed as intended. Whatever resists that reshaping is what moves out of reach.
If you want one thing to try today, run pbx_inventory.py once and keep the output as before.txt. The next time you touch the project file, having that single page changes what you can see.
Thank you for reading. I am still redrawing this line myself, and I suspect the third exception will narrow further before long.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.