diff --git a/README.md b/README.md index c1284d1..ec45eb0 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,22 @@ Analyze SDK integration status in your project. clix doctor ``` +### `clix ios-setup` + +Configure iOS capabilities required for the Clix SDK (Push Notifications and App Groups). + +```bash +clix ios-setup +``` + +**What it does:** +1. Analyzes your iOS project structure +2. Checks current capabilities status +3. Creates/modifies entitlements files +4. Guides you through Xcode and Apple Developer Portal configuration + +**Note:** Some steps require manual action in Xcode and Apple Developer Portal. + ### Interactive Skills (Chat Mode Only) The following skills require step-by-step guidance and are only available in chat mode. Run `clix` to start interactive chat, then use `/` commands. @@ -241,6 +257,7 @@ Use these commands within the interactive chat (`clix`): | `/install` | | Autonomous SDK installation | | `/doctor` | | Check SDK integration status | | `/debug` | | Interactive debugging assistant | +| `/ios-setup` | `/capabilities`, `/ios-capabilities` | Configure iOS capabilities | ### Interactive Skills diff --git a/bun.lock b/bun.lock index ed2f911..d1b9e2a 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,8 @@ "name": "@clix-so/clix-cli", "dependencies": { "@clix-so/clix-agent-skills": "^0.2.3", + "@expo/apple-utils": "^2.1.14", + "@expo/plist": "^0.4.8", "ink": "^6.6.0", "ink-select-input": "^6.2.0", "ink-spinner": "^5.0.0", @@ -50,6 +52,10 @@ "@clix-so/clix-agent-skills": ["@clix-so/clix-agent-skills@0.2.3", "", { "dependencies": { "@iarna/toml": "^2.2.5", "chalk": "^4.1.2", "commander": "^14.0.2", "fs-extra": "^11.3.3", "inquirer": "^8.2.5", "ora": "^5.4.1" }, "bin": { "clix-agent-skills": "dist/bin/cli.js" } }, "sha512-8klwTULuuVCBkN9Q8wMvktq101I2RvMft35zTUe16LRtqfjDn1o00T6OMeEIQevQo/lCTej6qa0RLR55nmlR8Q=="], + "@expo/apple-utils": ["@expo/apple-utils@2.1.14", "", { "bin": { "apple-utils": "bin.js" } }, "sha512-6k9KAyk/itPvNgkAI3LactHJyD/S8Jq2V3iEZRm2LMRDx/Gpu4KvqX1dQBon2G7qFM/AEkLO1dToF/dirB7K2Q=="], + + "@expo/plist": ["@expo/plist@0.4.8", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.2.3", "xmlbuilder": "^15.1.1" } }, "sha512-pfNtErGGzzRwHP+5+RqswzPDKkZrx+Cli0mzjQaus1ZWFsog5ibL+nVT3NcporW51o8ggnt7x813vtRbPiyOrQ=="], + "@iarna/toml": ["@iarna/toml@2.2.5", "", {}, "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg=="], "@inquirer/external-editor": ["@inquirer/external-editor@1.0.3", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA=="], @@ -60,6 +66,8 @@ "@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="], + "@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="], + "ansi-escapes": ["ansi-escapes@7.2.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -268,6 +276,8 @@ "xdg-portable": ["xdg-portable@10.6.0", "", { "dependencies": { "os-paths": "^7.4.0" }, "optionalDependencies": { "fsevents": "*" } }, "sha512-xrcqhWDvtZ7WLmt8G4f3hHy37iK7D2idtosRgkeiSPZEPmBShp0VfmRBLWAPC6zLF48APJ21yfea+RfQMF4/Aw=="], + "xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], + "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], diff --git a/llms.txt b/llms.txt index 4f8e19c..66ed2e0 100644 --- a/llms.txt +++ b/llms.txt @@ -9,8 +9,8 @@ Clix CLI is an interactive command-line tool that provides a chat interface with **Core Features:** - Interactive chat interface as the primary interaction mode - Support for 6 AI agents: Claude, Codex, Gemini, OpenCode, Cursor, and GitHub Copilot -- 17 slash commands for quick actions -- Skills system with 5 interactive skills + 3 autonomous commands +- 18 slash commands for quick actions +- Skills system with 5 interactive skills + 4 autonomous commands - Interactive debug assistant for problem diagnosis - Session transfer to native agent CLIs - Agent switching with full history preservation @@ -208,13 +208,14 @@ The interactive chat (`clix` command) is the primary way to interact with Clix C Type `/` in the chat to see the autocomplete menu. All 17 slash commands are organized into three categories: -#### Autonomous Commands (3 commands) +#### Autonomous Commands (4 commands) These can be run from command-line (`clix `) or chat mode: - `/install` - Autonomous SDK installation with automatic file modifications - `/doctor` - Analyze SDK integration status (automatic scan, JSON output) - `/debug` - Interactive debugging assistant (asks for problem description) +- `/ios-setup` (aliases: `/capabilities`, `/ios-capabilities`) - Configure iOS capabilities for push notifications and app groups #### Interactive Skills (5 commands) @@ -352,6 +353,56 @@ Describe the problem: Events not appearing in Clix dashboard [Provides fix with exact file location] ``` +#### `/ios-setup` - iOS Setup & Capabilities Configuration + +**Category:** Autonomous Command + +**Aliases:** `/capabilities`, `/ios-capabilities` + +**What it does:** Configures iOS capabilities required for the Clix SDK, specifically Push Notifications and App Groups. + +**Capabilities configured:** +- **Push Notifications** - Enables APNs communication (entitlement: `aps-environment`) +- **App Groups** - Enables data sharing between app and Notification Service Extension (entitlement: `com.apple.security.application-groups`, format: `group.clix.{BUNDLE_ID}`) + +**Workflow:** +1. Analyzes iOS project structure (finds .xcodeproj/.xcworkspace) +2. Detects Bundle ID and checks current capabilities status +3. Creates/modifies entitlements files for main app and extension +4. Provides step-by-step instructions for Xcode configuration +5. Guides through Apple Developer Portal setup +6. Outputs verification report + +**What can be automated:** +- Creating/modifying entitlements files +- Reading project configuration + +**What requires manual action:** +- Adding capabilities in Xcode UI (Signing & Capabilities) +- Enabling capabilities in Apple Developer Portal +- Registering App Group IDs +- Regenerating provisioning profiles + +**Example:** +``` +> /ios-setup +Analyzing iOS project... +Bundle ID: com.example.myapp +Push Notifications: not configured +App Groups: not configured + +Creating entitlements files... +✓ Created MyApp.entitlements +✓ Created NotificationServiceExtension.entitlements + +Manual steps required in Xcode: +1. Add Push Notifications capability to main target +2. Add App Groups capability with ID: group.clix.com.example.myapp +3. Add same App Group to extension target + +{verification report JSON} +``` + ### Interactive Skills #### `/integration` - SDK Integration Guide @@ -1123,8 +1174,8 @@ When helping users with Clix CLI, keep these points in mind: 1. **Primary command is `clix`** - This launches interactive chat, not just a welcome screen 2. **Interactive > Commands** - The tool is primarily interactive, not command-based 3. **6 supported agents** - Gemini, Copilot, OpenCode, Cursor, Claude, Codex (recommend starting with Gemini or Copilot for free tiers) -4. **17 slash commands** - 3 autonomous commands + 5 interactive skills + 9 system commands -5. **Autonomous vs Interactive** - Autonomous commands (`/install`, `/doctor`, `/debug`) can run from CLI, Interactive skills (`/integration`, `/event-tracking`, etc.) require chat mode +4. **18 slash commands** - 4 autonomous commands + 5 interactive skills + 9 system commands +5. **Autonomous vs Interactive** - Autonomous commands (`/install`, `/doctor`, `/debug`, `/ios-setup`) can run from CLI, Interactive skills (`/integration`, `/event-tracking`, etc.) require chat mode 6. **Skills from package** - Interactive skills from @clix-so/clix-agent-skills package, Autonomous commands are local 7. **/install vs /integration** - `/install` makes changes autonomously, `/integration` provides guided steps 8. **Session transfer** - Saves to `~/.local/state/clix/sessions/`, provides command for native CLI @@ -1142,6 +1193,7 @@ When helping users with Clix CLI, keep these points in mind: - "SDK integration" → Use `/install` for autonomous installation or `/integration` skill for guided steps - "iOS installation" → Detect: Package.swift (SPM, preferred), Podfile (CocoaPods), or suggest SPM for bare Xcode projects. SPM is the modern, recommended approach. - "SPM/Swift Package Manager" → Guide through Package.swift modification or Xcode UI (File > Add Package Dependencies) for adding packages +- "iOS capabilities" → Use `/ios-setup` to configure Push Notifications and App Groups - "Event tracking" → Use `/event-tracking` skill (interactive, creates event plans) - "User management" → Use `/user-management` skill (setUserId, properties, logout handling) - "Personalization" → Use `/personalization` skill (Liquid templates for messages) diff --git a/package.json b/package.json index 749ad9b..475faa6 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,8 @@ "homepage": "https://docs.clix.so/clix-cli", "dependencies": { "@clix-so/clix-agent-skills": "^0.2.3", + "@expo/apple-utils": "^2.1.14", + "@expo/plist": "^0.4.8", "ink": "^6.6.0", "ink-select-input": "^6.2.0", "ink-spinner": "^5.0.0", diff --git a/src/cli.tsx b/src/cli.tsx index bfd7884..665955d 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -3,6 +3,7 @@ import { agentCommand } from './commands/agent'; import { chatCommand } from './commands/chat'; import { debugCommand } from './commands/debug'; import { installMCPCommand } from './commands/install-mcp'; +import { iosSetupCommand } from './commands/ios-setup/index'; import { resumeCommand } from './commands/resume'; import { skillCommand } from './commands/skill/index'; import { uninstallCommand } from './commands/uninstall'; @@ -89,6 +90,26 @@ const cli = meow(generateHelpText(), { shortFlag: 'f', default: false, }, + // iOS setup flags + apiKey: { + type: 'string', + }, + keyId: { + type: 'string', + }, + issuerId: { + type: 'string', + }, + bundleId: { + type: 'string', + }, + skipPortal: { + type: 'boolean', + default: false, + }, + pushEnv: { + type: 'string', + }, }, }); @@ -148,6 +169,27 @@ async function main() { }); break; + case 'ios-setup': + case 'capabilities': + case 'ios-capabilities': { + const pushEnvRaw = cli.flags.pushEnv; + if (pushEnvRaw && !['development', 'production'].includes(pushEnvRaw)) { + console.error(`Invalid --push-env value: ${pushEnvRaw}`); + console.error('Expected: development | production'); + process.exit(1); + } + const pushEnv = pushEnvRaw as 'development' | 'production' | undefined; + await iosSetupCommand({ + apiKeyPath: cli.flags.apiKey, + keyId: cli.flags.keyId, + issuerId: cli.flags.issuerId, + bundleId: cli.flags.bundleId, + skipPortal: cli.flags.skipPortal, + pushEnvironment: pushEnv, + }); + break; + } + default: // Check if command is a skill type (dynamically) if (skillTypes.includes(command ?? '')) { diff --git a/src/commands/ios-setup/index.tsx b/src/commands/ios-setup/index.tsx new file mode 100644 index 0000000..1d4107b --- /dev/null +++ b/src/commands/ios-setup/index.tsx @@ -0,0 +1,147 @@ +import { render } from 'ink'; +import type { AgentInfo } from '../../lib/agents'; +import type { AgentExecutor, AgentMessage } from '../../lib/executor'; +import { generateAgentPrompt } from '../../lib/ios'; +import { AgentExecutionUI } from '../../ui/AgentExecutionUI'; +import { type IosSetupOptions, type IosSetupResult, IosSetupUI } from '../../ui/IosSetupUI'; +import { type FinalOutputResult, printFinalOutput } from '../../ui/utils/finalOutput'; + +export interface IosSetupCommandOptions { + /** Path to .p8 API Key file */ + apiKeyPath?: string; + /** API Key ID */ + keyId?: string; + /** Issuer ID */ + issuerId?: string; + /** Bundle ID (override auto-detection) */ + bundleId?: string; + /** Skip Apple Developer Portal sync */ + skipPortal?: boolean; + /** Push notification environment */ + pushEnvironment?: 'development' | 'production'; +} + +function toDirectSetupOutput(result: IosSetupResult): FinalOutputResult { + if (result.success) { + const details: string[] = []; + + if (result.projectInfo) { + details.push(`Project: ${result.projectInfo.appName}`); + details.push(`Bundle ID: ${result.projectInfo.bundleId}`); + } + + if (result.portalSync) { + if (result.portalSync.enabled.length > 0) { + details.push(`Enabled: ${result.portalSync.enabled.join(', ')}`); + } + if (result.portalSync.appGroupCreated && result.portalSync.appGroupId) { + details.push(`Created App Group: ${result.portalSync.appGroupId}`); + } + } + + if (result.entitlementsUpdated.length > 0) { + details.push(`Updated files: ${result.entitlementsUpdated.length}`); + } + + return { + type: 'success', + title: 'Direct setup completed', + message: result.agentContext + ? 'Portal sync and entitlements configured. Starting agent for remaining tasks...' + : 'Portal sync and entitlements configured.', + details: details.length > 0 ? details : undefined, + }; + } + + return { + type: 'error', + title: 'iOS setup failed', + message: result.error || 'Unknown error occurred', + }; +} + +/** + * Run the direct implementation phase (Portal sync + Entitlements) + */ +async function runDirectSetup(options: IosSetupCommandOptions): Promise { + const uiOptions: IosSetupOptions = { + apiKeyPath: options.apiKeyPath, + keyId: options.keyId, + issuerId: options.issuerId, + bundleId: options.bundleId, + skipPortal: options.skipPortal ?? (!options.apiKeyPath && !options.keyId && !options.issuerId), + pushEnvironment: options.pushEnvironment, + }; + + return new Promise((resolve) => { + const { unmount } = render( + { + unmount(); + resolve(result); + }} + />, + { incrementalRendering: true }, + ); + }); +} + +/** + * Run the agent phase to complete remaining tasks (Xcode project modifications, Extension setup) + */ +async function runAgentCompletion( + directResult: IosSetupResult, +): Promise { + if (!directResult.agentContext) { + return undefined; + } + + const agentPrompt = generateAgentPrompt(directResult.agentContext); + + // Create execute function for agent + async function* executeAgent( + executor: AgentExecutor, + _agent: AgentInfo, + ): AsyncGenerator { + yield* executor.execute(agentPrompt); + } + + return new Promise((resolve) => { + const { unmount } = render( + { + unmount(); + resolve(result); + }} + />, + { incrementalRendering: true }, + ); + }); +} + +export async function iosSetupCommand(options: IosSetupCommandOptions): Promise { + // Phase 1: Direct implementation (Portal sync + Entitlements) + const directResult = await runDirectSetup(options); + + if (!directResult.success) { + printFinalOutput(toDirectSetupOutput(directResult)); + return; + } + + // Show direct setup completion + printFinalOutput(toDirectSetupOutput(directResult)); + + // Phase 2: Agent completion (Xcode project modifications, Extension setup) + if (directResult.agentContext) { + console.log('\n'); // Add spacing before agent phase + const agentResult = await runAgentCompletion(directResult); + + if (agentResult) { + printFinalOutput(agentResult); + } + } +} diff --git a/src/commands/skill/index.tsx b/src/commands/skill/index.tsx index eba8dcb..7f1b0f9 100644 --- a/src/commands/skill/index.tsx +++ b/src/commands/skill/index.tsx @@ -75,6 +75,13 @@ export async function skillCommand(options: SkillCommandOptions): Promise process.exit(1); } + // Check if skill uses direct implementation (not agent-based) + if (skillInfo.usesAgent === false) { + console.error(`Skill '${action}' uses direct implementation.`); + console.error(`Please run 'clix ${action}' directly instead.`); + process.exit(1); + } + const skillType = action as SkillType; // Create execute function that wraps executeSkill diff --git a/src/lib/commands/skills.ts b/src/lib/commands/skills.ts index b9691f8..e239983 100644 --- a/src/lib/commands/skills.ts +++ b/src/lib/commands/skills.ts @@ -81,6 +81,14 @@ function generateSkillCommands(): Command[] { ), ); commands.push(createLocalSkillCommand('doctor', 'Check SDK integration status', 'doctor')); + commands.push( + createLocalSkillCommand( + 'ios-setup', + 'Configure iOS capabilities for push notifications and app groups', + 'ios-setup', + ['capabilities', 'ios-capabilities'], + ), + ); return commands; } diff --git a/src/lib/ios/agent-prompt-generator.ts b/src/lib/ios/agent-prompt-generator.ts new file mode 100644 index 0000000..198c6a5 --- /dev/null +++ b/src/lib/ios/agent-prompt-generator.ts @@ -0,0 +1,145 @@ +import type { IosProjectInfo } from './project-analyzer'; + +/** + * Context for agent to complete remaining iOS setup tasks + */ +export interface AgentContext { + bundleId: string; + appGroupId: string; + projectPath: string; + entitlementsPath: string; + appName: string; + iosDir: string; +} + +/** + * Generate agent prompt for completing iOS setup + * The agent will handle tasks that cannot be done programmatically: + * - Xcode project file modifications (.pbxproj) + * - Notification Service Extension creation + * - Extension code generation + */ +export function generateAgentPrompt(context: AgentContext): string { + return `## iOS Setup - Remaining Tasks + +The following has been completed automatically: +- Apple Developer Portal capabilities synced (Push Notifications, App Groups) +- Entitlements file created: ${context.entitlementsPath} +- App Group ID: ${context.appGroupId} + +### Project Information +- App Name: ${context.appName} +- Bundle ID: ${context.bundleId} +- iOS Directory: ${context.iosDir} +- Project Path: ${context.projectPath} + +### Tasks to Complete + +#### 1. Link Entitlements in Xcode Project [Required] +Modify the Xcode project file (.pbxproj) to link the entitlements file: +- Find the main app target +- Add CODE_SIGN_ENTITLEMENTS build setting pointing to the entitlements file +- Ensure both Debug and Release configurations are updated + +#### 2. Create Notification Service Extension [Required] +Create a new Notification Service Extension target for rich push notifications: +- Target name: \`${context.appName}NotificationServiceExtension\` or \`NotificationServiceExtension\` +- Bundle ID: \`${context.bundleId}.NotificationServiceExtension\` +- Deployment target: Same as main app or iOS 14.0+ +- Create necessary files in the extension directory + +#### 3. Implement NotificationService.swift [Required] +Create the NotificationService.swift file with Clix SDK integration: + +\`\`\`swift +import UserNotifications +import ClixSDK + +class NotificationService: UNNotificationServiceExtension { + var contentHandler: ((UNNotificationContent) -> Void)? + var bestAttemptContent: UNMutableNotificationContent? + + override func didReceive( + _ request: UNNotificationRequest, + withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void + ) { + self.contentHandler = contentHandler + bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) + + if let bestAttemptContent = bestAttemptContent { + // Let Clix SDK handle the notification + Clix.shared.handleNotificationServiceExtension( + request: request, + content: bestAttemptContent + ) { processedContent in + contentHandler(processedContent) + } + } + } + + override func serviceExtensionTimeWillExpire() { + if let contentHandler = contentHandler, + let bestAttemptContent = bestAttemptContent { + contentHandler(bestAttemptContent) + } + } +} +\`\`\` + +#### 4. Create Extension Entitlements [Required] +Create entitlements file for the extension at \`${context.iosDir}/NotificationServiceExtension/NotificationServiceExtension.entitlements\`: + +\`\`\`xml + + + + + com.apple.security.application-groups + + ${context.appGroupId} + + + +\`\`\` + +#### 5. Update Extension Info.plist [Required] +Ensure the extension's Info.plist has the correct NSExtension configuration: + +\`\`\`xml +NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationService + +\`\`\` + +### Important Notes +- The extension must share the same App Group as the main app +- The extension's bundle ID must be a child of the main app's bundle ID +- Add the ClixSDK to the extension target's frameworks +- Ensure the extension is added to the app's "Embed App Extensions" build phase + +Please complete these tasks by modifying the Xcode project files directly.`; +} + +/** + * Build agent context from iOS setup results + */ +export function buildAgentContext( + projectInfo: IosProjectInfo, + bundleId: string, + appGroupId: string, + entitlementsPath: string, + iosDir: string, +): AgentContext { + return { + bundleId, + appGroupId, + projectPath: projectInfo.projectPath, + entitlementsPath, + appName: projectInfo.appName, + iosDir, + }; +} diff --git a/src/lib/ios/apple-portal.ts b/src/lib/ios/apple-portal.ts new file mode 100644 index 0000000..5ab3365 --- /dev/null +++ b/src/lib/ios/apple-portal.ts @@ -0,0 +1,266 @@ +import * as fs from 'node:fs'; +import type { RequestContext } from '@expo/apple-utils'; +import { + AppGroup, + BundleId, + BundleIdPlatform, + CapabilityType, + CapabilityTypeOption, + Token, +} from '@expo/apple-utils'; + +export interface ApiKeyAuthConfig { + /** API Key ID from App Store Connect */ + keyId: string; + /** Issuer ID from App Store Connect */ + issuerId: string; + /** Contents of the .p8 file */ + keyP8: string; +} + +export interface AppleAuthConfig { + /** API Key authentication (recommended) */ + apiKey?: ApiKeyAuthConfig; +} + +export interface CapabilitySyncResult { + /** Capabilities that were enabled */ + enabled: string[]; + /** Capabilities that were already enabled */ + alreadyEnabled: string[]; + /** App group ID that was created or used */ + appGroupId?: string; + /** Whether app group was newly created */ + appGroupCreated: boolean; +} + +/** + * Create authentication context using API Key + */ +export async function createAuthContext(config: AppleAuthConfig): Promise { + if (!config.apiKey) { + throw new Error( + 'API Key authentication is required. Please provide --api-key, --issuer-id, and --key-id flags.', + ); + } + + const { keyId, issuerId, keyP8 } = config.apiKey; + + // Validate inputs + if (!keyId || !issuerId || !keyP8) { + throw new Error( + 'Missing required API Key parameters: keyId, issuerId, and keyP8 are all required.', + ); + } + + const token = new Token({ + key: keyP8, + issuerId, + keyId, + duration: 1200, // 20 minutes + }); + + return { token }; +} + +/** + * Load API Key from .p8 file + */ +export function loadApiKeyFromFile(p8FilePath: string): string { + if (!fs.existsSync(p8FilePath)) { + throw new Error(`API Key file not found: ${p8FilePath}`); + } + + const content = fs.readFileSync(p8FilePath, 'utf-8'); + + // Validate it looks like a .p8 key + if (!content.includes('-----BEGIN PRIVATE KEY-----')) { + throw new Error( + `Invalid API Key file: ${p8FilePath}. File should be a .p8 private key file from App Store Connect.`, + ); + } + + return content; +} + +/** + * Find or create Bundle ID in Apple Developer Portal + */ +export async function findOrCreateBundleId( + context: RequestContext, + bundleIdentifier: string, + name?: string, +): Promise { + // Try to find existing Bundle ID + const existingBundleId = await BundleId.findAsync(context, { + identifier: bundleIdentifier, + }); + + if (existingBundleId) { + return existingBundleId; + } + + // Create new Bundle ID + const displayName = name || bundleIdentifier.split('.').pop() || bundleIdentifier; + const newBundleId = await BundleId.createAsync(context, { + name: displayName, + identifier: bundleIdentifier, + platform: BundleIdPlatform.IOS, + }); + + return newBundleId; +} + +/** + * Find or create App Group in Apple Developer Portal + */ +export async function findOrCreateAppGroup( + context: RequestContext, + appGroupIdentifier: string, +): Promise<{ appGroup: AppGroup; created: boolean }> { + // Try to find existing App Group + const existingGroups = await AppGroup.getAsync(context, { + query: { + filter: { identifier: appGroupIdentifier }, + }, + }); + + if (existingGroups && existingGroups.length > 0) { + return { appGroup: existingGroups[0] as AppGroup, created: false }; + } + + // Create new App Group + const newAppGroup = await AppGroup.createAsync(context, { + identifier: appGroupIdentifier, + name: appGroupIdentifier, + }); + + return { appGroup: newAppGroup as AppGroup, created: true }; +} + +/** + * Sync capabilities for a Bundle ID + * Enables Push Notifications and App Groups + */ +export async function syncCapabilities( + context: RequestContext, + bundleIdentifier: string, + appGroupIdentifier: string, +): Promise { + const result: CapabilitySyncResult = { + enabled: [], + alreadyEnabled: [], + appGroupCreated: false, + }; + + // Find or create Bundle ID + const bundleId = await findOrCreateBundleId(context, bundleIdentifier); + + // Get current capabilities + const currentCapabilities = await bundleId.getBundleIdCapabilitiesAsync(); + + // Check Push Notifications + const hasPush = currentCapabilities.some((cap) => cap.isType(CapabilityType.PUSH_NOTIFICATIONS)); + + // Check App Groups + const hasAppGroups = currentCapabilities.some((cap) => cap.isType(CapabilityType.APP_GROUP)); + + // Find or create App Group + const { appGroup, created: appGroupCreated } = await findOrCreateAppGroup( + context, + appGroupIdentifier, + ); + result.appGroupId = appGroupIdentifier; + result.appGroupCreated = appGroupCreated; + + // Build update request + const updateRequests: Array<{ + capabilityType: CapabilityType; + option: typeof CapabilityTypeOption.ON; + relationships?: { appGroups: string[] }; + }> = []; + + // Add Push Notifications if not enabled + if (!hasPush) { + updateRequests.push({ + capabilityType: CapabilityType.PUSH_NOTIFICATIONS, + option: CapabilityTypeOption.ON, + }); + result.enabled.push('Push Notifications'); + } else { + result.alreadyEnabled.push('Push Notifications'); + } + + // Add App Groups if not enabled + if (!hasAppGroups) { + updateRequests.push({ + capabilityType: CapabilityType.APP_GROUP, + option: CapabilityTypeOption.ON, + relationships: { appGroups: [appGroup.id] }, + }); + result.enabled.push('App Groups'); + } else { + result.alreadyEnabled.push('App Groups'); + + // Even if App Groups is enabled, we might need to add our App Group + // Update the capability to include our app group + try { + await bundleId.updateBundleIdCapabilityAsync({ + capabilityType: CapabilityType.APP_GROUP, + option: CapabilityTypeOption.ON, + relationships: { appGroups: [appGroup.id] }, + }); + } catch { + // App group might already be associated - this is expected behavior + } + } + + // Apply updates if any + if (updateRequests.length > 0) { + await bundleId.updateBundleIdCapabilityAsync(updateRequests); + } + + return result; +} + +/** + * Validate API Key credentials by making a test API call + */ +export async function validateCredentials(context: RequestContext): Promise { + try { + // Try to list bundle IDs - this will fail if credentials are invalid + await BundleId.getAsync(context, { query: { limit: 1 } }); + return true; + } catch { + return false; + } +} + +/** + * Get human-readable error message for Apple API errors + */ +export function getAppleApiErrorMessage(error: unknown): string { + if (error instanceof Error) { + const message = error.message; + + if (message.includes('401') || message.includes('Unauthorized')) { + return 'Invalid API Key credentials. Please check your Key ID, Issuer ID, and .p8 file.'; + } + + if (message.includes('403') || message.includes('Forbidden')) { + return 'API Key does not have sufficient permissions. Ensure it has App Manager or Admin role.'; + } + + if (message.includes('404')) { + return 'Bundle ID not found in Apple Developer Portal.'; + } + + if (message.includes('409')) { + return 'Conflict: Resource already exists or is in an invalid state.'; + } + + return message; + } + + return String(error); +} diff --git a/src/lib/ios/entitlements-manager.ts b/src/lib/ios/entitlements-manager.ts new file mode 100644 index 0000000..89b5050 --- /dev/null +++ b/src/lib/ios/entitlements-manager.ts @@ -0,0 +1,150 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import plist from '@expo/plist'; + +export interface EntitlementsConfig { + /** Push notification environment: development or production */ + pushNotifications?: 'development' | 'production'; + /** App group identifiers */ + appGroups?: string[]; +} + +export interface EntitlementsData { + 'aps-environment'?: 'development' | 'production'; + 'com.apple.security.application-groups'?: string[]; + [key: string]: unknown; +} + +/** + * Read and parse an entitlements file + */ +export async function readEntitlements(entitlementsPath: string): Promise { + if (!fs.existsSync(entitlementsPath)) { + return null; + } + + try { + const content = fs.readFileSync(entitlementsPath, 'utf-8'); + return plist.parse(content) as EntitlementsData; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to parse entitlements file: ${message}`); + } +} + +/** + * Write entitlements data to a file + */ +export async function writeEntitlements( + entitlementsPath: string, + data: EntitlementsData, +): Promise { + const content = plist.build(data); + + // Ensure directory exists + const dir = path.dirname(entitlementsPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + fs.writeFileSync(entitlementsPath, content, 'utf-8'); +} + +/** + * Generate App Group identifier from bundle ID + * Format: group.clix.{bundleId} + */ +export function generateAppGroupId(bundleId: string): string { + return `group.clix.${bundleId}`; +} + +/** + * Create or update entitlements with Clix configuration + */ +export async function updateEntitlementsForClix( + entitlementsPath: string, + bundleId: string, + options: { + pushEnvironment?: 'development' | 'production'; + existingEntitlements?: EntitlementsData | null; + } = {}, +): Promise { + const { pushEnvironment = 'development', existingEntitlements = null } = options; + + // Start with existing entitlements or empty object + const entitlements: EntitlementsData = existingEntitlements ? { ...existingEntitlements } : {}; + + // Add push notification environment + entitlements['aps-environment'] = pushEnvironment; + + // Add app group + const appGroupId = generateAppGroupId(bundleId); + const existingGroups = entitlements['com.apple.security.application-groups'] || []; + if (!existingGroups.includes(appGroupId)) { + entitlements['com.apple.security.application-groups'] = [...existingGroups, appGroupId]; + } + + // Write the entitlements file + await writeEntitlements(entitlementsPath, entitlements); + + return entitlements; +} + +/** + * Generate entitlements file path for a target + */ +export function getEntitlementsPath(projectDir: string, targetName: string): string { + return path.join(projectDir, targetName, `${targetName}.entitlements`); +} + +/** + * Check if entitlements have Clix configuration + */ +export function hasClixConfiguration( + entitlements: EntitlementsData | null, + bundleId: string, +): { hasPush: boolean; hasAppGroup: boolean } { + if (!entitlements) { + return { hasPush: false, hasAppGroup: false }; + } + + const hasPush = entitlements['aps-environment'] !== undefined; + const appGroupId = generateAppGroupId(bundleId); + const appGroups = entitlements['com.apple.security.application-groups'] || []; + const hasAppGroup = appGroups.includes(appGroupId); + + return { hasPush, hasAppGroup }; +} + +/** + * Get summary of entitlements configuration + */ +export function getEntitlementsSummary(entitlements: EntitlementsData): string[] { + const summary: string[] = []; + + if (entitlements['aps-environment']) { + summary.push(`Push Notifications: ${entitlements['aps-environment']}`); + } + + const appGroups = entitlements['com.apple.security.application-groups']; + if (appGroups && appGroups.length > 0) { + summary.push(`App Groups: ${appGroups.join(', ')}`); + } + + return summary; +} + +/** + * Generate Notification Service Extension entitlements + */ +export function generateExtensionEntitlements( + bundleId: string, + pushEnvironment: 'development' | 'production' = 'development', +): EntitlementsData { + const appGroupId = generateAppGroupId(bundleId); + + return { + 'aps-environment': pushEnvironment, + 'com.apple.security.application-groups': [appGroupId], + }; +} diff --git a/src/lib/ios/index.ts b/src/lib/ios/index.ts new file mode 100644 index 0000000..6e5df3d --- /dev/null +++ b/src/lib/ios/index.ts @@ -0,0 +1,43 @@ +// iOS project analysis + +// Agent prompt generation for remaining tasks +export { + type AgentContext, + buildAgentContext, + generateAgentPrompt, +} from './agent-prompt-generator'; + +// Apple Developer Portal integration +export { + type ApiKeyAuthConfig, + type AppleAuthConfig, + type CapabilitySyncResult, + createAuthContext, + findOrCreateAppGroup, + findOrCreateBundleId, + getAppleApiErrorMessage, + loadApiKeyFromFile, + syncCapabilities, + validateCredentials, +} from './apple-portal'; + +// Entitlements management +export { + type EntitlementsConfig, + type EntitlementsData, + generateAppGroupId, + generateExtensionEntitlements, + getEntitlementsPath, + getEntitlementsSummary, + hasClixConfiguration, + readEntitlements, + updateEntitlementsForClix, + writeEntitlements, +} from './entitlements-manager'; +export { + analyzeIosProject, + findEntitlementsFiles, + getIosProjectDir, + type IosProjectInfo, + type ProjectAnalysisResult, +} from './project-analyzer'; diff --git a/src/lib/ios/project-analyzer.ts b/src/lib/ios/project-analyzer.ts new file mode 100644 index 0000000..f5fbc37 --- /dev/null +++ b/src/lib/ios/project-analyzer.ts @@ -0,0 +1,284 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +export interface IosProjectInfo { + /** Path to .xcodeproj directory */ + projectPath: string; + /** Path to .xcworkspace directory (if exists) */ + workspacePath?: string; + /** Bundle identifier from project */ + bundleId: string; + /** App name extracted from project */ + appName: string; + /** List of target names */ + targets: string[]; + /** Existing entitlements file paths */ + entitlementsFiles: string[]; +} + +export interface ProjectAnalysisResult { + success: boolean; + project?: IosProjectInfo; + error?: string; +} + +/** + * Analyze iOS project structure in the given directory + */ +export async function analyzeIosProject(cwd: string): Promise { + // Find .xcodeproj directory + const projectPath = findXcodeProject(cwd); + if (!projectPath) { + return { + success: false, + error: 'No Xcode project found. Please run this command in an iOS project directory.', + }; + } + + // Find .xcworkspace if exists + const workspacePath = findXcodeWorkspace(cwd); + + // Parse project.pbxproj to get project info + const pbxprojPath = path.join(projectPath, 'project.pbxproj'); + if (!fs.existsSync(pbxprojPath)) { + return { + success: false, + error: `project.pbxproj not found at ${pbxprojPath}`, + }; + } + + const pbxprojContent = fs.readFileSync(pbxprojPath, 'utf-8'); + + // Extract bundle ID + const bundleId = extractBundleId(pbxprojContent); + if (!bundleId) { + return { + success: false, + error: + 'Could not extract bundle identifier from project. Please ensure PRODUCT_BUNDLE_IDENTIFIER is set.', + }; + } + + // Extract app name + const appName = extractAppName(pbxprojContent, projectPath); + + // Extract targets + const targets = extractTargets(pbxprojContent); + + // Find existing entitlements files + const entitlementsFiles = await findEntitlementsFiles(cwd); + + return { + success: true, + project: { + projectPath, + workspacePath, + bundleId, + appName, + targets, + entitlementsFiles, + }, + }; +} + +/** + * Find .xcodeproj directory in the given directory + */ +function findXcodeProject(cwd: string): string | null { + const entries = fs.readdirSync(cwd, { withFileTypes: true }); + + // Look for .xcodeproj in current directory + for (const entry of entries) { + if (entry.isDirectory() && entry.name.endsWith('.xcodeproj')) { + return path.join(cwd, entry.name); + } + } + + // Check common iOS project subdirectories + const iosSubdirs = ['ios', 'iOS']; + for (const subdir of iosSubdirs) { + const subdirPath = path.join(cwd, subdir); + if (fs.existsSync(subdirPath) && fs.statSync(subdirPath).isDirectory()) { + const subdirEntries = fs.readdirSync(subdirPath, { withFileTypes: true }); + for (const entry of subdirEntries) { + if (entry.isDirectory() && entry.name.endsWith('.xcodeproj')) { + return path.join(subdirPath, entry.name); + } + } + } + } + + return null; +} + +/** + * Find .xcworkspace directory in the given directory + */ +function findXcodeWorkspace(cwd: string): string | undefined { + const entries = fs.readdirSync(cwd, { withFileTypes: true }); + + // Look for .xcworkspace in current directory + for (const entry of entries) { + if (entry.isDirectory() && entry.name.endsWith('.xcworkspace')) { + return path.join(cwd, entry.name); + } + } + + // Check common iOS project subdirectories + const iosSubdirs = ['ios', 'iOS']; + for (const subdir of iosSubdirs) { + const subdirPath = path.join(cwd, subdir); + if (fs.existsSync(subdirPath) && fs.statSync(subdirPath).isDirectory()) { + const subdirEntries = fs.readdirSync(subdirPath, { withFileTypes: true }); + for (const entry of subdirEntries) { + if (entry.isDirectory() && entry.name.endsWith('.xcworkspace')) { + return path.join(subdirPath, entry.name); + } + } + } + } + + return undefined; +} + +/** + * Extract bundle identifier from pbxproj content + */ +function extractBundleId(pbxprojContent: string): string | null { + // Look for PRODUCT_BUNDLE_IDENTIFIER in build settings + // Pattern: PRODUCT_BUNDLE_IDENTIFIER = "com.example.app"; + // or: PRODUCT_BUNDLE_IDENTIFIER = com.example.app; + const patterns = [ + /PRODUCT_BUNDLE_IDENTIFIER\s*=\s*"([^"]+)"/, + /PRODUCT_BUNDLE_IDENTIFIER\s*=\s*([^;\s]+)/, + ]; + + for (const pattern of patterns) { + const match = pbxprojContent.match(pattern); + if (match?.[1]) { + // Skip if it's a variable reference like $(PRODUCT_BUNDLE_IDENTIFIER) + const bundleId = match[1]; + if (!bundleId.startsWith('$(') && !bundleId.startsWith('${')) { + return bundleId; + } + } + } + + return null; +} + +/** + * Extract app name from pbxproj content + */ +function extractAppName(pbxprojContent: string, projectPath: string): string { + // Try to find PRODUCT_NAME in build settings + const productNameMatch = pbxprojContent.match(/PRODUCT_NAME\s*=\s*"?([^";\n]+)"?/); + if (productNameMatch?.[1]) { + const name = productNameMatch[1].trim(); + // Skip variable references + if (!name.startsWith('$(') && !name.startsWith('${')) { + return name; + } + } + + // Fallback: use project directory name + const projectName = path.basename(projectPath, '.xcodeproj'); + return projectName; +} + +/** + * Extract target names from pbxproj content + */ +function extractTargets(pbxprojContent: string): string[] { + const targets: string[] = []; + + // Pattern to match PBXNativeTarget sections + // name = "TargetName"; + const targetPattern = + /\/\* Begin PBXNativeTarget section \*\/([\s\S]*?)\/\* End PBXNativeTarget section \*\//; + const sectionMatch = pbxprojContent.match(targetPattern); + + if (sectionMatch) { + const section = sectionMatch[1]; + const namePattern = /name\s*=\s*"?([^";\n]+)"?;/g; + const matches = section.matchAll(namePattern); + for (const match of matches) { + if (match[1]) { + targets.push(match[1].trim()); + } + } + } + + return targets; +} + +/** + * Find existing entitlements files in the project directory + */ +export async function findEntitlementsFiles(cwd: string): Promise { + const entitlementsFiles: string[] = []; + + const searchDirs = [cwd, path.join(cwd, 'ios'), path.join(cwd, 'iOS')]; + + for (const searchDir of searchDirs) { + if (!fs.existsSync(searchDir)) continue; + + await searchForEntitlements(searchDir, entitlementsFiles); + } + + return entitlementsFiles; +} + +/** + * Recursively search for .entitlements files + */ +async function searchForEntitlements(dir: string, results: string[], depth = 0): Promise { + // Limit search depth to avoid deep recursion + if (depth > 5) return; + + // Skip certain directories + const skipDirs = ['node_modules', 'Pods', 'build', 'DerivedData', '.git']; + + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + if (!skipDirs.includes(entry.name)) { + await searchForEntitlements(fullPath, results, depth + 1); + } + } else if (entry.isFile() && entry.name.endsWith('.entitlements')) { + results.push(fullPath); + } + } +} + +/** + * Get the iOS project directory (either cwd or cwd/ios or cwd/iOS) + */ +export function getIosProjectDir(cwd: string): string | null { + // Check if current directory has .xcodeproj + const entries = fs.readdirSync(cwd, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory() && entry.name.endsWith('.xcodeproj')) { + return cwd; + } + } + + // Check ios subdirectories (both casings for case-sensitive filesystems) + const iosSubdirs = ['ios', 'iOS']; + for (const subdir of iosSubdirs) { + const iosDir = path.join(cwd, subdir); + if (fs.existsSync(iosDir) && fs.statSync(iosDir).isDirectory()) { + const iosEntries = fs.readdirSync(iosDir, { withFileTypes: true }); + for (const entry of iosEntries) { + if (entry.isDirectory() && entry.name.endsWith('.xcodeproj')) { + return iosDir; + } + } + } + } + + return null; +} diff --git a/src/lib/skills.ts b/src/lib/skills.ts index a1a8990..e024c4b 100644 --- a/src/lib/skills.ts +++ b/src/lib/skills.ts @@ -31,6 +31,8 @@ export interface SkillInfo { description: string; /** Whether this is a local skill (not from @clix-so/clix-agent-skills) */ isLocal?: boolean; + /** Whether this skill uses an AI agent (default: true). Set to false for direct implementation. */ + usesAgent?: boolean; } /** @@ -56,6 +58,13 @@ const LOCAL_SKILLS: SkillInfo[] = [ description: 'Interactive debugging assistant', isLocal: true, }, + { + type: 'ios-setup', + name: 'iOS Setup', + description: 'Configure iOS capabilities for push notifications and app groups', + isLocal: true, + usesAgent: false, // Direct implementation without AI agent + }, ]; /** @@ -313,6 +322,8 @@ async function getLocalSkillPrompt(skillType: SkillType, options?: SkillOptions) projectPath: options?.projectPath ?? process.cwd(), oneShot: options?.oneShot, }); + case 'ios-setup': + return getIosSetupPrompt(options); default: throw new Error(`Unknown local skill: ${skillType}`); } @@ -340,6 +351,28 @@ function getDoctorPrompt(options?: SkillOptions): string { return prompt; } +/** + * Get prompt for the ios-setup skill. + * Configures iOS capabilities for push notifications and app groups. + * Prompt is loaded from src/lib/skills/ios-setup/SKILL.md + */ +function getIosSetupPrompt(options?: SkillOptions): string { + const projectPath = options?.projectPath ?? process.cwd(); + + let prompt = `Project path: ${projectPath}\n\n`; + + // Add one-shot instruction for autonomous execution + if (options?.oneShot) { + prompt += `${ONE_SHOT_INSTRUCTION}\n\n`; + } + + // Load the ios-setup prompt from external file + const iosSetupPrompt = readLocalSkillPrompt('ios-setup'); + prompt += iosSetupPrompt; + + return prompt; +} + export async function* executeSkill( skillType: SkillType, executor: AgentExecutor, diff --git a/src/lib/skills/ios-setup/SKILL.md b/src/lib/skills/ios-setup/SKILL.md new file mode 100644 index 0000000..82914e5 --- /dev/null +++ b/src/lib/skills/ios-setup/SKILL.md @@ -0,0 +1,315 @@ +# iOS Capabilities Configuration + +You are an AI agent that configures iOS capabilities required for the Clix SDK. + +## Core Directive + +**GUIDE USERS** through iOS capability configuration for push notifications and data sharing. For file modifications, use Edit/Write tools when possible. For Xcode-only steps, provide clear step-by-step instructions. + +## Required Capabilities for Clix iOS SDK + +### 1. Push Notifications + +- **Purpose:** Enable APNs (Apple Push Notification service) communication +- **Entitlement Key:** `aps-environment` +- **Values:** `development` (debug builds) or `production` (release builds) +- **Xcode Capability:** Push Notifications + +### 2. App Groups + +- **Purpose:** Share data between main app and Notification Service Extension using MMKV +- **Entitlement Key:** `com.apple.security.application-groups` +- **ID Format:** `group.clix.{BUNDLE_ID}` (e.g., `group.clix.com.example.myapp`) +- **Xcode Capability:** App Groups +- **Important:** Must be configured for BOTH main app AND Notification Service Extension targets + +## Workflow + +### Phase 1: Project Analysis + +1. **Detect iOS Project** + - Search for `*.xcodeproj` or `*.xcworkspace` files + - Identify the main app target name + - Check if this is a native iOS, React Native, or Flutter project + +2. **Find Bundle Identifier** + - Check `Info.plist` for `CFBundleIdentifier` + - Or parse `project.pbxproj` for `PRODUCT_BUNDLE_IDENTIFIER` + +3. **Check Current Capabilities Status** + - Search for existing `*.entitlements` files + - Check for `aps-environment` entitlement (Push Notifications configured) + - Check for `com.apple.security.application-groups` (App Groups configured) + - Check `project.pbxproj` for `SystemCapabilities` section + +4. **Report Current State** + Output findings: + ```text + Project: {project_name} + Bundle ID: {bundle_id} + Push Notifications: {configured/not configured} + App Groups: {configured/not configured} + Existing entitlements files: {list} + ``` + +### Phase 2: Xcode Configuration (Manual Steps) + +Provide clear instructions for adding capabilities in Xcode. These steps CANNOT be automated and require user action in Xcode IDE. + +**Add Push Notifications:** +```text +1. Open your project in Xcode +2. Select your main app target in the Navigator (left sidebar) +3. Go to the "Signing & Capabilities" tab +4. Click the "+ Capability" button +5. Search for and select "Push Notifications" +6. Xcode will automatically create an entitlements file if one doesn't exist +``` + +**Add Background Modes (Recommended):** +```text +1. In "Signing & Capabilities", click "+ Capability" +2. Select "Background Modes" +3. Enable "Remote notifications" checkbox + - This allows the app to process push notifications in the background +``` + +**Add App Groups:** +```text +1. Click "+ Capability" +2. Select "App Groups" +3. Click the "+" button under App Groups +4. Enter the App Group ID: group.clix.{BUNDLE_ID} + Example: group.clix.com.example.myapp +5. Click OK to create the group + +IMPORTANT: Repeat steps 1-5 for the Notification Service Extension target: +1. Select the extension target (usually named "{AppName}NotificationServiceExtension") +2. Go to "Signing & Capabilities" +3. Add "App Groups" capability +4. Select the SAME App Group ID you created above +``` + +### Phase 3: Entitlements Files + +Create or modify entitlements files. Use Write/Edit tools for these operations. + +**Main App Entitlements** (`{AppName}.entitlements` or `{AppName}/{AppName}.entitlements`): + +```xml + + + + + aps-environment + development + com.apple.security.application-groups + + group.clix.{BUNDLE_ID} + + + +``` + +**Notification Service Extension Entitlements** (`{ExtensionName}/{ExtensionName}.entitlements`): + +```xml + + + + + com.apple.security.application-groups + + group.clix.{BUNDLE_ID} + + + +``` + +**Note:** Replace `{BUNDLE_ID}` with the actual bundle identifier (e.g., `com.example.myapp`). + +### Phase 4: Apple Developer Portal Configuration + +Guide user through manual portal configuration. These steps CANNOT be automated. + +**Enable Capabilities on App ID:** +```text +1. Go to https://developer.apple.com/account +2. Navigate to "Certificates, Identifiers & Profiles" +3. Select "Identifiers" from the sidebar +4. Find and click your App ID (Bundle ID) +5. Scroll down to "Capabilities" section +6. Enable "Push Notifications" + - You may need to configure certificates (Development/Production) +7. Enable "App Groups" +8. Click "Save" +``` + +**Register App Group ID:** +```text +1. In the sidebar, select "Identifiers" +2. Click the "+" button +3. Select "App Groups" and click "Continue" +4. Enter: + - Description: Clix SDK App Group for {App Name} + - Identifier: group.clix.{BUNDLE_ID} +5. Click "Continue" then "Register" +6. Go back to your App ID and associate the App Group: + - Edit your App ID + - Under "App Groups", click "Configure" + - Select the App Group you just created + - Click "Save" +``` + +**Regenerate Provisioning Profile:** +```text +After enabling capabilities, your provisioning profiles become invalid. + +1. Navigate to "Profiles" in the sidebar +2. Find your Development and/or Distribution profile +3. Click on the profile +4. Click "Edit" or delete and recreate the profile +5. Ensure the updated App ID is selected +6. Download the new profile + +In Xcode: +1. Go to Xcode > Settings (or Preferences) > Accounts +2. Select your Apple ID +3. Click "Download Manual Profiles" + Or: Delete old profiles and let Xcode auto-manage +``` + +### Phase 5: Verification + +After configuration, verify the setup and output a report. + +**Check Entitlements Files:** +- Main app entitlements contains `aps-environment` +- Main app entitlements contains `com.apple.security.application-groups` +- Extension entitlements contains matching App Group ID + +**Check project.pbxproj (if accessible):** +- Look for `SystemCapabilities` dictionary +- Verify `com.apple.Push` is enabled +- Verify `com.apple.ApplicationGroups.iOS` is enabled + +**Output Verification Report:** + +```json +{ + "project": "{project_name}", + "bundleId": "{bundle_id}", + "capabilities": { + "pushNotifications": { + "entitlementFile": true, + "environment": "development", + "xcodeCapability": "verify manually in Xcode", + "developerPortal": "verify manually at developer.apple.com" + }, + "appGroups": { + "groupId": "group.clix.{bundle_id}", + "mainAppEntitlement": true, + "extensionEntitlement": true, + "developerPortal": "verify manually at developer.apple.com" + } + }, + "nextSteps": [ + "Verify capabilities are added in Xcode Signing & Capabilities", + "Confirm App Group ID is registered in Apple Developer Portal", + "Regenerate provisioning profiles if needed", + "Build and run to verify no signing errors" + ] +} +``` + +## Common Issues and Solutions + +### Missing Entitlements File + +**Symptom:** No `.entitlements` file exists in the project. + +**Solution:** +- Xcode automatically creates one when you add your first capability +- Or create manually and link in Build Settings: + 1. Create `{AppName}.entitlements` file + 2. In Xcode, select target > Build Settings + 3. Search for "Code Signing Entitlements" + 4. Set the path to your entitlements file + +### App Group ID Mismatch + +**Symptom:** Data not shared between app and extension. + +**Solution:** +- Verify the App Group ID is EXACTLY the same in both targets +- Format must be: `group.clix.{BUNDLE_ID}` +- Check both entitlements files have identical values + +### Provisioning Profile Invalid + +**Symptom:** "Provisioning profile doesn't include the X capability" error. + +**Solution:** +1. Go to Apple Developer Portal +2. Delete the old provisioning profile +3. Create a new one with the updated App ID +4. Download and install in Xcode +5. Or enable "Automatically manage signing" in Xcode + +### Push Notifications Not Working + +**Symptom:** Push notifications not received. + +**Checklist:** +- [ ] Push Notifications capability added in Xcode +- [ ] `aps-environment` in entitlements (check value matches build config) +- [ ] Push Notifications enabled on App ID in Developer Portal +- [ ] APNs certificate or key configured in Clix console +- [ ] Provisioning profile regenerated after enabling capability +- [ ] Physical device used (simulator doesn't receive push) + +### App Group Data Not Shared + +**Symptom:** MMKV data not accessible from extension. + +**Checklist:** +- [ ] App Groups capability added to BOTH main app AND extension +- [ ] Same App Group ID in both targets' entitlements +- [ ] App Group ID registered in Developer Portal +- [ ] App Group associated with App ID in Developer Portal + +## Automation Rules + +**CAN automate (use Write/Edit tools):** +- Creating entitlements files +- Modifying existing entitlements files +- Reading project configuration files +- Detecting current capabilities status + +**CANNOT automate (provide instructions only):** +- Adding capabilities in Xcode UI (Signing & Capabilities tab) +- Enabling capabilities in Apple Developer Portal +- Registering App Group IDs in Developer Portal +- Generating/downloading provisioning profiles +- Associating App Groups with App IDs + +For manual steps, provide clear instructions and proceed without waiting for confirmation. + +## Output Format + +After completing the workflow, summarize: + +1. **Files Created/Modified** + - List all entitlements files with full paths + - Show what was added or changed + +2. **Manual Steps Required** + - Xcode capability additions + - Developer Portal configurations + +3. **Verification Checklist** + - JSON report with status of each component + - Next steps for user to complete + +4. **Troubleshooting Tips** + - Common issues to watch for based on project state diff --git a/src/ui/IosSetupUI.tsx b/src/ui/IosSetupUI.tsx new file mode 100644 index 0000000..681d111 --- /dev/null +++ b/src/ui/IosSetupUI.tsx @@ -0,0 +1,390 @@ +import { Box, Text, useApp } from 'ink'; +import type React from 'react'; +import { useEffect, useState } from 'react'; +import { + type AgentContext, + analyzeIosProject, + buildAgentContext, + type CapabilitySyncResult, + createAuthContext, + generateAppGroupId, + getAppleApiErrorMessage, + getEntitlementsPath, + getIosProjectDir, + type IosProjectInfo, + loadApiKeyFromFile, + readEntitlements, + syncCapabilities, + updateEntitlementsForClix, +} from '@/lib/ios'; +import { Header } from '@/ui/components/Header'; +import { StatusMessage } from '@/ui/components/StatusMessage'; + +type SetupPhase = + | 'analyzing' + | 'authenticating' + | 'syncing' + | 'updating_entitlements' + | 'complete' + | 'error'; + +export interface IosSetupOptions { + /** Path to .p8 API Key file */ + apiKeyPath?: string; + /** API Key ID */ + keyId?: string; + /** Issuer ID */ + issuerId?: string; + /** Bundle ID (override auto-detection) */ + bundleId?: string; + /** Skip Apple Developer Portal sync */ + skipPortal?: boolean; + /** Push notification environment */ + pushEnvironment?: 'development' | 'production'; +} + +export interface IosSetupResult { + success: boolean; + projectInfo?: IosProjectInfo; + portalSync?: CapabilitySyncResult; + entitlementsUpdated: string[]; + error?: string; + /** Context for agent to complete remaining tasks */ + agentContext?: AgentContext; +} + +interface IosSetupUIProps { + options: IosSetupOptions; + onComplete?: (result: IosSetupResult) => void; +} + +interface SetupState { + phase: SetupPhase; + projectInfo: IosProjectInfo | null; + portalResult: CapabilitySyncResult | null; + updatedFiles: string[]; + errorMessage: string; +} + +/** + * Sync capabilities with Apple Developer Portal + */ +async function syncWithPortal( + options: IosSetupOptions, + bundleId: string, + appGroupId: string, + setPhase: (phase: SetupPhase) => void, +): Promise { + // Check for partial credentials - user provided some but not all + const hasAnyCreds = !!(options.apiKeyPath || options.keyId || options.issuerId); + const hasAllCreds = !!(options.apiKeyPath && options.keyId && options.issuerId); + + if (!options.skipPortal && hasAnyCreds && !hasAllCreds) { + throw new Error( + 'Incomplete portal credentials. Provide --api-key, --key-id, and --issuer-id together, or use --skip-portal.', + ); + } + + if (options.skipPortal || !hasAllCreds) { + return null; + } + + // At this point, hasAllCreds is true, so all credentials are defined + const { apiKeyPath, keyId, issuerId } = options as Required< + Pick + >; + + setPhase('authenticating'); + const keyP8 = loadApiKeyFromFile(apiKeyPath); + const authContext = await createAuthContext({ + apiKey: { + keyId, + issuerId, + keyP8, + }, + }); + + setPhase('syncing'); + return await syncCapabilities(authContext, bundleId, appGroupId); +} + +interface EntitlementsUpdateResult { + files: string[]; + entitlementsPath: string; + iosDir: string; +} + +/** + * Update local entitlements files + */ +async function updateEntitlements( + project: IosProjectInfo, + bundleId: string, + options: IosSetupOptions, +): Promise { + const iosDir = getIosProjectDir(process.cwd()); + if (!iosDir || project.targets.length === 0) { + return null; + } + + const files: string[] = []; + const mainTarget = project.targets[0]; + const entitlementsPath = getEntitlementsPath(iosDir, mainTarget); + + const existing = + project.entitlementsFiles.length > 0 + ? await readEntitlements(project.entitlementsFiles[0]) + : null; + + await updateEntitlementsForClix(entitlementsPath, bundleId, { + pushEnvironment: options.pushEnvironment || 'development', + existingEntitlements: existing, + }); + + files.push(entitlementsPath); + return { files, entitlementsPath, iosDir }; +} + +/** + * Run the iOS setup process + */ +async function runSetup( + options: IosSetupOptions, + setState: React.Dispatch>, +): Promise { + const result: IosSetupResult = { + success: false, + entitlementsUpdated: [], + }; + + // Phase 1: Analyze iOS project + setState((s) => ({ ...s, phase: 'analyzing' })); + const analysisResult = await analyzeIosProject(process.cwd()); + + if (!analysisResult.success || !analysisResult.project) { + throw new Error(analysisResult.error || 'Failed to analyze iOS project'); + } + + const project = analysisResult.project; + setState((s) => ({ ...s, projectInfo: project })); + result.projectInfo = project; + + const bundleId = options.bundleId || project.bundleId; + const appGroupId = generateAppGroupId(bundleId); + + // Phase 2: Sync with Apple Developer Portal (if not skipped) + const syncResult = await syncWithPortal(options, bundleId, appGroupId, (phase) => + setState((s) => ({ ...s, phase })), + ); + if (syncResult) { + setState((s) => ({ ...s, portalResult: syncResult })); + result.portalSync = syncResult; + } + + // Phase 3: Update local entitlements files + setState((s) => ({ ...s, phase: 'updating_entitlements' })); + const entitlementsResult = await updateEntitlements(project, bundleId, options); + + if (!entitlementsResult) { + throw new Error( + 'Failed to update entitlements files. No iOS project directory or targets found.', + ); + } + + const files = entitlementsResult.files; + setState((s) => ({ ...s, updatedFiles: files })); + result.entitlementsUpdated = files; + + // Build agent context for remaining tasks + result.agentContext = buildAgentContext( + project, + bundleId, + appGroupId, + entitlementsResult.entitlementsPath, + entitlementsResult.iosDir, + ); + + result.success = true; + setState((s) => ({ ...s, phase: 'complete' })); + return result; +} + +export const IosSetupUI: React.FC = ({ options, onComplete }) => { + const { exit } = useApp(); + const [state, setState] = useState({ + phase: 'analyzing', + projectInfo: null, + portalResult: null, + updatedFiles: [], + errorMessage: '', + }); + + const { phase, projectInfo, portalResult, updatedFiles, errorMessage } = state; + + useEffect(() => { + const execute = async () => { + try { + const result = await runSetup(options, setState); + setTimeout(() => { + onComplete?.(result); + if (!onComplete) exit(); + }, 1500); + } catch (error) { + const message = error instanceof Error ? getAppleApiErrorMessage(error) : String(error); + setState((s) => ({ ...s, errorMessage: message, phase: 'error' })); + + setTimeout(() => { + onComplete?.({ success: false, entitlementsUpdated: [], error: message }); + if (!onComplete) exit(); + }, 1500); + } + }; + + execute(); + }, [options, onComplete, exit]); + + return ( + +
+ + {/* Phase: Analyzing */} + {phase === 'analyzing' && } + + {/* Phase: Authenticating */} + {phase === 'authenticating' && ( + + + + + )} + + {/* Phase: Syncing */} + {phase === 'syncing' && ( + + + + + + )} + + {/* Phase: Updating Entitlements */} + {phase === 'updating_entitlements' && ( + + + + + + )} + + {/* Phase: Complete */} + {phase === 'complete' && ( + + )} + + {/* Phase: Error */} + {phase === 'error' && ( + + + + + )} + + ); +}; + +// Helper components to reduce complexity + +const ProjectInfoStatus: React.FC<{ projectInfo: IosProjectInfo | null }> = ({ projectInfo }) => { + if (!projectInfo) return null; + return ( + + ); +}; + +const PortalSyncStatus: React.FC<{ + portalResult: CapabilitySyncResult | null; + skipPortal?: boolean; +}> = ({ portalResult, skipPortal }) => { + if (skipPortal) { + return ; + } + if (!portalResult) return null; + return ( + + + {portalResult.enabled.length > 0 && ( + + Enabled: {portalResult.enabled.join(', ')} + + )} + {portalResult.alreadyEnabled.length > 0 && ( + + Already enabled: {portalResult.alreadyEnabled.join(', ')} + + )} + + ); +}; + +const CompletePhase: React.FC<{ + projectInfo: IosProjectInfo | null; + portalResult: CapabilitySyncResult | null; + updatedFiles: string[]; + skipPortal?: boolean; +}> = ({ projectInfo, portalResult, updatedFiles, skipPortal }) => ( + + + + {portalResult && ( + + + {portalResult.enabled.length > 0 && ( + + Enabled: {portalResult.enabled.join(', ')} + + )} + {portalResult.appGroupCreated && portalResult.appGroupId && ( + + Created App Group: {portalResult.appGroupId} + + )} + + )} + + {skipPortal && ( + + )} + + + {updatedFiles.map((file) => ( + + • {file} + + ))} + + + + ✓ iOS setup completed successfully! + + + + {/* Next steps */} + + Next steps: + + 1. Open your project in Xcode + 2. Go to Signing & Capabilities tab + 3. Verify the capabilities are enabled + {!skipPortal && 4. Regenerate provisioning profiles if needed} + + + +);