https://github.com/ciscoriordan/storescreens-cli
Capture App Store screenshots for iOS and macOS apps across every required device size in one command. Supports iPhone, iPad, Apple Watch, and Mac.
https://github.com/ciscoriordan/storescreens-cli
app-store cli ios macos mcp screenshots swift xcode
Last synced: 25 days ago
JSON representation
Capture App Store screenshots for iOS and macOS apps across every required device size in one command. Supports iPhone, iPad, Apple Watch, and Mac.
- Host: GitHub
- URL: https://github.com/ciscoriordan/storescreens-cli
- Owner: ciscoriordan
- License: mit
- Created: 2026-02-07T22:43:13.000Z (4 months ago)
- Default Branch: main
- Last Pushed: 2026-05-11T03:45:24.000Z (about 1 month ago)
- Last Synced: 2026-05-11T03:58:29.087Z (about 1 month ago)
- Topics: app-store, cli, ios, macos, mcp, screenshots, swift, xcode
- Language: Swift
- Homepage: https://storescreens.app
- Size: 22.9 MB
- Stars: 18
- Watchers: 0
- Forks: 1
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README

# StoreScreens
Every App Store Connect API call. Granular, agentic screenshot rendering.
StoreScreens is a `brew`-installed Swift MCP/CLI (and MD Skill) that drives the entire App Store Connect pipeline from one config file: XCUITest screenshots, framed renders with markdown captions (optionally unique per device/locale), metadata and binary upload via Apple's official API. Beautiful, modern bezels, no Ruby version hell.
Captures run your UI tests on multiple simulators in parallel (or natively on macOS), organize the output by device and locale, and auto-detect which App Store size each simulator maps to. Supports iPhone, iPad, Apple Watch, and Mac App Store screenshots.
## Three pieces, one workflow
StoreScreens ships as three complementary pieces. Most users only need the CLI; the other two exist to make AI coding assistants first-class operators.
| Piece | What it is | When you want it |
|---|---|---|
| `storescreens` (CLI) | The core binary. Runs UI tests across simulators, captures screenshots, builds the HTML preview gallery. | Always - this is the engine. Use it from your terminal, CI, or scripts. |
| `storescreens-mcp` (MCP server) | A structured wrapper that exposes the CLI's operations as [Model Context Protocol](https://modelcontextprotocol.io) tools with inline progress streaming. | When your AI coding assistant (Claude Code, Cursor, etc.) should drive captures directly instead of parsing CLI output from a Bash call. |
| [storescreens-skill](https://github.com/ciscoriordan/storescreens-skill) | An agent skill - instructions and templates that teach an assistant how to detect your Xcode project, generate config, scaffold UI tests, and run a capture. | When you want an assistant to do the full setup for you, from zero to first screenshots, with no manual steps. Works with any assistant that supports skills. |
Both the CLI and MCP server are installed by `brew install storescreens`.
### How this compares to other Xcode MCP servers
StoreScreens is purpose-built for one job: generating the complete set of App Store Connect screenshots. It is not a general Xcode control surface, and it is not competing with the general-purpose Xcode MCP servers, it complements them.
| Tool | Scope | Best for | App Store screenshot output |
|---|---|---|---|
| `storescreens` | Narrow. App Store screenshot capture, device-size routing, locale and appearance matrix, HTML preview gallery. | Producing the final screenshot set for App Store Connect in one command. | Yes. Named, organized by device and locale, ready to upload. |
| [Apple Xcode MCP](https://developer.apple.com/documentation/xcode/giving-agentic-coding-tools-access-to-xcode) (built into Xcode 26.3+) | Xcode-resident tools. Most notably `RenderPreview` for a single SwiftUI `#Preview`. | Checking one view's layout without spinning up a simulator. | No. |
| [XcodeBuildMCP](https://github.com/getsentry/XcodeBuildMCP) | General iOS/macOS build, test, and device interaction driven by `xcodebuild`. | Letting an agent compile, test, and debug iOS/macOS projects through a unified MCP interface. | No. |
| [xc-mcp](https://github.com/conorluddy/xc-mcp) | 29 tools covering build, simulator lifecycle, and accessibility-first UI automation. Optimized for low-context agent interactions. | Agents that need to drive the simulator via semantic accessibility queries (fast, token-cheap) instead of parsing screenshots. | No, its screenshot tool is for a single capture, not a full App Store matrix. |
If you are shipping an app, you will likely use StoreScreens alongside one of the general servers: the general server handles build and run, StoreScreens handles the screenshot matrix at the end.
Each run produces a browsable HTML preview with per-device galleries:



When the MCP server is configured, the agent streams per-screenshot progress inline as each device captures:


## Install
Requires macOS 14+ (Sonoma or later) on Apple Silicon (arm64). Intel Macs are not supported.
### Homebrew
```bash
brew tap ciscoriordan/tap
brew install storescreens
```
### Script
```bash
curl -fsSL https://raw.githubusercontent.com/ciscoriordan/storescreens-cli/main/install.sh | sh
```
### From source
Requires Xcode 16+.
```bash
git clone https://github.com/ciscoriordan/storescreens-cli.git
cd storescreens-cli
swift build -c release
sudo cp .build/release/storescreens-cli /usr/local/bin/storescreens
```
Verify the install worked:
```bash
storescreens --help
```
## Quick Start
```bash
cd /path/to/your/xcode-project
# 1. Generate a config file
storescreens init
# 2. Generate screenshot UI tests
storescreens setup
# 3. Open the generated test file and add your app navigation (see below)
open MyAppUITests/ScreenshotTests.swift
# 4. Capture screenshots on all devices (--verbose for live output)
storescreens capture --verbose
```
### How it works
The CLI uses XCUITest - Apple's built-in UI testing framework - to drive your app and capture screenshots. A UI test launches your app in a simulator, taps through screens programmatically, and saves screenshots at each step. The CLI then runs that test across every device size in your config.
#### Do I need a UI test target?
Yes. If your project doesn't have one yet, add it in Xcode:
1. File > New > Target
2. Select UI Testing Bundle
3. Name it something like `MyAppUITests`
4. Make sure it's targeting your app
`storescreens setup` will detect the target automatically. If none exists, it prints these steps for you.
#### Using a manually written test file
If you wrote your `ScreenshotTests.swift` by hand (rather than generating it with `storescreens setup`), the target setup requires one extra step because Xcode auto-creates a placeholder test file when you add the target:
1. File → New → Target → UI Testing Bundle
2. Name it to match your `test_target` in `storescreens.yml` (e.g. `ExampleUITests`)
3. Set Target to be Tested to your app
4. Click Finish - Xcode creates the target with a default `ExampleUITestsLaunchTests.swift` placeholder
5. Delete the placeholder file Xcode generated (move to Trash)
6. Right-click your UI test group in the Project Navigator → Add Files to "[project]"
7. Select your `ScreenshotTests.swift` and confirm it is added to the `ExampleUITests` target
8. In `storescreens.yml`, set `test_target` and `test_class` to match:
```yaml
test_target: ExampleUITests
test_class: ScreenshotTests
```
Then verify everything builds before running the full capture. Pipe the output to a log file so you can inspect errors:
```bash
# Confirm the test target builds and the test is discoverable
xcodebuild build-for-testing \
-workspace Example.xcworkspace \
-scheme Example \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
2>&1 | tee build.log
# Then capture (--verbose for live terminal output; logs are always saved)
storescreens capture --verbose
```
#### The generated test file
`storescreens setup` asks you to name the screens you want to capture, then generates a test file:
```swift
// MyAppUITests/ScreenshotTests.swift
import XCTest
class ScreenshotTests: XCTestCase {
func testScreenshots() {
let app = XCUIApplication()
app.launch()
takeScreenshot(named: "Home")
// TODO: Navigate to Search Results
takeScreenshot(named: "SearchResults")
// TODO: Navigate to Detail
takeScreenshot(named: "Detail")
// TODO: Navigate to Settings
takeScreenshot(named: "Settings")
}
func takeScreenshot(named name: String) {
let screenshot = XCUIScreen.main.screenshot()
let attachment = XCTAttachment(screenshot: screenshot)
attachment.name = name
attachment.lifetime = .keepAlways
add(attachment)
}
}
```
The first screenshot captures whatever's on screen right after launch. Each `// TODO` is where you add code to navigate to the next screen.
Name screenshots with meaningful identifiers (`Home`, `Search`, `Detail`) and let the `screenshots:` list in `storescreens.yml` drive display order. After capture, storescreens stamps each output PNG's mtime and creationDate in that order, so `ls -t` and Finder's "Date Created" sort match the order you configured. No numeric prefixes needed.
#### Adding navigation
Replace each TODO with XCUITest calls that interact with your app's UI. Common patterns:
```swift
// Tap a tab bar button
app.tabBars.buttons["Search"].tap()
// Tap a navigation link or button
app.buttons["Settings"].tap()
// Tap a list row
app.cells["My Profile"].tap()
// Type into a search field
app.searchFields.firstMatch.tap()
app.typeText("recipes")
// Scroll down
app.swipeUp()
// Wait for content to load
let element = app.staticTexts["Welcome"]
_ = element.waitForExistence(timeout: 5)
```
A complete example:
```swift
func testScreenshots() {
let app = XCUIApplication()
app.launch()
// Home screen - shown right after launch
takeScreenshot(named: "Home")
// Search - tap the search tab, enter a query
app.tabBars.buttons["Search"].tap()
app.searchFields.firstMatch.tap()
app.typeText("recipes")
takeScreenshot(named: "Search")
// Detail - tap a result
app.cells.firstMatch.tap()
takeScreenshot(named: "Detail")
// Settings - go back, open settings
app.navigationBars.buttons.firstMatch.tap()
app.tabBars.buttons["Settings"].tap()
takeScreenshot(named: "Settings")
}
```
You can test your navigation works before running the full capture - just run the test in Xcode with Cmd+U or click the diamond next to the test function.
#### Accessibility identifiers
UI tests find elements by accessibility identifier, text label, or type. Always prefer `.accessibilityIdentifier()` over matching by text, since text labels can appear in multiple places (e.g. your app name on both the launch screen and the main toolbar), causing tests to match the wrong element or pass prematurely.
Add identifiers to your SwiftUI views:
```swift
// Buttons and interactive elements
Button("Save") { ... }
.accessibilityIdentifier("saveButton")
// Loading indicators - so tests can wait for loading to finish
ProgressView()
.accessibilityIdentifier("loadingIndicator")
// Content containers - so tests can wait for content to appear
ScrollView { ... }
.accessibilityIdentifier("mainContent")
// Toolbar items
ToolbarItem(placement: .topBarLeading) {
Button { ... } label: { Image(systemName: "gear") }
.accessibilityIdentifier("settingsButton")
}
// Search fields
TextField("Search", text: $query)
.accessibilityIdentifier("searchField")
```
Then in your test, wait for elements by identifier instead of using `sleep()`:
```swift
// Bad: fragile timing, screenshots may capture loading spinners
sleep(5)
takeScreenshot(named: "Home")
// Good: waits for actual content to load
waitForElement(id: "mainContent", timeout: 15)
takeScreenshot(named: "Home")
```
The generated `waitForElement()` helper searches all element types by accessibility identifier, so it works for buttons, text, scroll views, or any other element.
Common pitfall: If your app name (e.g. "MyApp") appears as `Text("MyApp")` in both `LaunchScreen.swift` and your main view's toolbar, a test like `app.staticTexts["MyApp"].waitForExistence(timeout: 10)` will match the launch screen text and proceed before your main content loads. Use a unique identifier instead:
```swift
// In your main view:
Text("MyApp")
.accessibilityIdentifier("mainTitle")
// In your test:
app.staticTexts["mainTitle"].waitForExistence(timeout: 10)
```
#### How screenshots are saved
By default, screenshots are collected from the filesystem. Your test code writes PNGs directly to a cache directory, and the CLI copies them to the output folder after the test finishes.
The generated `takeScreenshot()` helper does two things:
1. Creates an `XCTAttachment` (stored in the `.xcresult` bundle as a backup)
2. Writes a PNG file to the StoreScreens cache directory on the host filesystem
The CLI reads the cache directory from a breadcrumb file at `~/.storescreens-cache-dir`, which it writes before each capture run. Your test code discovers this path using `SIMULATOR_HOST_HOME`:
```swift
let hostHome = ProcessInfo.processInfo.environment["SIMULATOR_HOST_HOME"]
?? ProcessInfo.processInfo.environment["HOME"]
?? NSHomeDirectory()
let breadcrumb = (hostHome as NSString).appendingPathComponent(".storescreens-cache-dir")
let cacheDir = try? String(contentsOfFile: breadcrumb, encoding: .utf8)
.trimmingCharacters(in: .whitespacesAndNewlines)
```
Intermediate screenshots and named pipes for real-time logging are stored in `.storescreens-cache/` in your project directory. Add it to `.gitignore`:
```
.storescreens-cache
```
##### Why filesystem over xcresult?
Filesystem capture is the primary path because it gives you things xcresult export can't:
- Streaming progress - PNGs land one-by-one as the test runs, so the MCP server streams per-screenshot updates to your AI assistant. `xcresulttool export` only runs after the entire test finishes, so progress is all-or-nothing.
- Incremental safety - if the test crashes partway through, you still get every screenshot captured before the crash.
- Deterministic filenames - you pick the name. `xcresulttool` appends `_N_UUID.png` to every attachment, which has to be regex-stripped back to the original name.
- No silent skip rules - `xcresulttool` silently drops attachments whose names start with `Screenshot`, `UI Snapshot`, `Synthesized Event`, `Screen Recording`, and several others. Filesystem writes are always kept, no matter what you name them.
- Faster - no post-processing step after the test finishes.
The tradeoff: filesystem capture only works on simulators, because it relies on `SIMULATOR_HOST_HOME` to cross the sandbox boundary back to your Mac. App Store screenshots are simulator-only anyway, so this rarely matters.
If you need to capture on real devices, or you want attachments visible in Xcode's built-in test report UI, pass `--xcresult` instead:
```bash
storescreens capture --xcresult
```
#### Screenshot mode in your app
Your app can detect when it's being run by StoreScreens and adjust its behavior accordingly. The generated test file launches your app with `--screenshotMode` as a launch argument:
```swift
app.launchArguments = ["--screenshotMode"]
```
Check for this in your app to set up the ideal state for screenshots:
```swift
// In your root view or app entry point
.task {
if ProcessInfo.processInfo.arguments.contains("--screenshotMode") {
// Grant pro/premium access so screenshots show full features
settings.isProUser = true
// Disable animations for faster, deterministic screenshots
UIView.setAnimationsEnabled(false)
// Reset any persisted UI state (e.g., expansion toggles, onboarding)
UserDefaults.standard.set("", forKey: "expandedSections")
}
}
```
Common things to configure in screenshot mode:
- Simulate pro/premium access - show the full app without paywalls. Make sure your entitlement checks don't override this (skip StoreKit verification in screenshot mode).
- Disable animations - makes UI interactions instant and screenshots deterministic.
- Reset UI state - clear persisted toggles, expansion states, or onboarding flags so tests always start from a known state.
- Seed sample data - pre-populate the app with good-looking content if it would otherwise be empty on first launch.
#### Simple mode (no tests needed)
If you don't want to write UI tests, use simple mode instead:
```bash
storescreens capture --mode simple
```
This boots each simulator, installs and launches your app, and takes a single screenshot of whatever's on screen. Good for capturing your launch screen or a static state.
## Commands
| Command | Description |
|---------|-------------|
| `storescreens init` | Generate a `storescreens.yml` config file |
| `storescreens setup` | Set up screenshot UI tests (interactive wizard) |
| `storescreens capture` | Capture screenshots on all configured devices |
| `storescreens check` | Scan source for iPad-unsafe patterns and device assumptions |
| `storescreens list` | Show available simulators and App Store size mappings |
| `storescreens screenshot` | Take a quick screenshot of a running simulator |
| `storescreens render` | Render captioned/framed screenshots from an existing capture |
| `storescreens search-preview` | Render an iPhone App Store search-result preview (icon + name + subtitle + stars + 3 screenshots) in light/dark |
| `storescreens templates` | List the built-in render templates (curated background + type + chrome presets) |
| `storescreens bezels` | Import / inspect Apple device bezel assets used by `render` |
| `storescreens auth` | Manage App Store Connect API credentials |
| `storescreens metadata init` | Scaffold `metadata//*.txt` files + README |
| `storescreens submit` | Upload rendered screenshots + metadata to App Store Connect |
| `storescreens upload-build` | Archive, export, and upload the `.ipa` to App Store Connect / TestFlight |
| `storescreens status` | Show current ASC state: versions and any in-flight review submission |
| `storescreens testflight ...` | TestFlight: beta groups, testers, builds, beta-app/build-localizations, beta-review, license-agreement, tester-metrics |
| `storescreens iap ...` | In-App Purchases (V2): products, localizations, pricing, submissions, content-hosting, images, promoted purchases |
| `storescreens subscriptions ...` | Auto-renewing subscriptions: groups, products, prices, offer codes, promotional offers, availability, submissions |
| `storescreens reviews ...` | Customer reviews: list with filters, get, respond (create/update/delete) |
| `storescreens reports ...` | Sales (TSV), finance (CSV), analytics report requests/instances/segments, perf-power metrics, diagnostic signatures |
| `storescreens users ...` | Team users, invitations, user-visible-apps |
| `storescreens devportal ...` | Developer Portal: certificates, profiles, devices, bundle IDs + capabilities |
| `storescreens previews / app-clips / cpp / events / experiments / encryption-decl / routing-coverage` | App Previews, App Clips, Custom Product Pages, in-app App Events, A/B Version Experiments, App Encryption Declarations, Routing App Coverage |
| `storescreens game-center ...` | Game Center: achievements, leaderboards (+ sets + members), matchmaking, app versions, groups |
| `storescreens xcode-cloud ...` | Xcode Cloud (CI/CD): products, workflows, build runs (start/cancel/retry), actions, artifacts, issues, test results, SCM repositories |
| `storescreens alt-dist ...` | Alternative Distribution (EU DMA): keys, packages, package versions/deltas/variants, domains, marketplace search + webhooks |
| `storescreens apple-pay ...` | Apple Pay: pass type IDs + certificates (from CSR), merchant domains |
| `storescreens sandbox / resource-limits / diagnostic-sessions` | Sandbox testers, team resource quotas, Xcode Instruments diagnostic sessions |
| `storescreens webhooks ...` | General-purpose ASC webhooks: subscribe to build/review/availability events, list deliveries, resend, health-ping |
| `storescreens build-uploads ...` | API-native .ipa upload (alternative to altool): buildUploads + buildUploadFiles, chunked PUT, high-level `upload-ipa` convenience |
| `storescreens accessibility ...` | Accessibility Nutrition Labels: per-device-family declaration of VoiceOver / captions / contrast / motion / etc. support |
| `storescreens background-assets ...` | Background Assets: large post-install asset download (200GB/app); upload chunks, version + state per build channel |
| `storescreens version-release ...` | Version release control: phased releases, promo carousels, manual release requests, end-of-pre-order |
| `storescreens game-center-v2 ...` | Game Center Activities + Challenges (+ images + localizations + versions), V2 versioning for achievements / leaderboards / sets, sandbox-only score and achievement submissions |
| `storescreens beta-feedback / beta-recruitment / beta-app-clip ...` | Modern TestFlight: feedback crash + screenshot submissions, beta crash logs, automatic-recruitment criteria, App Clip invocation configs |
| `storescreens iap-offer-codes ...` | One-time-IAP offer codes (custom + one-time-use variants); distinct from subscription offer codes covered by `subscriptions` |
| `storescreens subs-extras / review-extras / asc-extras` | Subscription intro / win-back offers / grace periods / group submissions, review summarizations + attachments, plus merchant IDs / nominations / app tags / EULAs / Android→iOS mapping / actors / app price points V3 / etc. |
| `storescreens wall submit` | Submit your app to the storescreens.app Wall of Apps |
| `storescreens --help` | Show help and available commands |
### `storescreens init`
Generates a `storescreens.yml` config file by auto-detecting your project:
- Finds your `.xcodeproj` or `.xcworkspace`
- Detects your scheme and deployment target
- Picks simulators that match required App Store sizes and are compatible with your deployment target
- Warns if any required sizes are missing
```bash
storescreens init # generate config
storescreens init --force # overwrite existing config
```
### `storescreens setup`
Interactive wizard that generates a screenshot test file and wires it into your config. It scans your Swift source to auto-discover screens from `TabView`, `NavigationLink`, `.sheet`, `.fullScreenCover`, and [Navigator](https://github.com/hmlongco/Navigator) route patterns.
```
$ storescreens setup
Project Detection
✓ Found project: MyApp.xcodeproj
✓ Detected scheme: MyApp
UI Test Target
✓ Found UI test target: MyAppUITests
Screenshot Screens
Found 4 screens in your source code:
1. Home (TabView)
2. Search (TabView)
3. Settings (NavigationLink)
4. Profile (sheet)
Press Enter to use these, or type your own (comma-separated)
>
✓ Using discovered screens.
✓ Wrote MyAppUITests/ScreenshotTests.swift (4 screenshots)
✓ Updated storescreens.yml
```
If no screens are found in your source, it falls back to asking you to type them manually. If no UI test target exists, it prints step-by-step instructions to create one in Xcode.
Use `--non-interactive` to skip prompts and use auto-discovered screens (or defaults if none found).
### `storescreens capture`
Captures screenshots using one of two modes.
XCTest mode (default) - runs your UI tests, collects screenshots written to the filesystem by your test code. Use `--verbose` to see full xcodebuild output in the terminal (logs are always saved to the output directory either way):
```bash
storescreens capture --verbose
```
How it works:
1. Runs `xcodebuild test` on each target simulator
2. Your test code writes screenshots as PNGs to a shared cache directory
3. The CLI collects PNGs from the cache and organizes them into folders by device size
Simple mode - boots each simulator, installs your app, and takes a raw screenshot of whatever's on screen:
```bash
storescreens capture --mode simple
```
Useful if you don't have UI tests and just want a quick capture of your launch screen.
Options:
| Flag | Description |
|------|-------------|
| `--mode xctest\|simple` | Capture mode (default: `xctest`) |
| `--config PATH` | Config file path (default: `storescreens.yml`) |
| `--output DIR` | Override output directory |
| `--locale LOCALE` | Override locales (repeatable) |
| `--retries N` | Retry failed test runs per device |
| `--keep-alive` | Keep simulators running after capture |
| `--xcresult` | Extract screenshots from `.xcresult` bundle instead of filesystem |
| `--only PREFIXES` | Only capture screenshots matching these prefixes (comma-separated) |
| `--skip-check` | Skip preflight source code check |
| `--no-render` | Skip the post-capture render pass even if `render.enabled: true` is set in config |
| `--verbose` | Stream full xcodebuild output to terminal (logs are always saved to `logs/`) |
### `storescreens check`
Scans your Swift source files for patterns that can crash or break on iPad and other device-specific assumptions. Runs automatically before every `storescreens capture` unless disabled.
```
$ storescreens check
Preflight Check
iPad detected in config - running iPad-specific checks
✗ Views/ContentView.swift:47 [toolbar-tabbar-hidden]
.toolbarVisibility(.hidden, for: .tabBar) without iPad guard - may crash on iPad
! Views/CardView.swift:89 [hardcoded-screen-dimensions]
Possible hardcoded iPhone screen dimension (390) - use GeometryReader instead
1 error, 1 warning found
Errors block capture. Use --skip-check to bypass.
```
Detection rules:
| Rule | Severity | What it catches |
|------|----------|-----------------|
| `toolbar-tabbar-hidden` | Error | `.toolbarVisibility(.hidden, for: .tabBar)` without an iPad device check - can crash on iPad |
| `unguarded-cloudkit` | Error | `CKContainer`/`CKDatabase` usage without an `accountStatus` check, error handling, or UI-testing guard - crashes in simulator without iCloud account |
| `uiscreen-main-bounds` | Warning | `UIScreen.main.bounds` - deprecated, doesn't handle iPad split view or multiple scenes |
| `hardcoded-screen-dimensions` | Warning | Literal iPhone screen sizes (390, 844, etc.) used in layout context |
| `navigation-view-stack` | Warning | `.navigationViewStyle(.stack)` forces stack navigation on iPad |
iPad-specific rules only fire when an iPad is in your configured device list. The scanner is guard-aware - it won't flag `.toolbarVisibility(.hidden, for: .tabBar)` if it finds a `UIDevice` / `userInterfaceIdiom` check in the surrounding lines, and it won't flag CloudKit usage if the file contains an `isUITesting` guard, `accountStatus` check, `try`/`catch`, or `screenshotMode` launch argument check.
| Flag | Description |
|------|-------------|
| `--config PATH` | Config file path (default: `storescreens.yml`) |
| `--directory DIR` | Directory to scan (default: `.`) |
| `--verbose` | Show verbose output |
### `storescreens list`
Shows available simulators and which App Store size they map to. By default, only shows devices that match a known App Store size (excludes Apple Watch).
```
$ storescreens list
Available Simulators
Name State App Store Size
──────────────────────────────────────────────────
iPad Pro 13-inch (M5) Shutdown iPad Pro 13"
iPhone 17 Pro Max Shutdown iPhone 6.9"
iPhone 17 Pro Shutdown iPhone 6.3"
iPhone 16 Plus Shutdown iPhone 6.7"
...
```
| Flag | Description |
|------|-------------|
| `--all` | Show all simulators, including non-App Store sizes |
| `--include-watch` | Include Apple Watch simulators |
| `--include-mac` | Show Mac App Store screenshot sizes |
| `--json` | Machine-readable output |
### `storescreens screenshot`
Takes a quick screenshot of a running simulator's current screen. No build, no tests - just captures whatever is on screen and saves it to a file. Intended for quick visual checks during UI development.
```bash
# Screenshot the first booted simulator
storescreens screenshot
# Screenshot a specific simulator
storescreens screenshot --simulator "iPhone 17 Pro" --output screenshot.png
# Boot the simulator if it's not running
storescreens screenshot --simulator "iPhone 17 Pro" --boot
```
| Flag | Description |
|------|-------------|
| `--simulator NAME` | Simulator name (default: first booted simulator) |
| `--udid UDID` | Simulator UDID (alternative to `--simulator`) |
| `--output PATH` | Output file path (default: `screenshot.png`) |
| `--boot` | Boot the simulator if it's not already running |
| `--verbose` | Show verbose output |
## Configuration
`storescreens.yml`:
```yaml
project: "MyApp.xcodeproj"
scheme: "MyApp"
devices:
- simulator: "iPhone 17 Pro Max"
- simulator: "iPhone 17 Pro"
- simulator: "iPad Pro 13-inch (M5)"
# macOS devices run tests natively (no simulator)
# - simulator: "Mac 2560x1600"
# platform: macOS
# Multiple locales (optional) - runs the full capture once per locale
# locales:
# - en-US
# - ja
# - de-DE
output_dir: "./storescreens-output"
# XCTest mode: which tests to run
test_target: MyAppUITests
test_class: ScreenshotTests
# Preflight source code check (default: true)
# preflight: false
```
All values can be overridden via CLI flags.
Full config reference
```yaml
# Project or workspace (one required)
project: "MyApp.xcodeproj"
# workspace: "MyApp.xcworkspace"
scheme: "MyApp"
devices:
- simulator: "iPhone 17 Pro Max"
- simulator: "iPhone 17 Pro"
- simulator: "iPad Pro 13-inch (M5)"
# macOS: tests run natively, no simulator needed
# - simulator: "Mac 2560x1600"
# platform: macOS
# Per-device test selection: restrict a device to specific test methods,
# overriding the top-level test_class. Useful for iPad-only or iPhone-only
# screenshots that render poorly on the other form factor.
# - simulator: "iPad Pro 13-inch (M5)"
# tests:
# - testLandscapePolytonic # shorthand, expanded to test_target/test_class/method
# - LandscapeTests/testFoo # class-qualified, expanded to test_target/LandscapeTests/testFoo
# - MyAppUITests/Other/testBar # fully qualified, passed through verbatim
# Locales - runs full capture once per locale
locales:
- en-US
- ja
- de-DE
# Custom flags for the HTML preview gallery (optional).
# Keys are Xcode locale codes. Values are either:
# - A filename (without .svg) from ciscoriordan/svg-flags/circle/languages/
# - A full https:// URL, used as-is
# Merged with built-in defaults; your values win on collisions.
# locale_flags:
# en-IN: in-en
# hi: in-hi
# custom: https://example.com/my-flag.svg
# Display order for App Store Connect. Drives render order, HTML preview
# gallery order, and the mtime stamp on captured PNGs so `ls -t` / Finder
# "Date Created" sort matches this list. Also acts as a filter: only
# screenshots whose name appears here are kept.
# screenshots:
# - "Home"
# - "Search"
# - "Detail"
output_dir: "./storescreens-output"
# Run history: 1 = overwrite (default), 0 = keep all, N = keep last N
# keep_runs: 1
# XCTest mode
test_target: MyAppUITests
test_class: ScreenshotTests
# Simple mode: launch arguments (not supported for macOS devices)
# launch_arguments:
# - "--uitesting"
# - "--reset-state"
# Preflight source code check before capture (default: true)
# Scans for iPad-unsafe patterns. Use --skip-check to bypass per-run.
# preflight: true
# Upload after capture (default: false)
# upload: true
# Advanced: run the test suite twice per device (discard first, capture
# second). Useful when the app needs one full launch to finish seeding
# data (CloudKit, ODR, etc.). Default: false.
# warmup_run: true
# Advanced: override the simulator status bar (9:41 AM, full signal, full
# battery) for clean screenshots. Default: true. Set to false to leave
# the live status bar alone.
# status_bar: false
# Advanced: custom args passed to `xcrun simctl status_bar override` when
# status_bar is true. Default covers time, cellular mode, battery, and
# operator; override when a specific screenshot needs different values.
# status_bar_arguments: "--time 9:41 --batteryLevel 100"
# Advanced: auto-dismiss system alerts (App Store review prompts, etc.)
# during tests. Default: true.
# dismiss_system_alerts: false
# Advanced: log verbosity. "quiet" (errors/warnings only), "normal"
# (default), or "verbose" (full xcodebuild output).
# log_level: verbose
# Advanced: persistent DerivedData directory for faster incremental
# builds. When unset, a per-run temp dir is used and cleaned up after.
# derived_data_path: .derivedData
# Advanced: keep older `preview_*.html` pages on the gallery index under
# a "From older runs" heading. Default: false (a fresh capture wipes
# previews whose device/appearance isn't in the current run).
# keep_old_previews: true
# Render pass (optional): composites captioned images from captures
# render:
# enabled: true
# output_dir: ./storescreens-framed
# caption:
# title:
# font: system
# weight: bold
# font_size_pct: 5.5
# color: "#ffffff"
# min_height_pct: 22
# chrome:
# style: bezel
# slides:
# "Home":
# caption: "Your recipes, organized."
# App Store search preview (optional): renders a faithful iPhone search
# result row + iPhone bezel + status bar, sourced from your metadata files
# and captured assets. Useful for confirming how the app shows up in App
# Store search before you ship.
# search_preview:
# enabled: true
# output_dir: ./storescreens-search-preview
# appearances: [light]
# developer: "Acme Co"
# rating: 4.8
# reviews: "1.2K"
```
## Rendering captioned screenshots
`storescreens` can post-process captured screenshots into framed, captioned images suitable for App Store Connect uploads. Add a `render:` block to `storescreens.yml` and run `storescreens render` (or let it run automatically after `storescreens capture`).
### Quick start
```yaml
render:
enabled: true
output_dir: ./storescreens-framed
background:
color: "#1a1a2e"
caption:
title:
font: system
weight: bold
font_size_pct: 5.5
color: "#ffffff"
min_height_pct: 22
chrome:
style: stroke
stroke_color: "#ffffff"
stroke_width: 3
slides:
"Home":
caption: "Your recipes, organized."
"Search":
caption:
- Find anything
- in *seconds*.
"Detail":
caption:
title: Every **detail**, at a glance.
subtitle: Powered by AI
highlights:
- { match: detail, color: "#feb909", weight: heavy }
```
Run the render independently:
```bash
storescreens render
```
Or run capture with the render pass included (auto-enabled by `render.enabled: true`):
```bash
storescreens capture
```
Use `--no-render` to skip the render pass on a given capture run.
### Templates
Skip the hand-tuning with a named template: a curated palette, typography, and background pattern bundle. Add `template: ` under `render:` and every field that a template provides becomes a default (your own explicit fields still win).
```yaml
render:
enabled: true
template: sahara # see `storescreens templates` for the list
slides:
"Home":
caption: "Your adventure, planned."
```
Or try one on an existing capture without editing the config:
```bash
storescreens render --template midnight
```
List what's available:
```bash
storescreens templates
```
| | ID | Look | Best for |
|---|---|---|---|
|
| `ascent` | Cream paper with topographic contours | Outdoor, fitness, health |
|
| `all_the_wiser` | Warm cream with playful scattered shapes | Education, kids, language |
|
| `ethereal` | Warm taupe gradient, soft serif | Wellness, meditation, lifestyle |
|
| `sahara` | Sand-to-terracotta gradient with dune layers | Travel, adventure |
|
| `midnight` | Deep charcoal with champagne accent text | Premium, entertainment, nightlife |
|
| `pinecrest` | Forest moss gradient with cream type | Games, health, lifestyle |
|
| `blueprint` | Pale drafting paper with a grid | Developer tools, productivity |
|
| `sunset_blvd` | Bold four-stop sunset gradient, display type | Entertainment, lifestyle, social |
|
| `jazz_and_wine` | Deep bordeaux with elegant cream serif | Food, drink, hospitality, creative |
The showcase PNGs above are rendered by `TemplateShowcaseTests.testRegenerateShowcaseAssets`. Regenerate with `STORESCREENS_WRITE_SHOWCASE=1 swift test --filter TemplateShowcaseTests` after tweaking any template. The white rounded-rect frame in each thumbnail is `chrome.style: stroke` (a CoreGraphics outline), not a real Apple bezel PSD, so regenerating doesn't require the Apple Design Resources DMGs to be installed. When you apply a template to your own captures, each defaults to `chrome.style: bezel` and uses real silver/titanium bezels once you've run `storescreens bezels import`.
#### Template credits
The nine built-in templates are clean-room reproductions of the *visual direction* (palette, typography mood, pattern concept, target app category) of the free templates in [ButterKit](https://butterkit.app/templates/), which are [MIT-licensed](https://butterkit.app/license-agreement/). Nothing from ButterKit is bundled: no PSDs, SVGs, bitmaps, or code. Backgrounds are drawn procedurally in CoreGraphics (`PatternRenderer.swift`), fonts resolve via the Google Fonts API at render time, and every color, weight, and size is redefined in `RenderTemplate.swift` using StoreScreens' own render config shape. The showcase PNGs in `assets/templates/` are generated by `TemplateShowcaseTests` against a synthetic placeholder screenshot (also not sourced from ButterKit). Names are preserved so users who've seen ButterKit's catalog recognize the aesthetic.
Individual credits: *Ethereal* is by Zach Spitulski (founder of ButterKit); the other eight (*Ascent*, *All The Wiser*, *Sahara*, *Midnight*, *Pinecrest*, *Blueprint*, *Sunset Blvd*, *Jazz & Wine*) are credited to the ButterKit team on [butterkit.app/templates](https://butterkit.app/templates/). If you like the aesthetic, check out ButterKit directly. They ship many more templates plus a 3D rendering engine for the marketing pieces StoreScreens doesn't do.
Templates set `background`, `caption`, and `chrome` defaults. You still pick captions per slide via the usual `slides:` block, and any field you write in the config overrides the template.
Background patterns (`topographic`, `blueprint_grid`, `dune_layers`, `soft_waves`, `gamified_shapes`) can also be used directly without a template:
```yaml
render:
background:
color: "#F4EFE7"
pattern:
pattern: topographic
color: "#1A1F2E"
opacity: 0.15
```
### Background scrim
Layer a flat tint or vertical gradient on top of the background to deepen contrast under the caption, mute a busy photo, or punch up a flat color. Drawn after the background and pattern, before the device chrome.
```yaml
render:
background:
image: ./marketing/hero.jpg
scrim:
color: "#000000"
opacity: 0.35 # 0.0 = invisible, 1.0 = opaque
# Or a top-to-bottom gradient (color stays the same; opacity ramps):
scrim:
color: "#000000"
gradient:
top_opacity: 0.0
bottom_opacity: 0.6
```
`color` defaults to `#000000`. If `gradient` is set, `opacity` is ignored; the gradient ramps between `top_opacity` and `bottom_opacity`.
### Chrome styles
- `none`: no chrome; screenshot drawn at the padded rect.
- `stroke`: rounded-rect clip with device-derived corner radius plus optional colored border and drop shadow. Zero asset download.
- `bezel`: screenshot composited inside a real Apple device bezel. Requires [bezel assets](#device-bezels).
### Fonts
Four forms for `font:`:
```yaml
font: system # SF Pro / system font
font: "Helvetica Neue" # installed font family
font: "./assets/Inter-Bold.otf" # local file
font: # bundle for correct bold/italic
regular: ./Inter-Regular.otf
bold: ./Inter-Bold.otf
italic: ./Inter-Italic.otf
font: # Google Fonts auto-download
google: Inter
version: "3.19" # optional version pin
```
Google Fonts are cached to `~/Library/Caches/storescreens/fonts/`.
#### Per-locale font overrides
A single typeface rarely covers every script you ship. Add `locale_overrides:` to either caption role to swap the font (or any other style field) when a specific locale is being rendered:
```yaml
caption:
title:
font:
google: Cormorant Garamond # default for Latin scripts
weight: bold
locale_overrides:
el:
font: { google: GFS Didot } # Greek slides use Didot
ja:
font: "Hiragino Mincho ProN" # CJK serif for Japanese
weight: regular
```
Each entry is itself a `CaptionRole`; non-nil fields shadow the role defaults for that locale, the rest fall through. Works for both `caption.title` and `caption.subtitle`. Locales absent from the map keep the role unchanged.
### Captions
- Bare string → single title, wraps at canvas width.
- Array of strings → strict line breaks (never wrapped inside an array item).
- Object → `title:`, `subtitle:`, optional `highlights:` for per-word color/weight.
- Markdown supported inline: `**bold**`, `*italic*`, `` `code` ``.
- `highlights:` overrides color / weight / italic on literal substring matches (case-sensitive, all occurrences).
### Alignment and nudge
Each caption role (`title`, `subtitle`) has independent horizontal alignment:
```yaml
caption:
title:
align: left # left | center (default) | right
subtitle:
align: right
```
The caption block as a whole can be vertically positioned inside its reserved band, and captions, images, laurels, and logos all accept a fine-grained `nudge`:
```yaml
caption:
vertical_align: top # top | center (default) | bottom
nudge:
x_pct: 0 # positive = right, negative = left
y_pct: -2 # positive = up (toward screen top), negative = down
logo:
nudge:
x_pct: 1
y_pct: 0
```
`nudge.x_pct` and `nudge.y_pct` are percentages of the canvas width and height respectively, so offsets stay the same relative size across iPhone 6.9", iPad 13", and Mac renders. Both are optional; omit a field and it's treated as zero.
### Images
Up to two image overlays per slide, dropped into one of three slots around the caption block. Each entry is independent: each has its own path, slot, alignment, and nudge.
```yaml
render:
images:
- path: ./marketing/logo-wordmark.svg
position: above_title # above_title | below_title | above_subtitle | below_subtitle
align: center # left | center (default) | right
max_height_pct: 6 # % of canvas height; default 8
placement: first_only # first_only | all | none
```
`below_title` and `above_subtitle` are aliases for the same physical slot (the gap between the title and subtitle text); pick whichever reads more naturally. `placement` defaults to `first_only` for the `above_title` slot and `all` for every other slot, matching the "logo on slide 1, badges on every slide" convention.
When a caption is present, the `above_title` slot extends from the canvas top down to just above the caption block, so the image is automatically balanced between the canvas edge and the caption text without any manual `nudge.y_pct`. When the caption shifts (via `caption.nudge` or `caption.vertical_align`), the image follows. Configs upgraded from pre-2.8 may want to drop their old `images[].nudge.y_pct` workaround, since the default already puts the logo near the caption.
Two images in the same slot stack horizontally:
```yaml
render:
images:
- path: ./marketing/badge-editors-choice.png
position: below_subtitle
align: center
max_height_pct: 9
- path: ./marketing/badge-press.png
position: below_subtitle
align: center
max_height_pct: 9
```
Slot distribution rules:
- 1 item: respects its `align` (default `center`), centered vertically in the slot, with `nudge` applied last.
- 2 items: auto-distribute with equal whitespace. `gap = (canvas_width - item1_width - item2_width) / 3`; item 1 left at `gap`, item 2 left at `canvas_width - gap - item2_width`. Items never overlap as long as their combined width fits the canvas. The `align` field controls each item's internal text alignment (e.g. laurel title alignment) but does not affect anchoring. If the items are too wide together, they're clamped to abut at the midline and a warning is logged - lower `max_height_pct` to fit.
`path` accepts a `{ light:, dark: }` variant the same way `background.image` does, so a wordmark can swap between dark/light files when rendering both appearances.
The legacy `logo:` block still works and is treated as a single image at `above_title`. Setting `images: []` (an explicitly empty array) suppresses that legacy fallback.
### Laurels
A laurel "award badge" overlay - left and right laurel SVGs flanking centered title and subtitle text, tinted to a single color. Up to two per slide, same slot rules as `images`.
```yaml
render:
laurels:
- title: "Editors' Choice"
subtitle: "App Store"
color: "#FFD66B" # single hex, or { light:, dark: } variant
position: below_subtitle # default; same slots as images
align: center
max_height_pct: 11
placement: all # default; laurels usually repeat
```
`title` is bold by default, `subtitle` is regular. Override per-role with `title_style` and `subtitle_style`, which take the same fields as `caption.title` / `caption.subtitle` (font, weight, italic, font_size_pct, color, align):
```yaml
render:
laurels:
- title: "4.9"
subtitle: "200k reviews"
color:
light: "#1A1F2E"
dark: "#FFD66B"
title_style:
font_size_pct: 4.0
weight: heavy
subtitle_style:
font_size_pct: 2.4
italic: true
inset_pct: 4 # default 4. Positive = laurels closer to text (may overlap); negative = wider gap.
```
The laurel SVGs ship with the renderer; `color` tints both leaves with a solid fill (alpha-mask, so any color works). Two laurels in the same slot follow the same same-align/different-align rules as images.
### Tables
A 2D grid of text with optional borders. Up to two per slide, same slot semantics as images and laurels.
```yaml
render:
tables:
- rows:
- ["5,064", "Verbs"]
- ["2x more", "than competitors"]
text_color: "#FFFFFF"
border_color: "#FFD66B"
cell_style:
weight: bold
position: below_subtitle
max_height_pct: 14
```
Use `columns:` instead of `rows:` for column-major content. Rows of unequal length are padded with empty cells, so the grid is always rectangular. Cell content can include `\n` for in-cell line breaks; the row containing the multi-line cell auto-grows. Cell font size auto-derives to fit `max_height_pct` divided across the total number of text lines (a row with 2-line cells takes twice the height of a 1-line row), uniformly applied unless you override `cell_style.font_size_pct`. Per-column horizontal alignment via `column_aligns: [left, right]`; per-column vertical alignment via `column_valigns: [top, top]` (handy when a row auto-grows for a multi-line cell and you want neighboring single-line cells to anchor to the top instead of vertical-centering in the row). Border defaults to all sides + inner grid lines at width_pct: 0.15; override with `border.sides: [outer]`, `[inner]`, or per-side names like `[top, bottom]`.
### Per-slide overrides
Every render field shown above (`background`, `scrim`, `caption`, `chrome`, `images`, `laurels`, `tables`, `logo`) can be overridden per slide under the `slides:` block. Plus two slide-only fields:
#### Per-slide appearance
By default, capture runs each slide in every appearance listed at `appearances: [light, dark]` (a cross-product across all slides). To pin specific slides to one appearance instead, set `appearance:` on the slide and skip the cross-product:
```yaml
render:
slides:
"Home":
appearance: dark # this slide is always rendered dark
caption: "Built for late nights."
"Search":
appearance: light # this slide is always rendered light
caption: "Find anything, fast."
```
Capture groups slides by their effective appearance so each runs at most once per (device, locale) combo. Chrome fields that use the `{ light:, dark: }` variant shape automatically pick the matching side. Slides without an `appearance:` override still flow through the legacy top-level `appearances:` cross-product, so the two modes coexist.
#### Per-slide localized captions
`caption_locales:` lets a single slide carry per-locale title text without duplicating the whole `slides:` block per locale. Keyed by Xcode locale code (`en-US`, `el`, `ja`, `zh-Hans`, …):
```yaml
render:
slides:
"Spellcheck":
caption: "Auto-corrections" # default for unlisted locales
caption_locales:
el: "Αυτόματες διορθώσεις"
ja: "オートコレクト"
```
When the current render's locale matches a key, that entry replaces the slide's `caption:` for that pass. Locales absent from the map fall back to the slide's default caption. This is distinct from the `locale_overrides:` on caption roles documented above: `locale_overrides` swaps font/weight/color for the whole role across all slides; `caption_locales` swaps the actual title text on a single slide.
## App Store search preview
`storescreens search-preview` renders faithful iPhone App Store mockups so you can see how the app will read in search results - and on its detail page - before you ship. SF Pro throughout, drawn natively in Swift Core Graphics. Two modes:
- **Search row** - icon, three-line name/subtitle/stars stack next to the icon, category icons + developer in the meta row, 3-up screenshot strip.
- **Detail page** - what users see after tapping a search result. Hero row + GET, stats strip (ratings · age · category · developer), What's New (version + release notes with `more` link), Preview screenshots, About This App (description with `more` link).
mode: search_row
mode: detail_page
Every input is sourced from elsewhere in the pipeline - no copy to maintain twice:
```yaml
search_preview:
enabled: true
output_dir: ./storescreens-search-preview
appearances: [light] # add `dark` for App Store dark mode
devices: ["iPhone 6.9\""] # or "iPhone 6.3\""; default is Pro Max
mode: both # search_row | detail_page | both
developer: "Acme Co"
rating: 4.8
reviews: "1.2K"
age_rating: "4+" # detail-page stats strip
version: "2.1.0" # detail-page "What's New" header
# categories ← app_store_connect.categories.primary + .secondary
# name + subtitle ← metadata//{name,subtitle}.txt
# whats_new ← metadata//release_notes.txt
# description ← metadata//description.txt
# icon ← /AppIcon.png from your last capture
# screenshots ← first 3 entries from `screenshots:` or the manifest
```
With `enabled: true`, the preview runs automatically after `storescreens capture` (skip with `--no-search-preview`). Standalone:
```bash
storescreens search-preview --appearance light --locale en-US
```
Output mirrors the rest of the pipeline: `///iPhone_6.9_search-row.png` and `iPhone_6.9_detail-page.png`.
## Device bezels
The `bezel` chrome style requires PSD files from Apple's Design Resources. Apple licenses these for use with Apple products; we don't redistribute.
### Install
1. Download DMGs from https://developer.apple.com/design/resources/ (Product Bezels section; iPhone, iPad, MacBook as needed).
2. Double-click each DMG to mount.
3. Run:
```bash
storescreens bezels import
```
This auto-scans `/Volumes/` for Apple Design Resource DMGs, classifies PSDs by screen pixel dimensions, applies your colorway preferences, and exports transparent-screen PNGs + JSON sidecars to `~/Library/Application Support/storescreens/bezels/`.
### Inspect
```bash
storescreens bezels check # list installed bezels
storescreens bezels path # print the install directory
```
### Override per project
Drop bezel PNGs + their JSON sidecars into `./bezels/` next to `storescreens.yml` to override the user-global set for that project only.
### Colorway / model preference
By default the importer picks "Space Black" when available, else Silver / Natural Titanium. Override per project:
```yaml
render:
chrome:
style: bezel
model_preference: [Pro Max, Pro, Air]
colorway_preference: ["Cosmic Orange", Silver]
```
## Uploading to App Store Connect
`storescreens submit` pushes rendered screenshots and per-locale metadata (description, what's new, keywords, etc.) to App Store Connect via Apple's official API.
### Prerequisites
1. Create an App Store Connect API key at https://appstoreconnect.apple.com/access/integrations/api. Choose either Admin or App Manager access. Download the `AuthKey_XXXXXX.p8` file and keep it safe; Apple only lets you download it once.
2. Record the Key ID (10-character alphanumeric) and Issuer ID (a UUID) from the same page.
### Configure credentials
Either set environment variables (CI-friendly):
```bash
export ASC_KEY_ID=ABCDE12345
export ASC_ISSUER_ID=69a6de84-03c8-47e3-e053-5b8c7c11a4d1
export ASC_KEY_PATH=~/.appstoreconnect/AuthKey_ABCDE12345.p8
```
Or generate a pre-filled credentials template and edit it:
```bash
storescreens auth init
```
This writes `~/.storescreens/asc-credentials.yml` (0600 perms) with commented placeholders for `key_id`, `issuer_id`, and `key_path`, and opens it in your editor. Replace the three `REPLACE_ME` values with your real credentials.
Or run the interactive login that prompts for each value:
```bash
storescreens auth login
```
Either way, verify with:
```bash
storescreens auth status
```
This mints a JWT and hits `/v1/users` to confirm the key works.
### Add an `app_store_connect:` block
```yaml
app_store_connect:
# One of app_id or bundle_id is required. bundle_id is resolved via the API.
bundle_id: com.example.recipes
# app_id: "1234567890"
metadata_dir: ./metadata # default: ./metadata
submit:
create_version: "1.2.0" # creates the version if it doesn't exist
screenshots: true
metadata: true
submit_for_review: false # hard default; review submission is manual
```
### Metadata directory layout
Scaffold a starting directory with:
```bash
storescreens metadata init --locales en-US es-ES ja
```
This creates `metadata//` folders plus a `metadata/README.md` field reference. You create only the `.txt` files you want; missing files mean "don't touch that App Store field".
Fastlane convention. One folder per locale, one file per field:
```
metadata/
en-US/
name.txt
subtitle.txt
description.txt
keywords.txt
promotional_text.txt
release_notes.txt
support_url.txt
marketing_url.txt
privacy_url.txt
privacy_choices_url.txt
review_notes.txt
review_contact_first_name.txt
review_contact_last_name.txt
review_contact_phone.txt
review_contact_email.txt
review_demo_account_name.txt
review_demo_account_password.txt
es-ES/
description.txt
release_notes.txt
...
```
App Store Connect splits per-locale metadata across two resources, and `submit` routes each file to the correct endpoint:
| File | ASC resource |
|------|--------------|
| `name.txt` | `appInfoLocalizations.name` |
| `subtitle.txt` | `appInfoLocalizations.subtitle` |
| `privacy_url.txt` | `appInfoLocalizations.privacyPolicyUrl` |
| `privacy_choices_url.txt` | `appInfoLocalizations.privacyChoicesUrl` |
| `description.txt` | `appStoreVersionLocalizations.description` |
| `keywords.txt` | `appStoreVersionLocalizations.keywords` |
| `promotional_text.txt` | `appStoreVersionLocalizations.promotionalText` |
| `release_notes.txt` | `appStoreVersionLocalizations.whatsNew` |
| `support_url.txt` | `appStoreVersionLocalizations.supportUrl` |
| `marketing_url.txt` | `appStoreVersionLocalizations.marketingUrl` |
`appInfoLocalizations` lives on the app-level `appInfo` record, which can only be edited while the app has a version in an editable state (`PREPARE_FOR_SUBMISSION`, `DEVELOPER_REJECTED`, `METADATA_REJECTED`, etc.). If the only existing version is `READY_FOR_SALE`, App Store Connect won't accept `name`/`subtitle`/privacy URL PATCHes; `submit` detects the missing editable `appInfo`, logs `Skipped name/subtitle update - no editable appInfo (create a new editable version first)`, and proceeds with the version-level fields. To update name/subtitle on an already-released app, bump `submit.create_version` so `submit` creates a new editable version (which auto-creates a fresh editable `appInfo`).
`review_notes.txt` and the `review_contact_*.txt` / `review_demo_account_*.txt` files feed the version-level `appStoreReviewDetails` resource (the "App Review Information" panel in App Store Connect): free-form notes Apple's reviewers see when triaging, plus contact info Apple uses if they need to reach you during review, plus an optional demo-account login. These fields are NOT per-locale on Apple's side, so put them under one locale (any locale, typically your primary). If they appear in multiple locale folders, the alphabetically-first one wins and the rest emit a warning.
Any field you don't want to change: leave the file out. Present files replace whatever's currently in App Store Connect. Trailing whitespace and newlines are trimmed.
### Upload
Dry run first to validate everything without pushing:
```bash
storescreens submit --dry-run
```
It checks credentials, app lookup, metadata directory, and confirms every rendered PNG maps to a valid App Store display type and stays under Apple's 8 MB size cap.
Live upload:
```bash
storescreens submit
```
Flags:
- `--skip-screenshots` / `--skip-metadata` to upload only one side
- `--version-override 1.2.1` overrides `submit.create_version`
- `--submit-for-review` / `--no-submit-for-review` overrides `submit.submit_for_review` for one run without touching the yml
- `--render-dir` / `--metadata-dir` override config paths
Screenshot uploads are destructive: each App Store Connect screenshot set is wiped and re-populated from the manifest so the local rendered PNGs are the source of truth. The manifest's screenshot order becomes the App Store display order.
Re-runs are cheap. Both metadata and screenshots are idempotent: before PATCHing a localization, `submit` fetches the current version-localization attributes from ASC and sends only fields that actually differ (unchanged fields skip the PATCH entirely). Before wiping a screenshot set, it reads each existing screenshot's `sourceFileChecksum` (MD5) and compares to the local render's MD5 in manifest order. If the set already matches, no DELETEs fire and no uploads happen. The report lists unchanged locales with `count: 0` so you can see the skip happened.
### Submit for review
Set `submit_for_review: true` in the `submit:` block to automatically send the version to App Review after uploads finish. This posts to Apple's `reviewSubmissions` flow; no manual "Submit for Review" click in the web UI is needed.
```yaml
app_store_connect:
submit:
create_version: "1.2.0"
screenshots: true
metadata: true
submit_for_review: true # default false
```
Submission runs only after screenshots + metadata have been successfully uploaded, so the version is complete when Apple picks it up. The review submission ID and final state (`WAITING_FOR_REVIEW` on success) are included in the report output.
Under the hood we use Apple's newer three-step `reviewSubmissions` flow (create the submission, POST a `reviewSubmissionItems` to attach the version, PATCH `submitted:true` to push it into `WAITING_FOR_REVIEW`). The older per-version `appStoreVersionSubmissions` endpoint has been retired.
#### Auto-cleanup of stuck prior submissions
When Apple rejects a build, the previous `reviewSubmission` transitions to state `UNRESOLVED_ISSUES` and the rejected version is "stuck inside" that submission. Aborted prior runs can also leave `READY_FOR_REVIEW` drafts behind. To avoid manual cleanup in the ASC web UI, `submit` runs a pre-flight that either cancels or adopts the stale submissions before creating a new one:
1. List existing `reviewSubmissions` for the app on the configured platform.
2. If any are in `IN_REVIEW` or `WAITING_FOR_REVIEW`, bail loudly with an error. Apple is actively reviewing (or about to), and pulling the rug out from under that wastes a review slot. Cancel manually via the ASC web UI if you really mean to resubmit.
3. For each `UNRESOLVED_ISSUES` (rejected) submission: PATCH `canceled: true` and poll until the state settles to `COMPLETE`. The IDs land in `report.canceledReviewSubmissionIDs`.
4. For each stale `READY_FOR_REVIEW` draft: GET its items first.
- If items reference our target version (or the items list is empty so we can attach our version): adopt the draft as our submission. The flow attaches the version if needed and PATCHes `submitted: true` in place rather than recreating. The adopted ID lands in `report.adoptedReviewSubmissionID`.
- If items reference a different version: PATCH `canceled: true` like the rejected path above.
5. Proceed with the three-step flow against the adopted draft (just PATCH `submitted:true`) or a fresh submission (create + attach + finalize).
Adoption is what unblocks the "first submit ran before Apple finished processing the build" scenario - Apple refuses to cancel an empty draft AND refuses to cancel a draft once items are attached, so adopting it is the only programmatic way out.
Note: programmatic cancel uses PATCH `{"canceled": true}` on the submission. ASC's `DELETE /v1/reviewSubmissions/{id}` returns 403 regardless of state, so `submit` does not attempt DELETE.
#### Waiting for the build to finish processing
When `attach_build: true` (the default) and `submit_for_review: true`, `submit` will poll `/v1/builds` for up to 20 minutes waiting for a VALID build to appear for the target marketing version before continuing. Submitting against a build-less version is what leaves an empty draft `reviewSubmission` behind, so the wait is cheaper than the cleanup. If the wait times out, `submit` skips the review-submission step entirely (no empty draft is created) and reports an explicit "submit for review: skipped because no VALID build was attached" error; re-run once `storescreens testflight builds list` shows the build as `VALID`.
Prefer to leave `submit_for_review: false` in the yml as the default safe state and opt in per-run with `--submit-for-review` on the CLI when you're ready to ship. The inverse `--no-submit-for-review` suppresses submission even if the yml has it enabled, which is handy for a dry rehearsal against the production config. If neither flag is passed, the yml value wins. The flags combine with `--skip-screenshots --skip-metadata` if you just want to re-trigger the review submission against an already-uploaded version.
#### App Review notes and contact info
`metadata//review_notes.txt` and the matching `review_contact_*.txt` / `review_demo_account_*.txt` files feed the version's `appStoreReviewDetails` resource. They cover everything in the "App Review Information" panel of the ASC web UI:
| File | ASC field |
|------|-----------|
| `review_notes.txt` | `notes` (free-form notes for Apple's reviewers) |
| `review_contact_first_name.txt` | `contactFirstName` |
| `review_contact_last_name.txt` | `contactLastName` |
| `review_contact_phone.txt` | `contactPhone` |
| `review_contact_email.txt` | `contactEmail` |
| `review_demo_account_name.txt` | `demoAccountName` |
| `review_demo_account_password.txt` | `demoAccountPassword` |
Apple stores these per-version (not per-locale), so put the files under one locale only - typically your primary. The reader picks the alphabetically first locale that has any `review_*.txt` file and warns about review files in other locales. The PATCH only sends fields that actually differ from ASC's current values; an unchanged review-detail produces a `review detail: unchanged` line in the progress output and no API call.
#### Export compliance
Apple requires every build to answer the export-compliance question (`usesNonExemptEncryption` on the build) before it can be submitted for review or distributed to external TestFlight testers. `submit` can PATCH this for you on the build it just attached:
```yaml
app_store_connect:
submit:
create_version: "1.2.0"
submit_for_review: true
export_compliance: none # default: see values below
```
Four values:
| Value | Wire | Meaning |
|---|---|---|
| `none` (default) | `usesNonExemptEncryption: false` | App uses only standard iOS cryptography (HTTPS, keychain, signing). Correct for the vast majority of apps. |
| `exempt_algorithms` | `usesNonExemptEncryption: false` | App ships its own cryptography but every use qualifies for an Apple exemption (authentication, DRM, copy protection). |
| `non_exempt` | `usesNonExemptEncryption: true` | App uses non-exempt encryption. You're responsible for filing the BIS export paperwork separately. |
| `skip` | (not PATCHed) | Leave the question untouched. The build shows "Missing Compliance" in ASC until you answer manually. |
When `none` or `exempt_algorithms` is used, you can also bake the answer into the binary at build time by setting `INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO` in your Xcode target's Info.plist - `storescreens upload-build` does this by default for new archives so the question is pre-answered before the build even uploads.
### Pricing and availability
A brand-new app can't be submitted for review without having Pricing and Availability set in App Store Connect. `submit` can do both via the ASC API so the whole setup lives in one yml:
```yaml
app_store_connect:
bundle_id: com.example.app
pricing:
free: true
base_territory: USA
availability:
territories: all # or ["USA", "CAN", "GBR"]
available_in_new_territories: true
```
Both blocks are optional and idempotent. `pricing` only supports `free: true` today (paid pricing requires price-tier lookup, which isn't wired up yet - set paid pricing in the ASC web UI). The step is a no-op if the app already has a price schedule, so re-runs don't overwrite manual edits. `availability` accepts either `"all"` (expanded to every territory Apple supports at submit time) or an explicit list of ISO 3166-1 alpha-3 codes; it diffs against the current availability and skips the POST if nothing changed.
`release_notes.txt` (`whatsNew`) is also handled intelligently: ASC rejects release notes on the first version of a brand-new app, so `submit` detects that case (no prior released version on the app) and drops `whatsNew` from the metadata PATCH with a `skipping whatsNew` progress line. Leave your `release_notes.txt` in place - it'll be picked up automatically on subsequent submissions.
### Categories, age rating, and review info
Three more YAML blocks finish out what `submit` writes to App Store Connect, all optional:
```yaml
app_store_connect:
bundle_id: com.example.app
categories:
primary: EDUCATION
secondary: REFERENCE
# primary_subcategory_one: GAMES_ACTION # only relevant for GAMES
age_rating:
cartoon_or_fantasy_violence: NONE
realistic_violence: NONE
profanity_or_crude_humor: NONE
gambling: false
unrestricted_web_access: false
kids_age_band: NONE
review_info:
first_name: Jane
last_name: Doe
phone_number: "+1 555 123 4567"
email_address: jane@example.com
notes: |
Multi-line notes for Apple's reviewers.
```
`categories` and `age_rating` live on the editable AppInfo - the same record that hosts `name`/`subtitle`/`privacy_url` - so they require an editable AppInfo state (typically `PREPARE_FOR_SUBMISSION`). When the only AppInfo is `READY_FOR_SALE`, `submit` skips both with `skipped: no editable appInfo` (same skip-reason path as name/subtitle). Bump `submit.create_version` to create a new editable version and re-run.
`review_info` lives on the version's `appStoreReviewDetails` resource and is always editable. It's a YAML alternative to the per-locale `review_*.txt` files. When both YAML and files are present, YAML wins on a per-field basis. Demo-account credentials auto-flip `demo_account_required: true` unless you explicitly say otherwise; conversely, when no demo-account fields are configured at all and `submit` is creating a fresh review-detail record, it sends `demo_account_required: false` explicitly so Apple's "Sign-In Required" checkbox doesn't default to checked.
`storescreens submit --dry-run` validates each block in place: categories are checked against `GET /v1/appCategories` (a typo like `EDUKATION` fails before the live PATCH), age-rating frequency strings round-trip through a strict Codable enum, and `review_info` checks that any partial demo-account credentials are paired up.
All three diff against ASC before writing, so re-running an unchanged config is a quiet no-op:
```
categories: unchanged
age rating: unchanged
review detail: unchanged
```
API quirks worth knowing:
- **Categories use `PATCH /v1/appInfos/{id}` with relationships**, not the per-relationship endpoints. Apple's `PATCH /v1/appInfos/{id}/relationships/primaryCategory` returns `403 FORBIDDEN_ERROR` "does not allow UPDATE" - the parent PATCH is the only programmatic path that works. The body shape we use:
```json
{ "data": { "type": "appInfos", "id": "...",
"relationships": {
"primaryCategory": { "data": { "type": "appCategories", "id": "EDUCATION" } },
"secondaryCategory": { "data": { "type": "appCategories", "id": "REFERENCE" } }
} } }
```
All six category slots (primary, secondary, plus two subcategories under each) can be set in one PATCH.
- **The literal string `"none"` in `secondary:` (or any subcategory slot) means "explicitly clear"** and emits `data: null` on the wire. Useful for downgrading a 2-category app to a single primary.
- **Age-rating PATCHes reject empty bodies.** ASC errors out if every attribute matches what's already there. The orchestrator pre-diffs and skips the PATCH entirely when nothing differs.
- **Editable-record requirement.** `appInfos` only accepts PATCHes while in `PREPARE_FOR_SUBMISSION`, `DEVELOPER_REJECTED`, `METADATA_REJECTED`, `INVALID_BINARY`, `WAITING_FOR_REVIEW`, or `IN_REVIEW`. The orchestrator surfaces a missing-editable-AppInfo case as `report.appInfoSkipped = .noEditableAppInfo` and a clear progress line.
### What `submit` writes to ASC
Quick map of what each YAML block sends to which App Store Connect resource:
| YAML block | ASC resource | Endpoint | Notes |
|------------|--------------|----------|-------|
| `submit.create_version` | `appStoreVersions` | POST + PATCH | Find-or-create the version, attach the latest VALID build. |
| `metadata//{description,keywords,promotional_text,release_notes,support_url,marketing_url}.txt` | `appStoreVersionLocalizations` | PATCH | Per-locale, per-version. |
| `metadata//{name,subtitle,privacy_url,privacy_choices_url}.txt` | `appInfoLocalizations` | PATCH | Per-locale, app-level (lives on editable AppInfo). |
| `metadata//review_*.txt` _or_ `review_info:` | `appStoreReviewDetails` | POST or PATCH | Per-version, not per-locale. |
| `pricing.free: true` | `appPriceSchedules` | POST | Idempotent: skips if a schedule already exists. |
| `availability.territories` | `appAvailabilities` (v2) | POST | Diffs against current; skips if unchanged. |
| `categories.{primary,secondary,...}` | `appInfos` (relationships) | PATCH | All six slots in one body. |
| `age_rating.*` | `ageRatingDeclarations` | PATCH | Diffed before write; skips empty diffs. |
| Rendered PNGs in `render.output_dir` | `appScreenshotSets` + `appScreenshots` | DELETE-then-POST | Idempotent via MD5 checksum; reorders are wipe + reupload. |
| `submit.attach_build` | `appStoreVersions.build` (relationship) | PATCH | Latest VALID build for the marketing version. |
| `submit.export_compliance` | `builds.usesNonExemptEncryption` | PATCH | On the attached build. |
| `submit.submit_for_review: true` | `reviewSubmissions` 3-step | POST + PATCH | Cleans up stale prior submissions first. |
Every step is idempotent: re-running the same `submit` against an unchanged config is mostly a series of GETs and a clean report.
For the full schema (every field, default, gotcha) see `references/submit-reference.md` in the storescreens skill.
### Troubleshooting
- "credentials not configured": run `storescreens auth login` or check the `ASC_*` env vars.
- "no App Store Connect app matched": the `bundle_id` in config doesn't match any app in your ASC team; double-check spelling or use `app_id` instead.
- "no ASC display type for WxH": the rendered screenshot has unsupported dimensions. Most commonly this means a non-App-Store simulator. Rebuild with supported devices.
- "8MB limit exceeded": Apple caps individual screenshots at 8 MB. Reduce the PNG compression quality or simplify the background image.
### Checking on a submission with `storescreens status`
After running `storescreens submit --submit-for-review`, you don't need to log in to App Store Connect to see what's happening. `storescreens status` queries ASC and prints a one-screen summary of the current state of the app:
```bash
storescreens status
```
```
App Store Connect status
app: MyApp (1234567890)
bundle id: com.example.myapp
platform: IOS
Versions:
1.0.1 WAITING_FOR_REVIEW 2026-05-09 14:05
1.0 READY_FOR_SALE 2026-05-06 18:51
Open review submissions:
WAITING_FOR_REVIEW abc-... submitted 2026-05-09 14:11
Submission is queued; Apple has not started reviewing yet.
```
`--json` switches to machine-readable output for scripts and CI. `--platform` accepts `IOS` (default), `MAC_OS`, `TV_OS`, `VISION_OS`. Read-only: makes no changes to the app.
## Archiving + uploading the app binary
`storescreens submit` ships screenshots and text metadata. To also archive the `.ipa` and upload it to App Store Connect / TestFlight, use `storescreens upload-build`:
```bash
storescreens upload-build init # scaffold upload_build: block, open in editor
storescreens upload-build # xcodebuild archive + exportArchive + altool upload-app
```
Reuses the same ASC API credentials as `submit`. Pins `DEVELOPER_DIR` to a production Xcode (auto-detected from `/Applications/Xcode*.app`, excluding Xcode-beta) so a beta `xcode-select -p` can't taint the archive; override with `xcode_path:` or `--xcode-path`.
Minimum config:
```yaml
app_store_connect:
bundle_id: com.example.app
upload_build: {} # defaults: scheme from top-level, Release, app-store, auto Xcode, ./build
```
Useful flags:
- `--dry-run` prints the plan (which Xcode, scheme, destination, output paths, resolved version + build) without running xcodebuild.
- `--skip-upload` archives + exports but keeps the `.ipa` local (use `skip_upload: true` in yml for the same effect). Also skips the pre-archive version check.
- `--xcode-path /Applications/Xcode.app` forces a specific Xcode.
- `--marketing-version 1.2.0` / `--build 3` force a specific version and/or build number (overrides what's in the project).
- `--no-auto-bump` errors out instead of rewriting the pbxproj when a bump is required.
- `--verbose` streams full xcodebuild output instead of filtered progress.
### Automatic version + build resolution
Before archiving, `upload-build` queries App Store Connect for your app and decides whether your current `MARKETING_VERSION` / `CURRENT_PROJECT_VERSION` will produce a legal upload:
- Marketing version already shipped -> bump patch (`1.1.7` -> `1.1.8`), reset build to `1`.
- Marketing version is editable but TestFlight already has builds -> bump build number past `max(existing)`.
- Fresh version -> keep what you have.
When a bump is required, it rewrites the `project.pbxproj` in place (every config of every target, matching `agvtool new-version -all` / `new-marketing-version`) and syncs `submit.create_version` in `storescreens.yml` so the next `storescreens submit` picks up the right version. Opt out with `upload_build.auto_bump: false` or `--no-auto-bump`.
Full schema (every field + defaults, ExportOptions.plist generation, altool flow, version resolver rules, troubleshooting): run `storescreens upload-build --help` and `storescreens upload-build init --help`.
### Troubleshooting
**`xcodebuild` fails with `iOS is not installed`** (common right after upgrading to a new Xcode major, e.g. 26.x). The new Xcode often ships without its iOS platform component bundled. Download it once:
```bash
xcodebuild -downloadPlatform iOS
```
It's an 8+ GB download; once it finishes, re-run `storescreens upload-build`.
**`storescreens submit --submit-for-review` waits a long time on "no VALID build for yet; polling".** That's the orchestrator waiting for Apple to finish processing the build you just uploaded. Processing typically takes 5-15 min after `upload-build` completes; the wait is capped at 20 min by default. If Apple is slow, re-run `submit` once `storescreens testflight builds list` reports the build as `VALID`.
## App Store Connect API coverage
storescreens wraps Apple's App Store Connect API as both CLI subcommands and MCP tools, so an AI agent driving storescreens never needs to construct raw HTTPS requests. Credentials resolve once via `~/.storescreens/asc-credentials.yml` (or the `ASC_*` env vars) and are reused across every family below.
Every operation is reachable from two surfaces:
- **CLI**: nested subcommand trees like `storescreens testflight beta-groups list`, `storescreens iap purchases create`, `storescreens reports sales --frequency DAILY`. Every leaf supports `--json` for machine-readable output.
- **MCP**: snake_case tool names like `testflight_beta_groups_list`, `iap_in_app_purchases_create`, `reports_sales_get`. The full catalog (267 new tools across 7 families) is auto-exposed via `tools/list`.
Read-only operations (lookups, listings, GETs) are safe to call freely; write operations (POST/PATCH/DELETE) act on live App Store Connect data, so review the dry-run flow of `submit` for the surface you're editing.
## TestFlight
`storescreens testflight` wraps the App Store Connect TestFlight & pre-release distribution API as nested subcommands. The same operations are exposed as MCP tools under the `testflight_*` namespace so AI agents driving storescreens never need to construct raw ASC HTTP requests for beta workflows.
Credentials are resolved through the same path as the rest of the App Store Connect features (`storescreens auth login` or `ASC_KEY_ID` / `ASC_ISSUER_ID` / `ASC_KEY_PATH` env vars). Every leaf subcommand accepts `--json` for machine-readable output. List endpoints accept `--limit` and `--cursor`, and return a `next-cursor` value when more pages are available.
### Resources covered
| ASC resource | What it does | CLI namespace |
|--------------|--------------|---------------|
| `betaGroups` | Lists of internal or external testers | `beta-groups` |
| `betaTesters` | Individual TestFlight testers | `beta-testers` |
| `betaTesterInvitations` | Re-send the TF invite email | `beta-tester-invitations` |
| `prereleaseVersions` | Read-only build trains | `prerelease-versions` |
| `builds` | List, get, expire builds | `builds` |
| `buildBetaDetails` | Per-build auto-notify, internal/external state | `build-beta-detail` |
| `buildBetaNotifications` | Push "new build" emails | `build-beta-notifications` |
| `betaAppLocalizations` | Per-locale TestFlight App Information card | `beta-app-localizations` |
| `betaBuildLocalizations` | Per-locale "What to Test" notes per build | `beta-build-localizations` |
| `betaAppReviewDetails` | TF beta-review contact info, demo account | `beta-app-review-detail` |
| `betaAppReviewSubmissions` | Submit a build for Beta App Review | `beta-app-review-submissions` |
| `betaLicenseAgreements` | The TF EULA testers accept | `beta-license-agreement` |
| `betaTesterMetrics` | Read-only install/launch counts | `beta-tester-metrics` |
| `buildBundles` | Read-only primary `.app` + extensions/clips | `build-bundles` |
| `buildIcons` | Read-only per-build icon images | `build-icons` |
### CLI command catalog
```
storescreens testflight beta-groups list --app-id 1234567890 [--limit 200] [--cursor C] [--json]
storescreens testflight beta-groups get [--json]
storescreens testflight beta-groups create --app-id 1234567890 --name "Beta Squad" [--feedback-enabled true] [--public-link-enabled true]
storescreens testflight beta-groups update [--name N] [--feedback-enabled B] [--public-link-enabled B]
storescreens testflight beta-groups delete
storescreens testflight beta-groups add-builds --group-id G B1 B2 ...
storescreens testflight beta-groups remove-builds --group-id G B1 B2 ...
storescreens testflight beta-groups add-testers --group-id G T1 T2 ...
storescreens testflight beta-groups remove-testers --group-id G T1 T2 ...
storescreens testflight beta-groups create-and-invite --app-id A --name N T1 T2 ...
storescreens testflight beta-testers list --app-id 1234567890 [--json]
storescreens testflight beta-testers get [--json]
storescreens testflight beta-testers create --app-id A --email foo@bar.com [--first-name F] [--last-name L] [--beta-group-ids G1 G2]
storescreens testflight beta-testers delete
storescreens testflight beta-testers remove-from-app --tester-id T --app-id A
storescreens testflight beta-testers assign-to-groups --tester-id T G1 G2 ...
storescreens testflight beta-testers remove-from-groups --tester-id T G1 G2 ...
storescreens testflight beta-tester-invitations create --tester-id T --app-id A
storescreens testflight prerelease-versions list --app-id A [--platform IOS] [--json]
storescreens testflight prerelease-versions get [--json]
storescreens testflight builds list [--app-id A] [--expired B] [--processing-state VALID] [--prerelease-version-id V] [--json]
storescreens testflight builds get [--json]
storescreens testflight builds set-expired [--expired | --no-expired]
storescreens testflight build-beta-detail get --build-id B [--json]
storescreens testflight build-beta-detail update [--auto-notify | --no-auto-notify]
storescreens testflight build-beta-notifications create --build-id B
storescreens testflight beta-app-localizations list --app-id A [--json]
storescreens testflight beta-app-localizations get
storescreens testflight beta-app-localizations create --app-id A --locale en-US [--description D] [--feedback-email E]
storescreens testflight beta-app-localizations update [--description D] [--feedback-email E]
storescreens testflight beta-app-localizations delete
storescreens testflight beta-build-localizations list --build-id B [--json]
storescreens testflight beta-build-localizations get
storescreens testflight beta-build-localizations create --build-id B --locale en-US [--whats-new "Fixed bug X"]
storescreens testflight beta-build-localizations update [--whats-new N]
storescreens testflight beta-build-localizations delete
storescreens testflight beta-app-review-detail get --app-id A [--json]
storescreens testflight beta-app-review-detail update [--contact-first-name F] [--contact-last-name L] [--contact-email E] [--notes N]
storescreens testflight beta-app-review-submissions list --app-id A [--json]
storescreens testflight beta-app-review-submissions get
storescreens testflight beta-app-review-submissions create --build-id B
storescreens testflight beta-license-agreement get --app-id A [--json]
storescreens testflight beta-license-agreement update --from-file ./eula.txt
storescreens testflight beta-tester-metrics list --app-id A [--json]
storescreens testflight build-bundles list --build-id B [--json]
storescreens testflight build-bundles get
storescreens testflight build-icons list --build-id B [--json]
```
### MCP tool catalog
Every CLI subcommand has a matching MCP tool with the same shape:
- `testflight_beta_groups_list`, `testflight_beta_groups_get`, `testflight_beta_groups_create`, `testflight_beta_groups_update`, `testflight_beta_groups_delete`, `testflight_beta_groups_add_builds`, `testflight_beta_groups_remove_builds`, `testflight_beta_groups_add_testers`, `testflight_beta_groups_remove_testers`, `testflight_beta_groups_create_and_invite`
- `testflight_beta_testers_list`, `testflight_beta_testers_get`, `testflight_beta_testers_create`, `testflight_beta_testers_delete`, `testflight_beta_testers_remove_from_app`, `testflight_beta_testers_assign_to_groups`, `testflight_beta_testers_remove_from_groups`
- `testflight_beta_tester_invitations_create`
- `testflight_prerelease_versions_list`, `testflight_prerelease_versions_get`
- `testflight_builds_list`, `testflight_builds_get`, `testflight_builds_set_expired`
- `testflight_build_beta_detail_get`, `testflight_build_beta_detail_update`
- `testflight_build_beta_notifications_create`
- `testflight_beta_app_localizations_list`, `testflight_beta_app_localizations_get`, `testflight_beta_app_localizations_create`, `testflight_beta_app_localizations_update`, `testflight_beta_app_localizations_delete`
- `testflight_beta_build_localizations_list`, `testflight_beta_build_localizations_get`, `testflight_beta_build_localizations_create`, `testflight_beta_build_localizations_update`, `testflight_beta_build_localizations_delete`
- `testflight_beta_app_review_detail_get`, `testflight_beta_app_review_detail_update`
- `testflight_beta_app_review_submissions_list`, `testflight_beta_app_review_submissions_get`, `testflight_beta_app_review_submissions_create`
- `testflight_beta_license_agreement_get`, `testflight_beta_license_agreement_update`
- `testflight_beta_tester_metrics_list`
- `testflight_build_bundles_list`, `testflight_build_bundles_get`
- `testflight_build_icons_list`
MCP tools return pretty-printed JSON text content. Errors surface as `isError: true` with the App Store Connect status code and any error details from Apple's envelope.
### Common workflows
#### Push a new build to external testers
```bash
# 1. Find the new build by its app and processing state.
storescreens testflight builds list --app-id 1234567890 --processing-state VALID --json
# 2. Submit it for Beta App Review (required before external distribution).
storescreens testflight beta-app-review-submissions create --build-id ABCDEF123
# 3. After Apple approves (poll the submission state), attach the build to the
# external beta group and send notifications.
storescreens testflight beta-groups add-builds --group-id GROUP_ID ABCDEF123
storescreens testflight build-beta-notifications create --build-id ABCDEF123
```
#### Add a new beta tester to a group
```bash
# Create the tester record on the app and assign them to a group in one call.
storescreens testflight beta-testers create \
--app-id 1234567890 \
--email tester@example.com \
--first-name Alex \
--last-name Tester \
--beta-group-ids GROUP_ID_1 GROUP_ID_2
```
If the tester already exists and needs to be moved into a group:
```bash
storescreens testflight beta-testers assign-to-groups --tester-id T123 GROUP_ID_1
```
#### Resend a TestFlight invitation
```bash
storescreens testflight beta-tester-invitations create --tester-id T123 --app-id 1234567890
```
#### Curate which build TestFlight surfaces
```bash
# Disable auto-notify on a build that's still being smoke-tested internally.
storescreens testflight build-beta-detail get --build-id ABCDEF123 --json
# Use the returned id (NOT the build id) with update.
storescreens testflight build-beta-detail update DETAIL_ID --no-auto-notify
# Once the build is bad, retire it so testers stop seeing it.
storescreens testflight builds set-expired ABCDEF123 --expired
```
#### Localize the TestFlight install card
```bash
# Per-locale TestFlight App Information (description, feedback email).
storescreens testflight beta-app-localizations create \
--app-id 1234567890 \
--locale ja \
--description "新機能をお試しください" \
--feedback-email beta@example.com
# Per-locale What to Test notes attached to a specific build.
storescreens testflight beta-build-localizations create \
--build-id ABCDEF123 \
--locale ja \
--whats-new "プッシュ通知のバグ修正"
```
### Pagination
List endpoints return a `next-cursor` value when more pages are available. Pass it back via `--cursor` to fetch the next page. JSON output also includes a `nextCursor` field at the top level for machine consumers.
```bash
storescreens testflight beta-testers list --app-id 1234567890 --limit 50 --json | jq .nextCursor
storescreens testflight beta-testers list --app-id 1234567890 --limit 50 --cursor ""
```
### Error handling
Apple's API returns JSON:API error envelopes with `code`, `title`, and `detail`. The CLI prints these grouped under the HTTP status code; the MCP tools surface them as `isError: true` text content. 404 responses on `get` calls return `null` (not an error). 409 conflicts (e.g. attempting to add a build that's already in a group) flow through `ASCClient.APIError.isAlreadySetConflict` so callers can treat them as no-op successes.
## In-App Purchases
storescreens wraps the App Store Connect In-App Purchases V2 API so AI agents and humans can configure, price, and submit IAPs without crafting raw HTTP requests. This pass covers the V2 surface only (Apple deprecated V1 in 2023). Auto-renewing subscriptions live under a separate `subscriptionGroups` family and are not part of this wrapper, the `iap` commands handle CONSUMABLE, NON_CONSUMABLE, and NON_RENEWING_SUBSCRIPTION product types.
### Resources covered
| Resource | Operations |
| --- | --- |
| `inAppPurchases` (V2) | list, get, create, update, delete |
| `inAppPurchaseLocalizations` | list, get, create, update, delete |
| `inAppPurchasePricePoints` | list, get (read-only catalog) |
| `inAppPurchasePriceSchedules` | get, set |
| `inAppPurchaseSubmissions` | list, get, create |
| `inAppPurchaseContentHostings` | get, update |
| `inAppPurchaseImages` | list, get, upload, update, delete |
| `inAppPurchaseAppStoreReviewScreenshots` | get, upload, update, delete |
| `inAppPurchasePromotionalImages` | list, upload, delete |
| `promotedPurchases` | list, update |
| `promotedPurchaseImages` | list, upload, update, delete |
### MCP tool catalog
Tools are namespaced under `iap_*` and return pretty-printed JSON in `content[0].text`. Errors set `isError: true` with the message in the text payload. Credentials are resolved from `ASC_KEY_ID` / `ASC_ISSUER_ID` / `ASC_KEY_PATH` env vars or `~/.storescreens/asc-credentials.yml`, so tool arguments never carry key material.
Purchases:
- `iap_in_app_purchases_list`
- `iap_in_app_purchases_get`
- `iap_in_app_purchases_create`
- `iap_in_app_purchases_update`
- `iap_in_app_purchases_delete`
Localizations:
- `iap_localizations_list`
- `iap_localizations_get`
- `iap_localizations_create`
- `iap_localizations_update`
- `iap_localizations_delete`
Price points (read-only):
- `iap_price_points_list`
- `iap_price_points_get`
Pricing:
- `iap_price_schedule_get`
- `iap_price_schedule_set`
Submissions:
- `iap_submission_list`
- `iap_submission_get`
- `iap_submission_create`
Content hosting:
- `iap_content_hosting_get`
- `iap_content_hosting_update`
Images (IAP detail page):
- `iap_images_list`
- `iap_images_get`
- `iap_images_upload`
- `iap_images_update`
- `iap_images_delete`
Review screenshot:
- `iap_review_screenshot_get`
- `iap_review_screenshot_upload`
- `iap_review_screenshot_update`
- `iap_review_screenshot_delete`
Promotional images (App Store featured slots):
- `iap_promotional_images_list`
- `iap_promotional_images_upload`
- `iap_promotional_images_delete`
Promoted purchases (storefront promotion config):
- `iap_promoted_purchases_list`
- `iap_promoted_purchases_update`
Promoted purchase images:
- `iap_promoted_purchase_images_list`
- `iap_promoted_purchase_images_upload`
- `iap_promoted_purchase_images_update`
- `iap_promoted_purchase_images_delete`
### CLI examples
The `storescreens iap` parent command groups every IAP operation. Every subcommand takes `--json` to emit the raw JSON response instead of the human-readable summary.
List IAPs on an app:
```
storescreens iap purchases list --app-id 1234567890
```
Create a non-consumable IAP:
```
storescreens iap purchases create \
--app-id 1234567890 \
--name "Pro Unlock" \
--product-id com.acme.app.pro_unlock \
--in-app-purchase-type NON_CONSUMABLE \
--review-note "Unlocks the pro editing tools shown on the home screen."
```
Add a localization:
```
storescreens iap localizations create \
--iap-id 9876543210 \
--locale en-US \
--name "Pro Unlock" \
--description "One-time purchase that removes ads and unlocks every editing tool."
```
Look up the USA $9.99 price-point id, then set pricing for the IAP:
```
storescreens iap price-points list --iap-id 9876543210 --territory-id USA
storescreens iap pricing set \
--iap-id 9876543210 \
--base-territory-id USA \
--price USA:eyJzIjoiVVNBIiwidCI6IjA5OTkifQ==
```
Upload the review screenshot Apple needs:
```
storescreens iap review-screenshot upload \
--iap-id 9876543210 \
--file ./screenshots/iap-review.png
```
Submit the IAP for App Review:
```
storescreens iap submission create --iap-id 9876543210
```
Toggle a storefront-promoted IAP:
```
storescreens iap promoted-purchases list --app-id 1234567890
storescreens iap promoted-purchases update \
--promoted-purchase-id 5550001 \
--enabled \
--visible-for-distribution
```
### Common workflows
Create a non-consumable IAP end-to-end:
1. `storescreens iap purchases create --in-app-purchase-type NON_CONSUMABLE ...`
2. `storescreens iap localizations create ...` for every locale you support
3. `storescreens iap price-points list --territory-id USA` to discover the price-point id
4. `storescreens iap pricing set ...` to apply the price
5. `storescreens iap review-screenshot upload ...` for Apple's reviewers
6. `storescreens iap submission create --iap-id `
Set pricing for an existing IAP across multiple territories:
```
storescreens iap pricing set \
--iap-id 9876543210 \
--base-territory-id USA \
--price USA:pp_usa_999 \
--price CAN:pp_can_999 \
--price GBR:pp_gbr_999
```
Look up an IAP by product id (useful when you have the bundle id but not the ASC numeric id):
```
storescreens iap purchases list --app-id 1234567890 --json \
| jq '.items[] | select(.attributes.productId == "com.acme.app.pro_unlock")'
```
Pull the live state of all IAP submissions for an app:
```
storescreens iap purchases list --app-id 1234567890 --json \
| jq -r '.items[].id' \
| while read iap; do
storescreens iap submission list --iap-id "$iap" --json
done
```
## Subscriptions
storescreens-cli wraps Apple's App Store Connect Auto-Renewable Subscriptions
API so agents and humans can manage subscription products, pricing, offers,
and review submissions without hand-rolling HTTP. The same calls are exposed
three ways:
- Swift API: `SubscriptionsAPI` in `StorescreensCore` (use from another Swift
package or from custom orchestrators)
- MCP tools: `subs_*` tool family for AI agents
- CLI: `storescreens subscriptions ...` for humans and scripts
All three share the same credentials path (run `storescreens auth login` or
set `ASC_KEY_ID` / `ASC_ISSUER_ID` / `ASC_KEY_PATH`).
### Resources covered
| Apple resource | CRUD | Notes |
|---|---|---|
| `subscriptionGroups` | full | Container that subscriptions live in. |
| `subscriptionGroupLocalizations` | full | Per-locale group name + optional custom app name. |
| `subscriptions` | full | The actual auto-renewing products (productId, period, group level, review note). |
| `subscriptionLocalizations` | full | Per-locale name + description. |
| `subscriptionPrices` | list, create, delete | Immutable per record; a "change" is create-new + delete-old. |
| `subscriptionPricePoints` | list | Read-only catalog of valid Apple tiers per territory. |
| `subscriptionOfferCodes` | full | Win-back / promotional offer programs. |
| `subscriptionOfferCodeOneTimeUseCodes` | list, create | Apple-generated unique single-use codes. |
| `subscriptionOfferCodeCustomCodes` | list, create, delete | Developer-chosen strings (e.g. `BLACKFRIDAY2025`). |
| `subscriptionOfferCodePrices` | list, create | Per-territory pricing on an offer code. |
| `subscriptionPromotionalOffers` | full | Intro offers shown to new subscribers via StoreKit. |
| `subscriptionPromotionalOfferPrices` | list, create | Per-territory pricing on a promotional offer. |
| `subscriptionAvailabilities` | get, update | Territory list (POST replaces the list wholesale). |
| `subscriptionSubmissions` | list, get, create | Push metadata edits to App Review. |
| `subscriptionAppStoreReviewScreenshots` | full | Review-only screenshots Apple requires for approval. |
| `subscriptionImages` | list, create, delete | Promotional artwork. |
Paginated lists accept `limit` (default 200) and `cursor`; every response
carries `next_cursor` (or `nextCursor` for Swift callers) for the next page.
### MCP tools
Every tool name is `subs__` (snake_case). Group of tools:
Groups
- `subs_groups_list`
- `subs_groups_get`
- `subs_groups_create`
- `subs_groups_update`
- `subs_groups_delete`
Group localizations
- `subs_group_localizations_list`
- `subs_group_localizations_create`
- `subs_group_localizations_update`
- `subs_group_localizations_delete`
Subscriptions
- `subs_subscriptions_list`
- `subs_subscriptions_get`
- `subs_subscriptions_create`
- `subs_subscriptions_update`
- `subs_subscriptions_delete`
Subscription localizations
- `subs_localizations_list`
- `subs_localizations_create`
- `subs_localizations_update`
- `subs_localizations_delete`
Prices
- `subs_prices_list`
- `subs_prices_create`
- `subs_prices_delete`
Price points
- `subs_price_points_list`
Offer codes
- `subs_offer_codes_list`
- `subs_offer_codes_get`
- `subs_offer_codes_create`
- `subs_offer_codes_update`
- `subs_offer_codes_delete`
- `subs_offer_codes_one_time_list`
- `subs_offer_codes_one_time_create`
- `subs_offer_codes_custom_list`
- `subs_offer_codes_custom_create`
- `subs_offer_codes_custom_delete`
- `subs_offer_code_prices_list`
- `subs_offer_code_prices_create`
Promotional offers
- `subs_promotional_offers_lis