appkit: One Installed Program, and the Xcode Windows It Deletes

The honest origin story is smaller than the engineering: I got tired of remembering asc flags.

Not the hard kind of tired — I could look them up. The kind where every release starts with re-deriving a workflow I’d already invented twice before, because it lived as shell history in three different repos and none of the three agreed with each other. I wanted one thing that was deterministic — the same input produces the same bytes, every time, on every machine — and configurable — the parts that are genuinely one app’s own (a bundle id, a scene name, a caption) live in that app’s repo and nowhere else. Everything in between should be a program I install once and never think about again.

That program is appkit. It used to be a git submodule called .kit, checked into every app repo and invoked as scripts/kit. As of this week it’s a Homebrew formula:

brew tap walkccc/appkit https://github.com/walkccc/appkit
brew install --HEAD appkit

One install, every repo. The difference sounds cosmetic and isn’t — a submodule is a second git history you carry around and a symlink you hope survives a fresh clone. A brew formula is a PATH entry. appkit doctor runs from any subdirectory of any app, the way git status does, because it walks up looking for appkit.json the same way git walks up looking for .git.

The three things a repo owns

Everything else is appkit’s:

appkit.json           platform, locales, scenes, store ids, version
scripts/scenes.sh     what a store screenshot IS for this app
store/                the storyboard, the words, the finished cards

A repo declares those three things and nothing else about the pipeline. No app repo contains a device resolver, a JWT signer, a CoreGraphics compositor, or a copy of .prettierrc that quietly drifted from its siblings. It contains a manifest, one file that says what a screenshot means for this specific app, and the outputs.

The commands are verb noun, spelled out on purpose:

appkit new ios             # scaffold a repo, from nothing
appkit make screenshots    # capture + render, and stop
appkit upload metadata     # the listing text, onto the store
appkit ship                # the binary, onto the store

Not appkit ns, not --publish-shots. appkit help prints the whole list; appkit help upload prints one command’s own page. I stopped needing to remember anything the moment the vocabulary became “what am I trying to do” instead of “what did I name this six months ago.”

Scaffolding a bundle id into a pipeline

Here’s the part that used to eat an afternoon. A new app starts in Xcode — File ▸ New ▸ Project, pick a name, pick a bundle id. Say it’s com.magicparklabs.Tefuda. The moment that project exists, it needs a screenshot pipeline, a listing-text tree in five languages, a .swiftformat that matches every other repo, a pre-commit hook that actually runs both formatters instead of just checking they’re installed, and a manifest tying all of it together. None of that is specific to this app. All of it used to get copied out of whichever sibling repo I had open, which is how a settle floor tuned for one app’s animations, or a locale table with a language this app doesn’t ship, ends up in a fresh project that has nothing to do with either.

Now it’s two commands:

mkdir tefuda-ios && cd tefuda-ios && git init
appkit new ios --name Tefuda --bundle com.magicparklabs.Tefuda --locales "en ja zh-Hant zh-Hans ko"

That writes appkit.json, scripts/scenes.sh, a store/cards.json storyboard whose every caption already speaks all five declared languages, a metadata skeleton under store/metadata/, and then runs appkit sync — which copies in AGENTS.core.md, .prettierrc, .swiftformat, and the pre-commit hook, and points core.hooksPath at it. It refuses to overwrite anything that already exists unless you pass --force, so running it again to add the Android sibling is safe rather than destructive.

What it deliberately does not do is make the Xcode project. That’s still File ▸ New ▸ Project, by hand, with the scheme named exactly what appkit.json says — because appkit never opens, reads, or edits an .xcodeproj. Scaffolding the config around an app and building the app are different jobs, and the line between them is one appkit is careful never to cross.

The part that isn’t in that command — wiring a debug-only screenshot mode into the app, what a scene has to guarantee to be photographable, when to pull a live store listing before writing over it — is the judgement call, not the mechanism, and it lives in /appkit-scaffold, a skill rather than a script. More on that split below.

The if DEBUG that makes a screenshot reproducible

appkit capture photographs the app by launching it like this, once per scene per language:

Tefuda -ScreenshotMode -ScreenshotScene board -AppleLanguages "(ja)"

That’s the entire contract. The app reads those launch arguments, puts itself on exactly that screen with exactly that data, and holds still — this is the if DEBUG { ... } branch every app on appkit carries, compiled out of Release entirely. Seeded data instead of whatever’s in the database that day; anything with repeatForever in it, stilled. appkit doesn’t know what a “board” scene is or what it should contain — scripts/scenes.sh in the app’s own repo owns that — but it owns everything around it: resolving a device, booting one simulator per language and running them in parallel, and the shutter.

The shutter is the piece I’d port to any project, screenshots or not: it doesn’t sleep for a guessed number of seconds and hope. It waits for two byte-identical frames.

appkit capture             device → .screenshots/<language>/<scene>.png    (gitignored)
appkit render              those  → store/screenshots/<locale>/NN-name.png (tracked)
appkit make screenshots    both of the above, and stop

render is a Swift binary — CoreGraphics and CoreText, driven by the JSON storyboard appkit new scaffolded — not a headless browser. Compose the same captures against the same storyboard twice and you get the same bytes twice, which is what makes git status -- store/screenshots an actual answer to “did any screen change.” That’s the whole point of “deterministic”: a screenshot pipeline that produces slightly different pixels on every run isn’t reviewable, it’s noise you learn to ignore, and the day you ignore a real change is the day a stale screen ships.

appkit doctor also catches the specific way this goes silently wrong: every iOS card here is 1242×2688, which files under Apple’s IPHONE_65 display type. asc screenshots upload --device-type is a filter, not a label — a set whose pixels don’t match any declared type uploads zero files and reports success. Doctor checks the storyboard’s own dimensions against appkit.json’s declared types on every run, because I have, more than once, scaffolded IPHONE_69 next to a 1242×2688 storyboard and not found out until a listing quietly kept its old screenshots.

appkit ship: everything between ⌘B and TestFlight

This is the one I actually built the rest of appkit to get to.

The old way to release a build: ⌘B, then Product ▸ Archive, and wait for Xcode’s window to finish doing whatever it does. Then the Organizer opens, and you click Distribute App, and pick App Store Connect, and click through four more screens that ask questions you answered identically the last twenty times, and wait again while it uploads. Then you tab over to a browser, open App Store Connect, click into TestFlight, and refresh the build list every couple of minutes until “Processing” turns into something you can attach to a version. None of that is hard. All of it is a person sitting in front of a spinner, because the tool wants to be watched.

appkit ship is one command:

appkit ship --dry-run    # print the plan, touch nothing
appkit ship              # archive, upload, wait for processing, attach
appkit ship --submit     # …and send it for review

Underneath, it hands the project straight to asc publish appstore:

asc publish appstore \
  --app "$ASC_APP_ID" \
  --project "$project" \
  --scheme "$SCHEME" \
  --configuration Release \
  --version "$version" \
  --archive-path "$ROOT_DIR/.asc/artifacts/$APP_NAME.xcarchive" \
  --ipa-path "$ROOT_DIR/.asc/artifacts/$APP_NAME.ipa" \
  --wait \
  --output table

--wait is doing the part I used to do by refreshing a tab: it blocks until Connect finishes processing the binary, then attaches it. One process, no context switch, no polling by hand. And the build number is resolved against what Connect already holds, not guessed locally — because a number picked on my machine gets rejected as a duplicate only after the archive finishes, which is the slow half of the whole operation. Finding that out at the start instead of the end is most of what “the tool respects my time” means in practice.

The split between appkit ship and appkit ship --submit is deliberate, not decorative. Attaching a build to a version is reversible in the Connect UI — you can swap it before anyone sees it. Submitting for review is not something you take back by clicking around, so it costs one extra word, typed on purpose, rather than being the default behavior of the command that builds.

Android has no Organizer to route around — appkit ship on that platform puts the AAB straight on a track, same verb, same manifest, whichever store appkit.json declares.

Not memorizing asc, deliberately

The metadata side has the same shape. I don’t type into App Store Connect’s text fields by hand, and I don’t hold asc metadata plan/apply syntax in my head either — the day-to-day vocabulary is three verbs:

appkit pull metadata     # what the store actually shows, right now
appkit check metadata    # every field's length, every language's coverage — no network
appkit upload metadata   # send it, every locale, in one run

check counts characters, not bytes, because every CJK locale fails a byte count that en-US sails through, and an app on both stores is checked against the tighter of the two limits before anything goes out — a listing that fits Apple and not Google shouldn’t fail an upload halfway through, having already changed the locales that came before it. pull exists because the tracked tree is only the source of truth if it’s kept current: someone tightens a subtitle from the Connect dashboard during a review, that edit never comes back to the repo, and the next upload silently overwrites it with the older tracked copy. Pulling first is how “what should the listing say” and “what does the listing currently say” stay the same question.

None of that requires knowing asc’s actual flags. That’s the point of wrapping it — the CLI underneath can be exactly as detailed as it needs to be, because I’m not the one holding the details.

The command is the mechanism, the skill is the judgement

Not everything that repeats is a script. Some of it is a decision that depends on context a script doesn’t have — whether a settle floor reaches past an animation’s start, whether this is the release where --submit is safe to run, whether a scene’s caption should be borrowed verbatim from the app’s own UI strings. That’s what the four skills are, linked once per machine by appkit skills into ~/.claude/skills rather than copied into every repo:

SkillJudgement it carries
/appkit-scaffoldwhat a fresh repo needs after appkit new writes the files
/appkit-screenshotswhich of three pipeline stages actually broke, and why a set won’t reproduce
/appkit-metadatastore character limits, keyword rules, when to pull before editing
/appkit-releasethe release order, and which steps are reversible

The command writes files and moves bytes. The skill is what I used to hold in my head and re-derive every release — now it’s read by whatever’s helping me ship that day, and it doesn’t degrade between releases the way my own memory of “what did I do last time” reliably does.

What’s still deliberately by hand

appkit has a short, sharp list of things it refuses to do, and the list is as important as the commands: it never runs xcodebuild or gradlew outside of appkit run, appkit ship, and appkit capture --build — every app’s own AGENTS.md forbids anything else from building. It never creates the Xcode or Android Studio project. It never makes the Google Play app record, because Play has no API for that — the package name is all it ever needs. And the values that make one app look like itself — Spacing.lg, a brand palette, a title card somebody actually illustrated instead of composed — are explicitly not shipped from here; only the shape of where they live is, in one paragraph of AGENTS.core.md.

The bar for what belongs in appkit at all is one sentence, and it’s the whole of CONTRIBUTING.md: is this true of every app, or only true of the one currently open in front of me. Most of the engineering effort, honestly, has gone into deciding what stays out.

What it comes down to

One binary. One manifest per repo. A handful of skills for the parts that need judgement instead of a script. And a release that now looks like this:

appkit make screenshots
appkit upload metadata 1.3.0
appkit upload screenshots 1.3.0
appkit ship --submit

Four lines, none of which I have to remember the internals of, all of which do exactly the same thing on the next app as they did on this one. That was always the ask — not a faster Xcode, just fewer things I have to hold in my own head between releases.