From 720794de2de36e4f892d508a07c4fc8fac4626ab Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Sat, 19 Sep 2026 23:30:00 +0800 Subject: [PATCH] chore: add project-level xcodegen agent skill --- .agents/skills/xcodegen/SKILL.md | 76 +++++ .../xcodegen/references/project-spec.md | 261 ++++++++++++++++++ .agents/skills/xcodegen/references/recipes.md | 252 +++++++++++++++++ 3 files changed, 589 insertions(+) create mode 100644 .agents/skills/xcodegen/SKILL.md create mode 100644 .agents/skills/xcodegen/references/project-spec.md create mode 100644 .agents/skills/xcodegen/references/recipes.md diff --git a/.agents/skills/xcodegen/SKILL.md b/.agents/skills/xcodegen/SKILL.md new file mode 100644 index 00000000..382e1863 --- /dev/null +++ b/.agents/skills/xcodegen/SKILL.md @@ -0,0 +1,76 @@ +--- +name: xcodegen +description: Generate and manage Xcode projects with XcodeGen — write project.yml as the source of truth and produce .xcodeproj from it instead of hand-editing pbxproj files. Use this skill whenever creating or modifying an Xcode project's structure (targets, schemes, build settings, Info.plist, entitlements, dependencies like SPM packages or frameworks), when the user mentions xcodegen / project.yml / regenerating or fixing a .xcodeproj, or when asked to add files or targets to an Xcode project — even if they don't name XcodeGen explicitly. Also applies to CI project generation and migrating a repo to a generated-project workflow. +--- + +# XcodeGen + +XcodeGen generates `.xcodeproj` files from a declarative `project.yml` spec. + +**Core mental model: `project.yml` is the source of truth; `.xcodeproj` is a disposable build artifact.** Never hand-edit the `.xcodeproj` and never tell the user to add files through Xcode's UI — every structural change (new target, new source directory, new dependency) goes through `project.yml` + regeneration. That is the entire point of the tool: no pbxproj merge conflicts, and the project structure becomes reviewable YAML in git. + +## The workflow + +1. Check the tool exists: `which xcodegen` (install with `brew install xcodegen`). If the spec uses newer features, note them in `options.minimumXcodeGenVersion`. +2. Write or edit `project.yml` at the repo/project root. +3. Generate: `xcodegen generate` (add `--use-cache` in scripts/CI so unchanged specs skip regeneration). +4. Verify the result — a generated project that doesn't build is not done: + - `xcodebuild -list -project .xcodeproj` — schemes and configurations are as expected. + - `xcodebuild -project .xcodeproj -scheme build` — the real gate. + - `xcodegen dump --spec project.yml` prints the fully-resolved spec; use it to debug `include:` merges or surprising settings presets. + +## Minimal working example + +macOS app + unit tests + scheme — the shape most projects need; adapt platform, paths, and deployment target: + +```yaml +name: MyApp +options: + bundleIdPrefix: com.example # targets without PRODUCT_BUNDLE_IDENTIFIER get com.example. + deploymentTarget: + macOS: "13.0" + createIntermediateGroups: true +targets: + MyApp: + type: application + platform: macOS + sources: + - path: MyApp # a directory: everything under it is picked up + info: + path: MyApp/Info.plist # generated and written to disk; CFBundle* keys auto-filled + properties: + LSMinimumSystemVersion: "13.0" + scheme: + testTargets: + - MyAppTests # scheme "MyApp" builds app + runs tests + MyAppTests: + type: bundle.unit-test + platform: macOS + sources: + - path: MyAppTests + dependencies: + - target: MyApp # TEST_HOST is set automatically for unit tests +``` + +```bash +xcodegen generate +xcodebuild -project MyApp.xcodeproj -scheme MyApp build +``` + +## Rules that prevent most mistakes + +- **Regenerate after adding/removing source files.** Source globs are resolved when `xcodegen generate` runs, not live by Xcode — a new `.swift` file on disk is invisible to the project until then. "My file isn't in the target" almost always means: run `xcodegen generate`. (Xcode auto-reloads a regenerated project that's already open.) +- **Never mix settings styles.** In a `settings:` block, if you use `base:`, `configs:`, or `groups:`, a sibling plain map (e.g. `MARKETING_VERSION: 1.0` at the same level) is *silently ignored*. Pick one style per block; the plain-map style only works alone. +- **Quote deployment targets**: `deploymentTarget: "14.0"`, not `14.0` (YAML parses the latter as a float and trailing zeros vanish). +- **Prefer `info:` / `entitlements:` blocks** over committing static plists: XcodeGen writes the files and sets `INFOPLIST_FILE` / `CODE_SIGN_ENTITLEMENTS` for you, and fills `CFBundleIdentifier`, `CFBundleExecutable`, `CFBundleShortVersionString` etc. automatically. Anything you put in `properties:` wins. Info.plist files found in sources are never added to build phases. +- **Mark generated/optional paths** with `optional: true` on a source, or generation fails when the directory doesn't exist yet. +- **Test targets**: declare the app as a `dependencies: - target:` of the test target — that alone wires up `TEST_HOST` (unit tests) or `TEST_TARGET_NAME` (UI tests, type `bundle.ui-testing`). +- **Extensions embed themselves**: a dependency of an `application` target defaults to `embed: true`, so `- target: MyExtension` is usually all an app needs — no copy-files phase to write. +- **Extension bundle IDs must live under the app's bundle ID.** macOS/iOS validate that an embedded extension's bundle ID starts with the parent app's — `com.example.MyProxy.Tunnel`, not `com.example.Tunnel`. Since `bundleIdPrefix` auto-derivation gives every target `prefix.TargetName`, extensions fail a *build-validation* step (not compilation) with "Embedded binary's bundle identifier is not prefixed with the parent app's bundle identifier". Always set `PRODUCT_BUNDLE_IDENTIFIER` explicitly on extension targets (see recipes.md #2). +- **`.gitignore` the generated project** (`*.xcodeproj`) so no one edits it by accident. If the repo currently tracks one, flag the migration step (remove from index, regenerate locally) rather than doing it silently. +- **`xcodegen generate` is idempotent.** When in doubt, re-run it; there is no state to corrupt. + +## Choosing between the reference files + +- `references/project-spec.md` — syntax cheat sheet: every section of `project.yml` with exact key names. Read when you need a field you don't remember precisely (source exclude patterns, dependency options, scheme fields, SPM version constraints). +- `references/recipes.md` — complete copy-pasteable `project.yml` examples: app + unit/UI tests, app extension (e.g. network extension) with entitlements, SPM remote/local packages, per-config xcconfig, vendored pre-built binaries/frameworks, build-script phases, splitting a spec with `include:`. Read before writing any non-trivial spec — reuse a recipe rather than assembling from memory. diff --git a/.agents/skills/xcodegen/references/project-spec.md b/.agents/skills/xcodegen/references/project-spec.md new file mode 100644 index 00000000..24510b48 --- /dev/null +++ b/.agents/skills/xcodegen/references/project-spec.md @@ -0,0 +1,261 @@ +# project.yml Syntax Cheat Sheet + +Condensed from the official ProjectSpec docs (XcodeGen 2.4x). When this file and the official docs disagree, trust the docs: https://github.com/yonaskolb/XcodeGen/blob/master/Docs/ProjectSpec.md + +## Top level + +```yaml +name: MyProject # required +include: [base.yml] # merge other specs (see bottom of this file) +options: {...} # project-wide behavior (below) +attributes: {...} # raw PBXProject attributes (rarely needed) +configs: {...} # build configurations (default: Debug/Release) +configFiles: {Debug: path.xcconfig} +settingGroups: {name: {settings}} # reusable settings, referenced via settings.groups +settings: {...} # project-level build settings +targets: {TargetName: {...}} # the targets +fileGroups: [dir-or-file] # shown in project navigator, not in any target +schemes: {SchemeName: {...}} +schemeTemplates: {...} +targetTemplates: {...} +packages: {PackageName: {...}} # Swift packages +projectReferences: {Name: {path: Other.xcodeproj}} +``` + +## Options (commonly used) + +```yaml +options: + bundleIdPrefix: com.example # auto bundle IDs: prefix + target name + deploymentTarget: # project-wide defaults + macOS: "13.0" + iOS: "16.0" + createIntermediateGroups: true # groups for intermediate path components + minimumXcodeGenVersion: "2.38.0" + developmentLanguage: zh-Hans # default "en" + transitivelyLinkDependencies: false # link deps-of-deps (default false) + generateEmptyDirectories: false + groupSortPosition: bottom # top | bottom | none + groupOrdering: # custom group order in navigator + - order: [Sources, Resources, Tests] + preGenCommand: make resources # bash, skipped when cache skips generation + postGenCommand: pod install + defaultConfig: Debug + defaultSourceDirectoryType: group # group | folder | syncedFolder (xcode16_0+) +``` + +Environment variable expansion works in any string: `${SOME_VAR}` (disable with `xcodegen generate -n`). + +## Configs + +Each config maps to a build type; anything other than `debug`/`release` gets no default settings: + +```yaml +configs: + Debug: debug + Beta: release + AppStore: release +``` + +## Settings + +Two forms — **never mix them in one block** (plain keys are silently dropped if `base`/`configs`/`groups` is present): + +```yaml +settings: + SWIFT_VERSION: "5.9" # form 1: simple map + +settings: # form 2: structured + base: + MARKETING_VERSION: "1.0" + configs: # key matched case-insensitively/partially to config names + debug: + CODE_SIGN_IDENTITY: Apple Development + release: + CODE_SIGN_IDENTITY: Apple Distribution + groups: [shared-settings] # references to settingGroups +``` + +## Target + +```yaml +targets: + MyTarget: + type: application # required, see list below + platform: macOS # required: macOS | iOS | tvOS | watchOS | visionOS | auto + supportedDestinations: [macOS, macCatalyst] # alternative to platform (Xcode 14+) + deploymentTarget: "13.0" # or per-platform map for multi-platform targets + sources: [...] + dependencies: [...] + settings: {...} + configFiles: {Debug: path.xcconfig} + info: {...} # generates Info.plist + entitlements: {...} # generates .entitlements + templates: [Name] # from targetTemplates; ${target_name} substitution + templateAttributes: {attr: value} + preBuildScripts: [...] # also postCompileScripts, postBuildScripts + buildRules: [...] + buildToolPlugins: [{plugin: P, package: PKG}] + scheme: {...} # per-target convenience scheme + attributes: {...} + transitivelyLinkDependencies: true + requiresObjCLinking: true # default true only for library.static +``` + +Product types (attribute to `type:`): `application`, `app-extension`, `extensionkit-extension`, `framework`, `framework.static`, `library.static`, `library.dynamic`, `bundle`, `bundle.unit-test`, `bundle.ui-testing`, `tool`, `xpc-service`, `system-extension`, `watchkit2-extension`, `application.messages`, `application.watchapp2`, and friends. + +Settings set automatically for you (don't set by hand): `INFOPLIST_FILE` (if an Info.plist is found in sources or defined via `info:`), `TEST_HOST` (unit tests depending on an app), `TEST_TARGET_NAME` (UI tests depending on an app), `FRAMEWORK_SEARCH_PATHS` (Carthage), `OTHER_LDFLAGS` (ObjC linking). + +Multi-platform: `platform: [iOS, tvOS]` generates one target per platform named `Name_iOS`, `Name_tvOS`; `${platform}` interpolates inside the target spec. + +## Sources + +A source is a path string or an object. Directories are expanded at **generation time** — new files require regeneration. + +```yaml +sources: + - Sources # whole directory + - path: Sources + excludes: # bash-4 globstar patterns, relative to `path` + - "**/*.md" + - "ios/*.[mh]" + - "*-Private.h" + includes: ["**/*.swift"] # whitelist; excludes win on conflict + compilerFlags: ["-Werror"] + createIntermediateGroups: true + optional: true # don't fail if the path doesn't exist + headerVisibility: public # public | private | project + type: group # group (default for dirs) | file | folder | syncedFolder + buildPhase: sources # sources | resources | headers | none, or copyFiles: {destination: …, subpath: …} + - path: Vendor/Binaries + buildPhase: none # show in navigator only + - path: MyFile.swift # single file + buildPhase: + copyFiles: + destination: wrapper # absolutePath | productsDirectory | wrapper | executables | + subpath: Contents/Tools # resources | javaResources | frameworks | sharedFrameworks | + # sharedSupport | plugins +``` + +## Dependencies + +```yaml +dependencies: + - target: OtherTarget # links; embeds if this is an application (embed default true) + - target: OtherProject/OtherTarget # via projectReferences + - package: SomePackage # name from top-level packages + product: LibraryName # only if product ≠ package name + products: [A, B] # multiple products + - framework: Vendor/Foo.xcframework + embed: true # default true for apps + codeSign: true + weak: false + - sdk: libc++.tbd # or Contacts.framework, libz.dylib + - carthage: Alamofire # linkType: static|dynamic, findFrameworks: bool + - bundle: Prebuilt.bundle # adds to Copy Resources +``` + +## Info.plist and entitlements (`info:` / `entitlements:`) + +Files are written to disk on every generation. `info` auto-fills CFBundleIdentifier, CFBundleInfoDictionaryVersion, CFBundleExecutable, CFBundleName, CFBundleDevelopmentRegion, CFBundleShortVersionString, CFBundleVersion, CFBundlePackageType where appropriate; `entitlements` requires all properties explicitly. + +```yaml +info: + path: App/Info.plist + properties: + LSMinimumSystemVersion: "13.0" +entitlements: + path: App/App.entitlements + properties: + com.apple.security.application-groups: [group.com.example.app] +``` + +## Build scripts + +```yaml +preBuildScripts: # also postCompileScripts, postBuildScripts + - name: SwiftLint + script: swiftlint # or path: scripts/lint.sh + inputFiles: ["$(SRCROOT)/file1"] + outputFileLists: ["$(SRCROOT)/outputs.xcfilelist"] + showEnvVars: false + basedOnDependencyAnalysis: true # skip when inputs/outputs unchanged + runOnlyWhenInstalling: false +``` + +## Schemes + +Per-target convenience (preferred when one scheme ≈ one target): + +```yaml +targets: + App: + scheme: + testTargets: [AppTests] + configVariants: [Staging, Production] # generates one scheme per variant + gatherCoverageData: true + commandLineArguments: {"-MyArg": true} + environmentVariables: {KEY: VALUE} +``` + +Top-level (full control; `build.targets` is required): + +```yaml +schemes: + MyApp: + build: + targets: + MyApp: all # all | none | [run, test, profile, analyze, archive] + MyAppTests: [test] + run: + config: Debug + commandLineArguments: {"-InMemory": true} + environmentVariables: + - variable: API_ENV + value: staging + isEnabled: true + test: + gatherCoverageData: true + targets: + - AppTests + - name: UITests + parallelizable: true + randomExecutionOrder: true + skippedTests: [UITests/testFlaky()] + archive: + config: Release + customArchiveName: MyApp +``` + +## Swift packages + +```yaml +packages: + Yams: + url: https://github.com/jpsim/Yams + from: 2.0.0 # or majorVersion / minorVersion / exactVersion / version, + # minVersion+maxVersion, branch, revision + Alamofire: + github: Alamofire/Alamofire # shorthand for github URLs + exactVersion: 5.8.0 + LocalLib: + path: Packages/LocalLib # directory containing Package.swift + group: LocalPackages # navigator group, default "Packages" +``` + +Link into targets with `dependencies: - package: Yams`. + +## Splitting the spec (`include`) + +Included files merge additively (dicts merge, arrays append); suffix a key with `:REPLACE` to override instead of append. Paths inside included files are relative to that file unless `relativePaths: false`. + +```yaml +include: + - base.yml + - path: extra.yml + relativePaths: false + enable: ${INCLUDE_EXTRA} +targets: + App: # target defined in base.yml + sources:REPLACE: [NewSources] +``` diff --git a/.agents/skills/xcodegen/references/recipes.md b/.agents/skills/xcodegen/references/recipes.md new file mode 100644 index 00000000..f1685908 --- /dev/null +++ b/.agents/skills/xcodegen/references/recipes.md @@ -0,0 +1,252 @@ +# project.yml Recipes + +Complete, copy-pasteable specs. Combine the parts you need; trim what you don't. Each recipe is self-contained except where noted. + +## 1. iOS app + unit tests + UI tests + +```yaml +name: MyApp +options: + bundleIdPrefix: com.example + deploymentTarget: + iOS: "16.0" +targets: + MyApp: + type: application + platform: iOS + sources: [MyApp] + info: + path: MyApp/Info.plist + properties: + UILaunchScreen: {} # modern no-storyboard launch + UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait] + scheme: + testTargets: [MyAppTests, MyAppUITests] + MyAppTests: + type: bundle.unit-test + platform: iOS + sources: [MyAppTests] + dependencies: + - target: MyApp # TEST_HOST set automatically + MyAppUITests: + type: bundle.ui-testing + platform: iOS + sources: [MyAppUITests] + dependencies: + - target: MyApp # TEST_TARGET_NAME set automatically +``` + +## 2. macOS app with an embedded extension (network extension, share extension, …) + +Extensions need three things: the right `type`, an `NSExtension` (or `NSSystemExtension`) dict in Info.plist properties, and usually entitlements. The app embeds them automatically — no copy phase needed. + +```yaml +name: MyProxy +options: + bundleIdPrefix: com.example + deploymentTarget: + macOS: "13.0" +targets: + MyProxy: + type: application + platform: macOS + sources: + - path: MyProxy + info: + path: MyProxy/Info.plist + properties: + LSMinimumSystemVersion: "13.0" + entitlements: + path: MyProxy/MyProxy.entitlements + properties: + com.apple.security.app-sandbox: true + com.apple.security.network.client: true + com.apple.security.network.server: true + dependencies: + - target: Tunnel # embedded into Contents/PlugIns automatically + scheme: + testTargets: [MyProxyTests] + Tunnel: + type: app-extension # Network Extension packet-tunnel providers use this type; + # use system-extension for DriverKit/transparent-proxy style + platform: macOS + sources: [Tunnel] + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.example.MyProxy.Tunnel # must be prefixed by the app's + # bundle ID; bundleIdPrefix auto-derivation (com.example.Tunnel) + # fails at build validation, not compile + info: + path: Tunnel/Info.plist + properties: + CFBundleDisplayName: Tunnel + NSExtension: + NSExtensionPointIdentifier: com.apple.networkextension.packet-tunnel + entitlements: + path: Tunnel/Tunnel.entitlements + properties: + com.apple.security.app-sandbox: true + com.apple.security.network.client: true + MyProxyTests: + type: bundle.unit-test + platform: macOS + sources: [MyProxyTests] + dependencies: + - target: MyProxy +``` + +Note: Network Extension entitlements (`com.apple.developer.networking.networkextension`) require a provisioning profile from Apple; generation and local builds succeed without it, distribution signing does not. Don't "fix" a signing failure by inventing entitlement values. + +## 3. SPM dependencies (remote and local) + +```yaml +name: MyApp +options: + bundleIdPrefix: com.example +packages: + SnapshotTesting: + url: https://github.com/pointfreeco/swift-snapshot-testing + from: 1.17.0 + Alamofire: + github: Alamofire/Alamofire # shorthand + exactVersion: 5.8.0 + FeatureKit: + path: Packages/FeatureKit # local package (dir with Package.swift) +targets: + MyApp: + type: application + platform: iOS + sources: [MyApp] + dependencies: + - package: Alamofire + - package: FeatureKit + product: FeatureKitDomain # only when product ≠ package name + MyAppTests: + type: bundle.unit-test + platform: iOS + sources: [MyAppTests] + dependencies: + - target: MyApp + - package: SnapshotTesting + products: [SnapshotTesting] # or use `product:` for one +``` + +## 4. Custom configs + xcconfig files + +```yaml +name: MyApp +options: + bundleIdPrefix: com.example +configs: + Debug: debug + Beta: release # maps to release-type defaults + AppStore: release +configFiles: # project level + Debug: Configs/project-debug.xcconfig +targets: + MyApp: + type: application + platform: iOS + sources: [MyApp] + configFiles: # target level overrides + Debug: Configs/app-debug.xcconfig + Beta: Configs/app-beta.xcconfig + AppStore: Configs/app-appstore.xcconfig + settings: + base: + XCConfigOnlyWhenNotInFile: value # xcconfig wins for keys it defines +schemes: + MyApp-Beta: + build: + targets: {MyApp: all} + run: + config: Beta + archive: + config: AppStore +``` + +## 5. Vendored pre-built binaries and frameworks + +Pre-built CLI tools and xcframeworks checked into the repo (not built from source): + +```yaml +targets: + MyApp: + type: application + platform: macOS + sources: + - path: MyApp + - path: Vendor/Tools/ss-local # a single pre-built executable + buildPhase: + copyFiles: + destination: wrapper + subpath: Contents/Tools # ends up at MyApp.app/Contents/Tools/ss-local + - path: Vendor/Tools/privoxy + buildPhase: + copyFiles: + destination: wrapper + subpath: Contents/Tools + dependencies: + - framework: Vendor/Frameworks/SwiftZip.xcframework +``` + +Keep vendored binaries out of any target's plain `sources:` glob (or exclude them), otherwise XcodeGen guesses a build phase from the extension. Executables without extension are left alone, but being explicit with `buildPhase: copyFiles` documents intent. + +## 6. Build script phases with proper inputs/outputs + +Scripts without declared inputs/outputs re-run every build and break incremental builds — declare them: + +```yaml +targets: + MyApp: + type: application + platform: iOS + sources: [MyApp] + preBuildScripts: + - name: Generate Protos + script: | + ./scripts/generate-protos.sh + inputFiles: + - $(SRCROOT)/Protos/messages.proto + outputFiles: + - $(DERIVED_FILE_DIR)/Messages.pb.swift + basedOnDependencyAnalysis: true + postBuildScripts: + - name: SwiftLint + script: swiftlint + showEnvVars: false + - name: Crash Reporter Upload + script: ./scripts/upload-dsyms.sh + runOnlyWhenInstalling: true +``` + +## 7. Splitting a big spec across files + +`include` merges additively (dicts merge, arrays append); add `:REPLACE` to overwrite a list instead of appending to it. + +```yaml +# project.yml +name: MyApp +include: + - project/targets.yml + - project/schemes.yml +options: + bundleIdPrefix: com.example +``` + +```yaml +# project/targets.yml +targets: + MyApp: + type: application + platform: iOS + sources: [MyApp] + MyAppTests: + type: bundle.unit-test + platform: iOS + sources: [MyAppTests] + dependencies: + - target: MyApp +``` + +Use `xcodegen dump --spec project.yml` to inspect the merged result when an override doesn't behave.