# CLI-Only Workflow
Build, run, debug, and monitor macOS apps entirely from command line without opening Xcode.
```bash
# Ensure Xcode is installed and selected
xcode-select -p
# Should show: /Applications/Xcode.app/Contents/Developer
# If not, run:
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
# Install XcodeGen for project creation
brew install xcodegen
# Optional: prettier build output
brew install xcbeautify
```
**Create a new project entirely from CLI**:
```bash
# Create directory structure
mkdir MyApp && cd MyApp
mkdir -p Sources Tests Resources
# Create project.yml (Claude generates this)
cat > project.yml << 'EOF'
name: MyApp
options:
bundleIdPrefix: com.yourcompany
deploymentTarget:
macOS: "14.0"
targets:
MyApp:
type: application
platform: macOS
sources: [Sources]
settings:
PRODUCT_BUNDLE_IDENTIFIER: com.yourcompany.myapp
DEVELOPMENT_TEAM: YOURTEAMID
EOF
# Create app entry point
cat > Sources/MyApp.swift << 'EOF'
import SwiftUI
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
Text("Hello, World!")
}
}
}
EOF
# Generate .xcodeproj
xcodegen generate
# Verify
xcodebuild -list -project MyApp.xcodeproj
# Build
xcodebuild -project MyApp.xcodeproj -scheme MyApp build
```
See [project-scaffolding.md](project-scaffolding.md) for complete project.yml templates.
```bash
# See available schemes and targets
xcodebuild -list -project MyApp.xcodeproj
```
```bash
# Build debug configuration
xcodebuild -project MyApp.xcodeproj \
-scheme MyApp \
-configuration Debug \
-derivedDataPath ./build \
build
# Output location
ls ./build/Build/Products/Debug/MyApp.app
```
```bash
# Build release configuration
xcodebuild -project MyApp.xcodeproj \
-scheme MyApp \
-configuration Release \
-derivedDataPath ./build \
build
```
```bash
# Build with code signing for distribution
xcodebuild -project MyApp.xcodeproj \
-scheme MyApp \
-configuration Release \
-derivedDataPath ./build \
CODE_SIGN_IDENTITY="Developer ID Application: Your Name" \
DEVELOPMENT_TEAM=YOURTEAMID \
build
```
```bash
# Clean build artifacts
xcodebuild -project MyApp.xcodeproj \
-scheme MyApp \
clean
# Remove derived data
rm -rf ./build
```
Build output goes to stdout. Filter for errors:
```bash
xcodebuild -project MyApp.xcodeproj -scheme MyApp build 2>&1 | grep -E "error:|warning:"
```
For prettier output, use xcpretty (install with `gem install xcpretty`):
```bash
xcodebuild -project MyApp.xcodeproj -scheme MyApp build | xcpretty
```
```bash
# Run the built app
open ./build/Build/Products/Debug/MyApp.app
# Or run directly (shows stdout in terminal)
./build/Build/Products/Debug/MyApp.app/Contents/MacOS/MyApp
```
```bash
# Pass command line arguments
./build/Build/Products/Debug/MyApp.app/Contents/MacOS/MyApp --debug-mode
# Pass environment variables
MYAPP_DEBUG=1 ./build/Build/Products/Debug/MyApp.app/Contents/MacOS/MyApp
```
```bash
# Run in background (don't bring to front)
open -g ./build/Build/Products/Debug/MyApp.app
# Run hidden (no dock icon)
open -j ./build/Build/Products/Debug/MyApp.app
```
Add logging to your Swift code:
```swift
import os
class DataService {
private let logger = Logger(subsystem: "com.yourcompany.MyApp", category: "Data")
func loadItems() async throws -> [Item] {
logger.info("Loading items...")
do {
let items = try await fetchItems()
logger.info("Loaded \(items.count) items")
return items
} catch {
logger.error("Failed to load items: \(error.localizedDescription)")
throw error
}
}
func saveItem(_ item: Item) {
logger.debug("Saving item: \(item.id)")
// ...
}
}
```
**Log levels**:
- `.debug` - Verbose development info
- `.info` - General informational
- `.notice` - Notable conditions
- `.error` - Errors
- `.fault` - Critical failures
```bash
# Stream logs from your app (run while app is running)
log stream --predicate 'subsystem == "com.yourcompany.MyApp"' --level info
# Filter by category
log stream --predicate 'subsystem == "com.yourcompany.MyApp" and category == "Data"'
# Filter by process name
log stream --predicate 'process == "MyApp"' --level debug
# Include debug messages
log stream --predicate 'subsystem == "com.yourcompany.MyApp"' --level debug
# Show only errors
log stream --predicate 'subsystem == "com.yourcompany.MyApp" and messageType == error'
```
```bash
# Search recent logs (last hour)
log show --predicate 'subsystem == "com.yourcompany.MyApp"' --last 1h
# Search specific time range
log show --predicate 'subsystem == "com.yourcompany.MyApp"' \
--start "2024-01-15 10:00:00" \
--end "2024-01-15 11:00:00"
# Export to file
log show --predicate 'subsystem == "com.yourcompany.MyApp"' --last 1h > app_logs.txt
```
```bash
# See app lifecycle events
log stream --predicate 'process == "MyApp" or (sender == "lsd" and message contains "MyApp")'
# Network activity (if using NSURLSession)
log stream --predicate 'subsystem == "com.apple.network" and process == "MyApp"'
# Core Data / SwiftData activity
log stream --predicate 'subsystem == "com.apple.coredata"'
```
```bash
# Start app, then attach lldb
./build/Build/Products/Debug/MyApp.app/Contents/MacOS/MyApp &
# Attach by process name
lldb -n MyApp
# Or attach by PID
lldb -p $(pgrep MyApp)
```
```bash
# Launch app under lldb directly
lldb ./build/Build/Products/Debug/MyApp.app/Contents/MacOS/MyApp
# In lldb:
(lldb) run
```
```bash
# In lldb session:
# Set breakpoint by function name
(lldb) breakpoint set --name saveItem
(lldb) b DataService.swift:42
# Set conditional breakpoint
(lldb) breakpoint set --name saveItem --condition 'item.name == "Test"'
# Continue execution
(lldb) continue
(lldb) c
# Step over/into/out
(lldb) next
(lldb) step
(lldb) finish
# Print variable
(lldb) p item
(lldb) po self.items
# Print with format
(lldb) p/x pointer # hex
(lldb) p/t flags # binary
# Backtrace
(lldb) bt
(lldb) bt all # all threads
# List threads
(lldb) thread list
# Switch thread
(lldb) thread select 2
# Frame info
(lldb) frame info
(lldb) frame variable # all local variables
# Watchpoint (break when value changes)
(lldb) watchpoint set variable self.items.count
# Expression evaluation
(lldb) expr self.items.append(newItem)
```
For lldb to attach, your app needs the `get-task-allow` entitlement (included in Debug builds by default):
```xml
com.apple.security.get-task-allow
```
If you have attachment issues:
```bash
# Check entitlements
codesign -d --entitlements - ./build/Build/Products/Debug/MyApp.app
```
```bash
# User crash logs
ls ~/Library/Logs/DiagnosticReports/
# System crash logs (requires sudo)
ls /Library/Logs/DiagnosticReports/
# Find your app's crashes
ls ~/Library/Logs/DiagnosticReports/ | grep MyApp
```
```bash
# View latest crash
cat ~/Library/Logs/DiagnosticReports/MyApp_*.ips | head -200
# Symbolicate (if you have dSYM)
atos -arch arm64 -o ./build/Build/Products/Debug/MyApp.app.dSYM/Contents/Resources/DWARF/MyApp -l 0x100000000 0x100001234
```
```bash
# Watch for new crashes
fswatch ~/Library/Logs/DiagnosticReports/ | grep MyApp
```
```bash
# List available templates
instruments -s templates
# Profile CPU usage
instruments -t "Time Profiler" -D trace.trace ./build/Build/Products/Debug/MyApp.app
# Profile memory
instruments -t "Allocations" -D memory.trace ./build/Build/Products/Debug/MyApp.app
# Profile leaks
instruments -t "Leaks" -D leaks.trace ./build/Build/Products/Debug/MyApp.app
```
Add signposts for custom profiling:
```swift
import os
class DataService {
private let signposter = OSSignposter(subsystem: "com.yourcompany.MyApp", category: "Performance")
func loadItems() async throws -> [Item] {
let signpostID = signposter.makeSignpostID()
let state = signposter.beginInterval("Load Items", id: signpostID)
defer {
signposter.endInterval("Load Items", state)
}
return try await fetchItems()
}
}
```
View in Instruments with "os_signpost" instrument.
```bash
# Verify signature
codesign -v ./build/Build/Products/Release/MyApp.app
# Show signature details
codesign -dv --verbose=4 ./build/Build/Products/Release/MyApp.app
# Show entitlements
codesign -d --entitlements - ./build/Build/Products/Release/MyApp.app
```
```bash
# Sign with Developer ID (for distribution outside App Store)
codesign --force --sign "Developer ID Application: Your Name (TEAMID)" \
--entitlements MyApp/MyApp.entitlements \
--options runtime \
./build/Build/Products/Release/MyApp.app
```
```bash
# Create ZIP for notarization
ditto -c -k --keepParent ./build/Build/Products/Release/MyApp.app MyApp.zip
# Submit for notarization
xcrun notarytool submit MyApp.zip \
--apple-id your@email.com \
--team-id YOURTEAMID \
--password @keychain:AC_PASSWORD \
--wait
# Staple ticket to app
xcrun stapler staple ./build/Build/Products/Release/MyApp.app
```
**Store password in keychain**:
```bash
xcrun notarytool store-credentials --apple-id your@email.com --team-id TEAMID
```
```bash
# Run all tests
xcodebuild -project MyApp.xcodeproj \
-scheme MyApp \
-derivedDataPath ./build \
test
# Run specific test class
xcodebuild -project MyApp.xcodeproj \
-scheme MyApp \
-only-testing:MyAppTests/DataServiceTests \
test
# Run specific test method
xcodebuild -project MyApp.xcodeproj \
-scheme MyApp \
-only-testing:MyAppTests/DataServiceTests/testLoadItems \
test
```
```bash
# Pretty test output
xcodebuild test -project MyApp.xcodeproj -scheme MyApp | xcpretty --test
# Generate test report
xcodebuild test -project MyApp.xcodeproj -scheme MyApp \
-resultBundlePath ./TestResults.xcresult
# View result bundle
xcrun xcresulttool get --path ./TestResults.xcresult --format json
```
```bash
# Build with coverage
xcodebuild -project MyApp.xcodeproj \
-scheme MyApp \
-enableCodeCoverage YES \
-derivedDataPath ./build \
test
# Generate coverage report
xcrun llvm-cov report \
./build/Build/Products/Debug/MyApp.app/Contents/MacOS/MyApp \
-instr-profile=./build/Build/ProfileData/*/Coverage.profdata
```
Typical development cycle without opening Xcode:
```bash
# 1. Edit code (in your editor of choice)
# Claude Code, vim, VS Code, etc.
# 2. Build
xcodebuild -project MyApp.xcodeproj -scheme MyApp -configuration Debug -derivedDataPath ./build build 2>&1 | grep -E "error:|warning:" || echo "Build succeeded"
# 3. Run
open ./build/Build/Products/Debug/MyApp.app
# 4. Monitor logs (in separate terminal)
log stream --predicate 'subsystem == "com.yourcompany.MyApp"' --level debug
# 5. If crash, check logs
cat ~/Library/Logs/DiagnosticReports/MyApp_*.ips | head -100
# 6. Debug if needed
lldb -n MyApp
# 7. Run tests
xcodebuild -project MyApp.xcodeproj -scheme MyApp test
# 8. Build release
xcodebuild -project MyApp.xcodeproj -scheme MyApp -configuration Release -derivedDataPath ./build build
```
Create a build script for convenience:
```bash
#!/bin/bash
# build.sh
PROJECT="MyApp.xcodeproj"
SCHEME="MyApp"
CONFIG="${1:-Debug}"
echo "Building $SCHEME ($CONFIG)..."
xcodebuild -project "$PROJECT" \
-scheme "$SCHEME" \
-configuration "$CONFIG" \
-derivedDataPath ./build \
build 2>&1 | tee build.log | grep -E "error:|warning:|BUILD"
if [ ${PIPESTATUS[0]} -eq 0 ]; then
echo "✓ Build succeeded"
echo "App: ./build/Build/Products/$CONFIG/$SCHEME.app"
else
echo "✗ Build failed - see build.log"
exit 1
fi
```
```bash
chmod +x build.sh
./build.sh # Debug build
./build.sh Release # Release build
```
Add to ~/.zshrc or ~/.bashrc:
```bash
# Build current project
alias xb='xcodebuild -project *.xcodeproj -scheme $(basename *.xcodeproj .xcodeproj) -derivedDataPath ./build build'
# Build and run
alias xbr='xb && open ./build/Build/Products/Debug/*.app'
# Run tests
alias xt='xcodebuild -project *.xcodeproj -scheme $(basename *.xcodeproj .xcodeproj) test'
# Stream logs for current project
alias xl='log stream --predicate "subsystem contains \"$(defaults read ./build/Build/Products/Debug/*.app/Contents/Info.plist CFBundleIdentifier)\"" --level debug'
# Clean
alias xc='xcodebuild -project *.xcodeproj -scheme $(basename *.xcodeproj .xcodeproj) clean && rm -rf ./build'
```