This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
You MUST write a failing test before writing any production code. This rule overrides every other instinct, including "the change is small", "it's just a one-liner", "I'll add the test after". If you catch yourself opening a file under Sources/ before a test under Tests/ exists and fails, stop and reverse course.
Pre-implementation gate — before editing anything in Sources/, you must have done all of the following in order:
- Stated the user-facing behaviour in one sentence (e.g. "a version is live when state is
READY_FOR_SALE"). - Written a test in
Tests/that asserts the exact expected output for that behaviour. - Run
swift test(or a--filter'd subset) and observed the test fail — compile error counts as a failing test only if the assertion is the reason it can't compile (e.g. missing symbol the test names). - Reported the red result back to the user (one line is fine: "test X fails with: ").
Only after step 4 may you write code under Sources/. The full workflow, naming rules, and framework details are in the Testing section.
If you skip the gate, you are violating the project's primary rule. Treat this the same as committing secrets or force-pushing main.
# Build
swift build # Debug build
swift build -c release # Release build
# Test
swift test # All tests
swift test --filter 'AppTests' # Tests matching a pattern
swift test --enable-code-coverage # With coverage
# Run
swift run asc <args>
make run ARGS="apps list"Three strict layers with a unidirectional dependency flow: ASCCommand → Infrastructure → Domain
Sources/
├── Domain/ # Pure value types, @Mockable protocols — zero I/O
├── Infrastructure/# Implements Domain protocols via appstoreconnect-swift-sdk
└── ASCCommand/ # CLI entry point, output formatting, TUI
All models are public struct + Sendable + Equatable + Codable. The JSON encoding is the public schema. Models with optional text fields use custom Codable with encodeIfPresent to omit nil values from JSON output.
Design rules:
- Every model carries its parent ID (e.g.
AppStoreVersion.appId,AppScreenshot.setId) — the App Store Connect API doesn't return parent IDs, so Infrastructure injects them - State enums expose semantic booleans (
isLive,isEditable,isPending,isComplete) for agent decision-making - All repositories and providers are
@Mockableprotocols
Adapts appstoreconnect-swift-sdk to Domain protocols. The critical pattern: mappers always inject the parent ID from the request parameter into every mapped response object.
ASC.swift—@mainentry, registers all subcommandsGlobalOptions.swift—--output(default: json),--pretty,--timeoutOutputFormatter.swift— JSON/table/markdown rendering;formatAgentItems()merges affordancesClientProvider.swift— factory wiring auth → authenticated repositoriesCommands/Web/—asc web-serverserves the REST API;RESTRoutes.configurecomposes*Controllerstructs (Hummingbird). Every new list/read command must also be exposed here (see "REST exposure" below)
CLI equivalent of REST HATEOAS. Every response includes an affordances field with ready-to-run CLI commands so an AI agent can navigate without knowing the command tree. Affordances are state-aware — e.g. submitForReview only appears when isEditable == true.
All domain models implement AffordanceProviding:
protocol AffordanceProviding {
var affordances: [String: String] { get }
}OutputFormatter.formatAgentItems() merges affordances into the encoded JSON output.
The asc web-server command exposes the same functionality as the CLI over HTTP so an agent can drive it as a REST service. A feature is not complete until it is reachable via REST.
Required steps when adding a new list/read command (or any command that returns data an agent might want over HTTP):
- Make the domain model
Presentable—tableHeaders+tableRow. Needed becauserestFormatis<T: Encodable & AffordanceProviding & Presentable> - Use
structuredAffordances(not rawaffordances) on the model — the REST renderer derives_linksfromAffordancevalues; returning a plain[String: String]leaves_linksempty - Give the command an
affordanceModeparameter —func execute(repo:…, affordanceMode: AffordanceMode = .cli)forwards toformatter.formatAgentItems(items, affordanceMode: affordanceMode). The sameexecuteruns for bothcliandrestmodes - Add/extend a controller under
Sources/ASCCommand/Commands/Web/Controllers/— inject the repository, register agroup.get("/…")route, parse query params viarequest.uri.queryParameters(use the same names as the CLI flags, e.g.?state=&limit=&expired-only=&before=), call the repository, returntry restFormat(items) - Wire the controller in
Sources/ASCCommand/Commands/Web/RESTRoutes.swift(construct withfactory.make…Repository(authProvider: auth)) - Advertise the resource from
APIRoot.structuredAffordancessoGET /api/v1lists the new top-level resource - Add a REST test in
Tests/ASCCommandTests/Commands/Web/RESTRoutesTests.swift— callexecute(repo:affordanceMode: .rest)and assert the output contains"_links"and the resolved REST paths - CLI and REST query-param names must match — if the CLI uses
--expired-only, the REST query param is?expired-only=true; both go through the same repository method
Shared helpers (all in Sources/ASCCommand/Commands/Web/RESTRoutes.swift):
restFormat(items)— REST equivalent offormatter.formatAgentItems(items, affordanceMode: .rest)jsonError(message, status:)— JSON error response (lives inInfrastructure/Web/ASCWebServer.swift;import Infrastructure)
Controllers are structs with dependencies injected at init (Hummingbird pattern). Repositories are constructed once in RESTRoutes.configure, never per request.
Commands mirror the App Store Connect API hierarchy exactly:
App → AppStoreVersion → AppStoreVersionLocalization → AppScreenshotSet → AppScreenshot
App → AppInfo → AppInfoLocalization
App → AppInfo → AgeRatingDeclaration
AppCategory (top-level, not nested under App)
App → CustomerReview → CustomerReviewResponse
App → Build → BetaBuildLocalization
App → BuildUpload
App → TestFlight (BetaGroup → BetaTester)
App → CiProduct (XcodeCloud) → CiWorkflow → CiBuildRun
AppStoreVersion → VersionReadiness
AppStoreVersion → AppStoreReviewDetail
CodeSigning: BundleID → Profile
App → PerformanceMetric (via perfPowerMetrics)
Build → PerformanceMetric (via perfPowerMetrics)
Build → DiagnosticSignatureInfo → DiagnosticLogEntry
Domain folders are nested to mirror the resource hierarchy:
Domain/
├── Apps/ → App, AppRepository
│ ├── Versions/ → AppStoreVersion, AppStoreVersionState, VersionReadiness,
│ │ │ VersionRepository, ReviewDetailRepository,
│ │ │ AppStoreReviewDetail, ReviewDetailUpdate
│ │ └── Localizations/ → AppStoreVersionLocalization, VersionLocalizationRepository
│ │ └── ScreenshotSets/ → AppScreenshotSet, ScreenshotDisplayType, ScreenshotRepository
│ │ └── Screenshots/ → AppScreenshot
│ ├── AppInfos/ → AppInfo, AppInfoLocalization, AppInfoRepository,
│ │ AppCategory, AppCategoryRepository,
│ │ AgeRatingDeclaration, AgeRatingDeclarationRepository
│ ├── Reviews/ → CustomerReview, CustomerReviewResponse, ReviewResponseState,
│ │ CustomerReviewRepository
│ ├── Builds/ → Build, BuildUpload, BetaBuildLocalization,
│ │ BuildRepository, BuildUploadRepository, BetaBuildLocalizationRepository
│ ├── Pricing/ → PricingRepository
│ ├── TestFlight/ → BetaGroup, BetaTester, TestFlightRepository
│ └── Performance/ → PerformanceMetric, PerformanceMetricCategory, DiagnosticSignatureInfo,
│ DiagnosticType, DiagnosticLogEntry, PerfMetricsRepository, DiagnosticsRepository
├── CodeSigning/ → BundleID, Certificate, Device, Profile + their repositories
│ ├── BundleIDs/ → BundleID, BundleIDRepository
│ ├── Certificates/ → Certificate, CertificateRepository
│ ├── Devices/ → Device, DeviceRepository
│ └── Profiles/ → Profile, ProfileRepository
├── Submissions/ → ReviewSubmission, ReviewSubmissionState, SubmissionRepository
├── Auth/ → AuthCredentials, AuthProvider, AuthStatus, AuthStorage, CredentialSource, AuthError
├── Projects/ → ProjectConfig, ProjectConfigStorage
├── Skills/ → Skill, SkillCheckResult, SkillConfig, SkillRepository, SkillConfigStorage
└── Shared/ → AffordanceProviding, APIError, OutputFormat, PaginatedResponse
Infrastructure and test folders mirror this exact structure.
asc init saves the app ID, name, and bundle ID to .asc/project.json in the current directory:
asc init # auto-detect from *.xcodeproj bundle ID
asc init --name "X" # search by name
asc init --app-id <id>FileProjectConfigStorage (Infrastructure) reads/writes .asc/project.json relative to cwd. ProjectConfig (Domain) carries appId, appName, bundleId + CAEOAS affordances.
We follow the Chicago School of TDD — state-based, not interaction-based. Tests verify what domain objects return and compute, not how they call collaborators. The non-negotiable rule and pre-implementation gate live at the top of this file; everything below is the framework-specific detail.
Red → green → refactor, in that order, every time:
- Think from the user's mental model. Describe the behaviour as the user would: "a version is live when its state is
readyForSale", "submit is only available when the version is editable". Don't describe internal calls. - Write the test. Name it after the user's expectation. Assert exact output values (e.g.
"IOS","READY_FOR_SALE","expired": true) — not "is non-empty" or "doesn't throw". - Run the test and confirm it fails (red). If it passes before you wrote any code, it isn't testing new behaviour — fix the test.
- Implement just enough code to make it pass (green). No extra fields, no speculative branches.
- Refactor while green. Tests stay passing throughout.
Hard rules:
- Difficult to test = design problem, not a testing exception. Refactor the design, don't skip the test.
- Never modify a test to make it pass. If a test fails unexpectedly, the spec (step 1) was wrong — fix the thinking, not the assertion.
- No "I'll add tests after". Production code without a preceding red test is a defect, even if it works.
Framework:
@Testingmacro (not XCTest).@Mockableon protocols;given().willReturn()in tests.- Test names use backticks:
func `version is live when state is readyForSale`(). - Shared test data:
Tests/DomainTests/TestHelpers/MockRepositoryFactory.swift.
The codebase has two distinct localization concepts with separate repositories:
| Type | Domain folder | Repository | Commands | Data |
|---|---|---|---|---|
AppStoreVersionLocalization |
Domain/Localizations/ |
VersionLocalizationRepository |
asc version-localizations * |
whatsNew, description, keywords, screenshots |
AppInfoLocalization |
Domain/AppInfos/ |
AppInfoRepository |
asc app-info-localizations * |
name, subtitle, privacyPolicyUrl, privacyChoicesUrl, privacyPolicyText |
ScreenshotRepository (in Domain/ScreenshotSets/) handles screenshot sets and screenshot images — no localization methods.
After every code change — new feature, improvement, or bug fix — update all affected docs before considering the task done.
| Change type | Files to update |
|---|---|
| New feature / command | docs/features/<feature>.md (create, include a REST Endpoints section), CHANGELOG.md ([Unreleased]), README.md (feature list + CLI examples), skills/ (relevant skill files), Sources/ASCCommand/Commands/Web/Controllers/ (new or extended controller), Sources/ASCCommand/Commands/Web/RESTRoutes.swift (wire controller), Sources/Domain/Shared/APIRoot.swift (advertise new top-level resource), Tests/ASCCommandTests/Commands/Web/RESTRoutesTests.swift (REST test) |
| Improvement / enhancement | docs/features/<feature>.md (update affected sections), CHANGELOG.md ([Unreleased]) |
| Bug fix | CHANGELOG.md ([Unreleased]) |
| Architecture / API change | CLAUDE.md (update architecture / patterns sections), docs/features/<feature>.md |
| Auth / config change | CLAUDE.md (Authentication section), README.md |
docs/features/<feature>.md — write from actual code (read files first, never from memory). Structure:
- CLI Usage — flags table + examples + output samples (json + table)
- REST Endpoints — path table + query-param mapping (CLI flag → REST query) + curl example
- Typical Workflow — end-to-end bash script showing the happy path
- Architecture — three-layer ASCII diagram + dependency note
- Domain Models — every public struct/enum/protocol with fields, computed properties, affordances
- File Map —
Sources/andTests/trees + wiring files table (must list the REST controller) - API Reference — endpoint → SDK call → repository method
- Testing — representative test snippet +
swift testcommand - Extending — natural next steps with stub code
CHANGELOG.md — add entry under [Unreleased] using Keep a Changelog format:
### Addedfor new features/commands### Changedfor improvements to existing behaviour### Fixedfor bug fixes
README.md — update the feature/command table and any usage examples that changed.
skills/<feature> — always use the /skill-creator skill to create or update feature skills
Key skills to keep in sync:
implement-feature/SKILL.md— workflow + checklistasc-cli/references/commands.md— command reference- Feature-specific skills (
asc-testflight,asc-beta-review,asc-builds-upload,asc-code-signing,asc-check-readiness,asc-app-previews,asc-app-shots,asc-review-detail,asc-plugins, etc.)
CLAUDE.md — update when architecture patterns, file locations, or design rules change.
Option A — Persistent login (recommended):
asc auth login --key-id <id> --issuer-id <id> --private-key-path ~/.asc/AuthKey_XXXXXX.p8 [--vendor-number <number>]
asc auth update --vendor-number <number> # add vendor number to existing account
asc auth logout # remove saved credentials
asc auth check # verify credentials; shows source: "file" or "environment"Credentials saved to ~/.asc/credentials.json. Vendor number (optional) is used by sales-reports and finance-reports commands — auto-resolved from the active account when --vendor-number is omitted.
Option B — Environment variables:
export ASC_KEY_ID="YOUR_KEY_ID"
export ASC_ISSUER_ID="YOUR_ISSUER_ID"
export ASC_PRIVATE_KEY_PATH="~/.asc/AuthKey_XXXXXX.p8"
# OR use ASC_PRIVATE_KEY with the PEM content directlyResolution order: ~/.asc/credentials.json → environment variables, handled by CompositeAuthProvider in Infrastructure. EnvironmentAuthProvider is the fallback.