Start a plugin marketplace for Apple platform work

Skills have been living inside one app's repository, which means the next
Xcode project starts without them and a copy is the only way to share.
A marketplace makes them installable once at user scope.

Two kinds of knowledge, handled differently. What Apple ships with Xcode
stays Apple's: this repository carries a script that exports it from the
locally installed Xcode rather than a copy, so nobody redistributes
pre-release material and everyone's guidance matches their own SDK.

What we learned by shipping goes in the skill, and only if it burned us —
the file writes hidden in view bodies, the blobs that froze the main
thread, the watch target that quietly stopped compiling, the test summary
that reads as zero tests when eighty-four passed. Each of those cost real
time twice before being written down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
TomUviandClaude Opus 5 committed 2026-08-16 23:07:08 +02:00
commit 905ad2ce3e
6 files changed
+302

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
{
"name": "pdat-xcode",
"owner": {
"name": "PDAT s.r.o.",
"email": "tomas.uvira@pdat.cz"
},
"metadata": {
"description": "Practices for building Apple platform apps with Claude Code, learned from shipping them."
},
"plugins": [
{
"name": "pdat-swift-practice",
"source": "./pdat-swift-practice",
"description": "Practices for SwiftUI, Core Data, XcodeGen and Swift Testing that come from production incidents rather than documentation.",
"version": "0.1.0",
"author": {
"name": "PDAT s.r.o."
},
"license": "MIT",
"category": "development",
"keywords": ["swift", "swiftui", "ios", "macos", "watchos", "core-data", "xcodegen", "swift-testing"]
}
]
}
+1
View File
@@ -0,0 +1 @@
.DS_Store
+64
View File
@@ -0,0 +1,64 @@
# pdat-xcode
A Claude Code plugin marketplace for Apple platform work.
## What is here
**`pdat-swift-practice`** — one skill, `xcode-practice`. Practices for
SwiftUI, Core Data, XcodeGen and Swift Testing that came out of shipping
apps: work that must not happen in a view `body`, blobs that must stay off
the main thread, derived collections computed once, Keychain access
outside view `init`, keeping every target of a multi-platform project
building, and reading test output correctly.
It deliberately does not restate Apple's own guidance. Where the two
overlap, Apple wins.
**`bin/export-apple-skills.sh`** — Xcode ships its own skills, written by
the framework teams. They are Apple's material, so this repository carries
a script to export them rather than a copy of them. Everyone exports from
the Xcode they have, which keeps the guidance matched to their SDK.
```sh
bin/export-apple-skills.sh .claude/skills
```
Xcode must be running or the export hangs.
## Install
```
/plugin marketplace add <url-of-this-repo>
/plugin install pdat-swift-practice@pdat-xcode
```
Install at user scope and it applies to every project, including ones that
do not exist yet.
To hand it to a team through a repository instead, put this in that
project's `.claude/settings.json`:
```json
{
"extraKnownMarketplaces": {
"pdat-xcode": {
"source": { "source": "git", "url": "<url-of-this-repo>" }
}
},
"enabledPlugins": { "pdat-swift-practice@pdat-xcode": true }
}
```
Anyone who clones that project gets the skill without installing anything.
## Updating
Push a change, bump `version` in both `.claude-plugin/marketplace.json` and
`pdat-swift-practice/.claude-plugin/plugin.json`, then `/plugin marketplace
update` locally. Without the version bump, installs will not notice.
## Adding to the skill
The bar is deliberately high: something that actually went wrong, more than
once, and whose fix is not obvious from the framework documentation. A rule
nobody has been burned by is a rule nobody will follow.
+37
View File
@@ -0,0 +1,37 @@
#!/bin/sh
# Export the skills that ship inside Xcode into a project.
#
# They are Apple's material, not ours, so this repository does not carry a
# copy — everyone exports them from the Xcode they actually have, which
# also means the guidance matches the SDK they are building against.
#
# Usage: bin/export-apple-skills.sh [target-directory] [path-to-Xcode.app]
#
# Requires Xcode to be running; without it mcpbridge waits forever.
set -eu
TARGET="${1:-.claude/skills}"
XCODE="${2:-/Applications/Xcode.app}"
BRIDGE="$XCODE/Contents/Developer/usr/bin/mcpbridge"
if [ ! -x "$BRIDGE" ]; then
echo "mcpbridge not found at $BRIDGE" >&2
echo "Pass the path to your Xcode as the second argument." >&2
echo "Xcode 26.4 and later ship it; on older versions there is nothing to export." >&2
exit 1
fi
if ! pgrep -qx Xcode; then
echo "Xcode does not appear to be running — the export will hang." >&2
echo "Open it, then run this again." >&2
exit 1
fi
mkdir -p "$TARGET"
"$BRIDGE" run-agent skills export "$TARGET"
echo
echo "Exported to $TARGET"
echo "Keep only the ones that apply to the project; the rest just take up context."
echo "Re-run this after an Xcode update — the exported files describe that SDK."
@@ -0,0 +1,14 @@
{
"name": "pdat-swift-practice",
"version": "0.1.0",
"description": "Practices for SwiftUI, Core Data, XcodeGen and Swift Testing that come from production incidents rather than documentation.",
"author": {
"name": "PDAT s.r.o.",
"email": "tomas.uvira@pdat.cz"
},
"license": "MIT",
"keywords": ["swift", "swiftui", "ios", "macos", "watchos", "core-data", "xcodegen", "swift-testing"],
"skills": [
"./skills/xcode-practice"
]
}
@@ -0,0 +1,162 @@
---
name: xcode-practice
description: Practices for SwiftUI, Core Data, XcodeGen and Swift Testing that come from shipped apps rather than documentation — work that must not happen in a view body, blobs that must stay off the main thread, derived collections computed once, Keychain access outside view init, multi-target project hygiene, and reading test output correctly. Use when writing or reviewing SwiftUI views, adding files to an XcodeGen project, or interpreting a test run.
---
# Practices for Apple platform apps
These are not restatements of Apple's guidance. Each one is here because it
caused a freeze, a regression, or a wrong conclusion in a real app, and the
same mistake was made more than once.
Apple's own skills — `swiftui-specialist`, `swiftui-whats-new-27`,
`app-intents-specialist` and the rest that ship with Xcode — are the
authority on framework APIs. This skill covers what they do not: the
failure modes of putting those APIs together. Where they overlap, Apple
wins.
## Nothing that touches the disk, the network or a renderer belongs in `body`
A view's `body` runs far more often than it looks. Every unrelated state
change re-runs it, so anything expensive in there runs on the main thread
at the frequency of redraws.
Three separate instances of this shipped before being caught:
- `ShareLink(item: writeVCardFile())` — wrote a file to disk on every
redraw of a detail screen, because the argument is evaluated when the
view is built, not when the user taps share.
- Serialising a whole record to JSON inside a toolbar `Menu`, twice, to
decide whether two menu items should appear.
- Rendering barcodes with Core Image inside `body`, at up to 12× scale.
The fix is always the same shape: prepare the value in `.task(id:)`, hold
it in `@State`, and let `body` read the finished thing.
```swift
@State private var exportURL: URL?
var body: some View {
List { … }
.task(id: exportSignature) {
let payload = Payload(record) // main actor: reads the model
exportURL = await Task.detached { // off the main thread: writes
payload.writeTemporaryFile()
}.value
}
}
```
Give `.task` an `id` that covers every field the output depends on, or the
file goes stale after an edit.
## Keep external-storage blobs off the main thread
Core Data attributes with external storage (photos, PDFs) hit the file
system when read, deleted, or attached — the property access looks free
and is not. Three separate freezes in one app traced back to this.
Delete through `performBackgroundTask`, build attachments in the
background, write temporary files in a detached task. Never touch a blob
attribute in a `body`, in a `ForEach`, or in a synchronous button action.
## Compute derived collections once per pass
A computed property that sorts or filters runs again at every use, and a
computed property used *inside a closure* runs once per element:
```swift
// O(n² log n): `hero` re-sorts the whole collection for every card
let rest = sortedCards.filter { $0 != hero }
```
Hoist it into a local (`let hero = hero`), or better, build one value that
carries everything the body needs — the sorted list, the highlighted item,
the groups, the filter chips — and compute it once at the top of `body`.
Watch for repeated database fetches hidden inside such properties; a
registry lookup called from three computed properties is three fetches per
redraw.
## Keychain access does not belong in a view's `init`
`State(initialValue: Keychain.load(…))` in an initialiser looks like it
runs once. View initialisers run on every parent redraw, and the value is
discarded every time but the first. Read it in the parent's `.task` and
pass the loaded value down as a plain `let`.
Also: check what the Keychain returned. `SecItemUpdate` and `SecItemAdd`
fail on a locked device (`errSecInteractionNotAllowed`). Ignoring the
status means the person walks away believing a PIN was saved that was not.
Return the status and surface it.
## Prefer `sheet(item:)` over hand-built bindings
`sheet(isPresented: Binding(get:set:))` allocates a closure on every body
evaluation and compares poorly. When two optionals must agree — a share
and its container, say — wrap them in one `Identifiable` value so the
sheet cannot open with half the data.
## Cache expensive renderers, not just their output
`CIContext()` is costly to create; one shared instance serves the whole
app. Rendered images are worth an `NSCache` keyed by their inputs, which
fixes every call site at once instead of restructuring each view.
## XcodeGen: regenerate, then build every target
After adding a file: `xcodegen generate`, or the build fails with "cannot
find in scope".
More important, and easier to miss: targets list their sources
individually. A model type added for the phone app is *not* automatically
available to a watch or widget target that compiles the same shared model
file. A watch app sat broken for several commits this way, because only
the iOS and macOS builds were being checked.
Build every target after a change to shared model code, not just the one
being worked on.
## Read the right test summary
With Swift Testing, `xcodebuild`'s familiar line
```
Executed 0 tests, with 0 failures
```
is the XCTest counter and says nothing about Swift Testing suites. Theirs
looks like:
```
✔ Test run with 84 tests in 14 suites passed after 0.809 seconds.
```
Reaching a conclusion from the wrong line means declaring a whole suite
dead when it is passing. Also: do not pipe a test run through `tail` or a
narrow `grep` and then diagnose from what survived — filter the saved log
afterwards instead.
## Core Data in tests
Build objects through the context's own model rather than
`NSManagedObject.entity()`; with more than one coordinator alive, the
latter returns an entity from the wrong store and the object silently
loses its defaults. Keep the context alive for the whole test — a context
that deallocates mid-test takes its objects with it.
## Default main actor isolation
In a project that defaults to main actor isolation, plain data types need
`nonisolated` explicitly: `Codable` structs crossing to background work,
`ActivityAttributes` conformances handed to ActivityKit, enums used from
detached tasks. Swift 6 rejects using a main-actor-isolated conformance
from a nonisolated async context, so this surfaces as a build failure
against a new SDK rather than as a bug.
## Small things that cost an afternoon each
- `navigationDestination` belongs on the root of a stack, not inside rows.
- One `CLMonitor` instance per name; a second one crashes with "already in
use".
- Anchor `confirmationDialog` on the control that triggered it, or iPad
shows a popover floating in the middle of the screen.