Saltar al contenido
Development

Dejar que Claude Code maneje Xcode: el truco de los grupos sincronizados

Por Victor Da Luz
iosswiftxcodeclaude-codedev-logdeep-cut-atlas

Esta app fue renombrada más tarde como Deep Cut Atlas. Se la llama “Discoverer” a lo largo de este texto, porque así se llamaba el día en que ocurrió esto.

Se está construyendo una app de iOS con Claude Code escribiendo la mayor parte del código. La primera tarea real fue la menos glamorosa: crear el proyecto de Xcode y el esqueleto de la app del que cuelga todo lo demás. Tres pestañas, una estructura de carpetas, los entitlements de MusicKit y CloudKit, una configuración de StoreKit. Nada difícil en sí mismo. Lo difícil es que Claude no puede hacer clic.

El problema: Claude no puede usar el asistente de New Project

Un .xcodeproj no es un archivo que se pueda escribir desde cero. Es un paquete con un project.pbxproj delicado adentro, y las propias reglas del proyecto dicen que nunca hay que dejar que un agente edite ese archivo a mano. Xcode lo reescribe constantemente y una mala edición rompe todo el proyecto. Así que el habitual “Claude, crea el proyecto” no funciona, y “Claude, edita el pbxproj” está prohibido a propósito.

Se evaluaron tres formas de sortear esto:

  • La herramienta de scaffold de XcodeBuildMCP. Puede generar un proyecto a partir de una plantilla. Pero produce un workspace más una estructura de paquete Swift, que es más estructura de la que necesita una sola app, y separa las capacidades importantes en un target de app delgado. Más piezas en movimiento desde el primer día.
  • XcodeGen. Generación declarativa de proyectos a partir de una especificación YAML. Reproducible, pero agrega una dependencia de build y choca con la regla de “dejar que Xcode sea dueño del proyecto”.
  • Crear el cascarón y dejar que Claude lo llene. Correr el asistente una vez (unos dos minutos de clics), y que Claude escriba cada archivo Swift después de eso.

Se optó por la tercera. Es la menos ingeniosa y la que encajaba con las reglas ya existentes. El proyecto vacío se crea a mano, Claude hace el otro 95%.

El truco que lo hace funcionar: grupos sincronizados

Acá está la parte que no se sabía que iba a salvar la situación. Xcode 16 cambió cómo los proyectos nuevos rastrean archivos. En vez de registrar cada archivo a mano en el pbxproj, un proyecto de App nuevo usa un grupo sincronizado con el sistema de archivos (PBXFileSystemSynchronizedRootGroup, si se busca el nombre exacto). El trato es simple: cualquier archivo que aparezca en la carpeta del target pasa a formar parte del target automáticamente. Sin diálogo de “Add Files to project”, sin edición del pbxproj.

Esa es exactamente la costura que Claude necesita. Escribe un archivo .swift en la carpeta y el archivo queda en el build. Se verificó que estuviera activo antes de escribir nada:

grep -c PBXFileSystemSynchronizedRootGroup Discoverer.xcodeproj/project.pbxproj

Tres coincidencias, todo listo. A partir de ahí Claude creó todo el árbol (App/, Features/, Models/, etc.) y escribió las vistas, el punto de entrada de la app, y un modelo provisional. La primera compilación recogió todo sin una sola edición del archivo de proyecto. Eso es lo que va a quedar de esta tarea: en Xcode 16 o superior, un agente puede tener el control completo de los archivos fuente mientras se mantenga dentro de la carpeta sincronizada.

Los inconvenientes (siempre hay inconvenientes)

Los grupos sincronizados agrupan todo lo que hay en la carpeta, lo cual causó problemas dos veces.

Primero, se pusieron archivos .gitkeep en dos carpetas vacías para mantenerlas en git. El build murió:

error: Multiple commands produce '.../Discoverer.app/.gitkeep'

Ambos .gitkeep querían copiarse al mismo lugar dentro del paquete de la app. Solución: usar un archivo Swift con nombre único que solo contenga un comentario como marcador de carpeta. Compila a nada y no se trata como recurso.

Después, ambos marcadores se llamaron Placeholder.swift. Carpetas distintas, mismo nombre. El build murió otra vez:

error: Multiple commands produce '.../Placeholder.stringsdata'

El compilador deriva un .stringsdata por archivo con el nombre del archivo fuente, así que los nombres de archivo tienen que ser únicos en todo el target, no solo dentro de una carpeta. Renombrarlos a ServicesPlaceholder.swift y UtilitiesPlaceholder.swift lo resolvió.

La trampa del deployment target

Los proyectos nuevos en Xcode 26 fijan el deployment target mínimo al del sistema operativo actual por defecto. En este caso quedó en iOS 26.5. Al intentar compilar para un simulador de iPhone 16 Pro, xcodebuild avisó que ningún dispositivo coincidía. El motivo: el runtime de simulador más nuevo instalado era el 26.2, por debajo del 26.5, así que todos los simuladores quedaban inelegibles. Una app no puede correr en un sistema operativo más viejo que su propio piso.

Bajar el target a iOS 17 lo resolvió. En el camino apareció una trampa menor. Este comando falla:

xcodebuild build -scheme Discoverer -destination 'platform=iOS Simulator,name=iPhone 16 Pro'

Un nombre de dispositivo desnudo significa “el sistema operativo más reciente”. El runtime más nuevo en esa máquina era 26.x, que solo trae modelos iPhone 17. El iPhone 16 Pro vive en el runtime 18.5. Así que hubo que especificar qué sistema operativo se quería:

xcodebuild build -scheme Discoverer -destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=18.5' | xcbeautify

Mantener el cascarón ejecutable antes de que exista CloudKit

Se marcó “Host in CloudKit” en el asistente, lo cual agrega el entitlement de CloudKit pero deja vacío el identificador del contenedor hasta que se elige un team. Un contenedor SwiftData por defecto con CloudKit activado y sin contenedor puede fallar al arrancar, y se quería un “esto corre” limpio antes de conectar iCloud. Así que se forzó el almacenamiento local por ahora:

let configuration = ModelConfiguration(cloudKitDatabase: .none)
modelContainer = try ModelContainer(for: Item.self, configurations: configuration)

El entitlement sigue declarando CloudKit para más adelante; el runtime solo se queda local hasta que el contenedor sea real. Una regla relacionada que se incorporó desde el inicio: cada propiedad de SwiftData tiene un valor por defecto o es opcional, porque los almacenes respaldados por CloudKit no pueden forzar columnas no opcionales.

Lo que se hubiera querido saber antes de empezar

El modelo mental que funcionó: el archivo de proyecto y los clics de la interfaz quedan de un lado, el código fuente del otro, en manos de Claude. Los grupos sincronizados de Xcode 16 son lo que hace esa separación limpia en vez de un flujo constante de “agrega este archivo al target”. Verificar que el grupo sincronizado esté activo, mantener los nombres de archivo únicos, fijar un deployment target que un simulador realmente pueda correr, y no activar CloudKit en el runtime hasta que haya un contenedor real detrás. La app compila, aparecen las tres pestañas, y ni una línea del pbxproj se editó a mano.

Nota de herramientas: la salida del build pasa por xcbeautify para logs legibles, y XcodeBuildMCP queda instalado para más adelante (su instalación es brew tap getsentry/xcodebuildmcp && brew install xcodebuildmcp, no el brew install a secas que aparece en notas desactualizadas).

Lecturas relacionadas

Development

Configurando Claude Code para un proyecto de iOS

Escribiendo el CLAUDE.md de una nueva app de iOS antes de que exista una sola línea de Swift: la trampa de MusicKit en el simulador, las reglas de CloudKit para SwiftData, y por qué el enfoque de proyecto de aprendizaje vino primero.

Leer